Files
isaiah/app/client/assets/js/isaiah.js
2024-01-04 01:24:18 +01:00

2710 lines
74 KiB
JavaScript

/**
* This file holds absolutely all the logic for the app
* on the frontend
*
* It is responsible for handling :
* - keyboard navigation
* - mouse navigation
* - websocket transactions
* - ui rendering
* - remote commands execution
*
* The app logic is operated mostly like in a video game :
* - First loop :
* - 1. key press
* - 2. run command(s)
* - 3. update local state
* - 4. refresh render
* - Second loop :
* - 1. message received from server
* - 2. run command(s)
* - 3. update local state
* - 4. refresh render
* - Third loop :
* - 1. mouse click
* - 2. run command(s)
* - 3. update local state
* - 4. refresh render
*
* A command usually falls into one of two categories :
* - Local : Update local state in prevision for future render
* - Remote : Send a command to the server via websocket
* - Additionally, a command can be private or public, a.k.a
* directly mapped to a key press (public), or used
* internally to facilitate some operations (private).
*/
((window) => {
// === Handy methods and aliases
/**
* @param {string} s
* @returns {HTMLElement}
*/
const q = (s) => document.querySelector(s);
/**
* @param {string} s
* @returns {Array<HTMLElement>}
*/
const qq = (s) => [...document.querySelectorAll(s)];
/**
* @param {string} s
* @returns {boolean}
*/
const e = (s) => (document.querySelector(s) ? true : false);
/**
* Prevent xss
* @param {string} str
* @returns {string}
*/
const s = (str) => {
return str
.replace(/javascript:/gi, '')
.replace(/[^\w-_. ]/gi, function (c) {
return `&#${c.charCodeAt(0)};`;
});
};
/**
* Prevent artifacts from CLI color codes
* @param {string} str
* @returns {string}
*/
const removeEscapeSequences = (str) =>
str.replace(
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
''
);
// === Handy HTML-querying methods
/**
* @returns {HTMLElement}
*/
const hgetApp = () => q(`.app-wrapper`);
/**
* @returns {HTMLElement}
*/
const hgetPopupContainer = () => q(`.popup-layer`);
/**
* @param {string} key
* @returns {HTMLElement}
*/
const hgetScreen = (key) => q(`.screen.for-${key}`);
/**
* @param {string} key
* @returns {HTMLElement}
*/
const hgetTab = (key) => q(`.tab.for-${key}`);
/**
* @param {string} key
* @returns {HTMLElement}
*/
const hgetPopup = (key) => q(`.popup.for-${key}`);
/**
* @param {string} key
* @returns {Array<HTMLElement>}
*/
const hgetTabRows = (key) => qq(`.tab.for-${key} .rows`);
/**
* @param {string} key
* @returns {Array<HTMLElement>}
*/
const hgetPopupRows = (key) => qq(`.popup.for-${key} .rows`);
/**
* @param {string} key
* @param {number} index (1-indexed)
* @returns {HTMLElement}
*/
const hgetTabRow = (key, index) =>
q(`.tab.for-${key} .row:nth-of-type(${index})`);
/**
* @param {string} key
* @param {number} index (1-indexed)
* @returns {HTMLElement}
*/
const hgetPopupRow = (key, index) =>
q(`.popup.for-${key} .row:nth-of-type(${index})`);
/**
* @param {string} key
* @returns {HTMLElement}
*/
const hgetHelper = (key) => q(`.help.for-${key}`);
/**
* @param {string} key
* @returns {HTMLElement}
*/
const hgetConnectionIndicator = (key) => q(`.indicator.for-${key}`);
/**
* @returns {Array<HTMLElement>}
*/
const hgetConnectionIndicators = () => qq(`.indicator`);
/**
* @returns {HTMLElement}
*/
const hgetTtyInput = () => q('#tty-input');
/**
* @returns {HTMLElement}
*/
const hgetPromptInput = () => q('#prompt-input');
// === Render-related methods
/**
* @param {object} action
* @param {string} action.Prompt
* @param {string} action.PromptInput
* @param {string} action.Command
* @param {string} action.Key
* @param {string} action.Label
* @param {boolean} action.RequiresResource
* @param {boolean} action.RunLocally
* @param {number} maxKeyWidth
* @returns {string}
*/
const renderMenuAction = (action, maxKeyWidth) => {
let html = `<button type="button" class="row"`;
if (action.Prompt) html += ` data-prompt="${action.Prompt}"`;
if (action.PromptInput)
html += ` data-prompt-input="${action.PromptInput}"`;
if (action.RequiresResource) html += ` data-use-row="true"`;
if (action.RunLocally) html += ` data-run-locally="true"`;
html += ` data-command="${action.Command}">`;
if (action.Key)
html += `<span class="cell">${s(
action.Key.padEnd(maxKeyWidth + 1, whitespace)
)}</span>`;
html += `<span class="cell">${action.Label}</span>`;
html += `</button>`;
return html;
};
/**
* @returns {string}
*/
const renderMenuActionCancel = () => {
return `<button type="button" class="row" data-action="reject" data-cancel>
<span class="cell" data-action="reject">cancel</span>
</button>`;
};
/**
* @typedef {object} Cell
* @property {string} field
* @property {string} representation
* @property {string} value
*/
/**
* @param {object} cell
* @param {number} cell.Width
* @param {Cell|string} cell.Content
* @returns {string}
*/
const renderCell = (cell) => {
let html = `<div class="cell" data-navigate="cell" `;
// Cell object
if (typeof cell.Content === 'object') {
html += `data-value="${s(cell.Content.value)}" `;
html += `data-field="${s(cell.Content.field)}">`;
if (!cell.Content.representation)
html += `${s(cell.Content.value.padEnd(cell.Width, whitespace))}`;
else
html += `${s(
cell.Content.representation.padEnd(cell.Width, whitespace)
)}`;
}
// Raw string
else {
html += `>`;
html += `${s(cell.Content.padEnd(cell.Width, whitespace))}`;
}
html += `</div>`;
return html;
};
/**
* @param {object} content
* @param {boolean} isSubObject
* @returns {string}
*/
const renderJSON = (content, isSubObject = false) => {
let html = '';
for (const entry of Object.entries(content)) {
// prettier-ignore
html += `<div class="row ${!isSubObject ? '' : 'sub-row'} is-not-interactive is-colored is-json">`;
html += `<div class="cell">${entry[0]}:</div>`;
// Case when Array
if (Array.isArray(entry[1])) {
if (entry[1].length > 0)
for (const cell of entry[1]) {
if (typeof cell === 'object') html += renderJSON(cell, true);
else
html += `<div class="row sub-row is-not-interactive is-colored is-json">
<div class="cell">-</div>
<div class="cell is-array-value">${renderJSONCell(
cell
)}</div>
</div>`;
}
else html += `<div class="cell">[]</div>`;
// Case when Object
} else if (entry[1] !== null && typeof entry[1] === 'object')
html += renderJSON(entry[1], true);
// Case when flat value
else {
html += `<div class="cell">${renderJSONCell(entry[1])}</div>`;
}
html += `</div>`;
}
return html;
};
/**
* @param {} cell
* @returns {string}
*/
const renderJSONCell = (cell) => {
if (cell === null) return `null`;
if (Array.isArray(cell)) {
if (cell.length > 0)
for (const v of cell)
html += `<div class="row sub-row is-not-interactive is-colored is-json">
<div class="cell">-</div>
<div class="cell is-array-value">${renderJSONCell(
v
)}</div>
</div>`;
else html += `<div class="cell">[]</div>`;
}
if (typeof cell === 'object') return renderJSON(cell, true);
if (typeof cell === 'string') return cell ? `"${cell}"` : '""';
return `${cell}`;
};
/**
* Render rows with cell padding according to the longest cell of each column
* @param {Array<Row>} rows
* @returns {string}
*/
const renderRows = (rows) => {
let html = '';
let maxs = [];
for (let i = 0; i < rows[0]._representation.length; i++) maxs[i] = -1;
// Find the max length of each column
for (const row of rows) {
for (const [index, cell] of row._representation.entries()) {
// Cell object
if (typeof cell === 'object') {
if (!cell.representation)
maxs[index] = Math.max(maxs[index], cell.value.length);
else maxs[index] = Math.max(maxs[index], cell.representation.length);
}
// Raw string
else maxs[index] = Math.max(maxs[index], cell.length);
}
}
// Rows creation
for (const row of rows) {
html += '<div class="row" data-navigate="row">';
for (const [index, cell] of row._representation.entries())
html += renderCell({ Width: maxs[index], Content: cell });
html += '</div>';
}
return html;
};
/**
* @param {object} tab
* @param {string} tab.Key
* @param {string} tab.Title
* @param {Array<Row>} tab.Rows
* @returns {string}
*/
const renderTab = (tab) => {
let html = `<div class="tab for-${tab.Key}">`;
html += `<button class="tab-title" data-navigate="tab.${tab.Key}">${tab.Title}</button>`;
html += `<div class="tab-content">`;
if (tab.Rows.length > 0) {
html += renderRows(tab.Rows);
}
html += `</div>`;
/*
html += `<div class="tab-scroller">
<div class="up">▲</div>
<div class="track">
<div class="thumb"></div>
</div>
<div class="down">▼</div>
</div>`;
*/
html += `</div>`;
return html;
};
/**
* @param {Inspector} inspector
* @returns {string}
*/
const renderInspector = (inspector) => {
let html = `<div class="tab for-inspector`;
if (inspector.isEnabled) html += ` is-active`;
html += ` " data-tab="${inspector.currentTab}">`;
html += `<div class="tab-title-group">`;
for (const tabName of inspector.availableTabs) {
html += `<button class="tab-sub-title`;
if (tabName === inspector.currentTab) html += ' is-active';
html += `" data-navigate="inspector.${tabName}">${tabName}</button>`;
}
html += `</div>`;
html += `<div class="tab-content">`;
if (inspector.content.length > 0) {
// Render Inspector Content
for (const inspectorPart of inspector.content) {
switch (inspectorPart.Type) {
// Render Rows
case 'rows':
html += renderRows(inspectorPart.Content);
break;
// Render a table
case 'table':
html += `<table>`;
html += `<thead>`;
html += `<tr>`;
for (const header of inspectorPart.Content.Headers)
html += `<th>${header}</th>`;
html += `</tr>`;
html += `</thead>`;
html += '<tbody>';
for (const row of inspectorPart.Content.Rows) {
html += `<tr>`;
for (const cell of row) html += `<td>${cell}</td>`;
html += `</tr>`;
}
html += '</tbody>';
html += '</table>';
break;
// Render a JSON structure
case 'json':
html += renderJSON(inspectorPart.Content);
break;
// Render raw lines
case 'lines':
if (Array.isArray(inspectorPart.Content))
for (const line of inspectorPart.Content)
html += `<div class="row is-textual is-not-interactive">${line}</div>`;
else
html += `<div class="row is-textual is-not-interactive">${inspectorPart.Content}</div>`;
break;
}
// Empty row separator between every content part (except for raw lines)
if (inspectorPart.Type !== 'lines')
html += `<div class="row is-not-interactive"></div>`;
}
}
html += `</div>`;
/*
html += `<div class="tab-scroller">
<div class="up">▲</div>
<div class="track">
<div class="thumb"></div>
</div>
<div class="down">▼</div>
</div>`;
*/
html += `</div>`;
return html;
};
/**
* @param {Prompt} prompt
* @returns {string}
*/
const renderPrompt = (prompt) => {
const classname = prompt.isForAuthentication ? 'for-login' : '';
const title = prompt.input.isEnabled ? 'Input' : 'Confirm';
const body = prompt.input.isEnabled
? `<div class="cell">${prompt.input.name}:</div><input placeholder="${
prompt.input.placeholder
} "type="${
prompt.isForAuthentication ? 'password' : 'text'
}" id="prompt-input"/>`
: `<p class="request">${prompt.text}</p>`;
return `
<div class="popup for-prompt ${classname}">
<div class="tab is-active">
<span class="tab-title">${title}</span>
<div class="tab-content">
<div class="row is-not-interactive is-textual">
${body}
</div>
</div>
</div>
</div>
`;
};
/**
* @param {Message} message
* @returns {string}
*/
const renderMessage = (message) => {
return `
<div class="popup for-message" data-category="${message.category}" data-type="${message.type}">
<div class="tab is-active">
<span class="tab-title">${message.title}</span>
<div class="tab-content">
<div class="row is-not-interactive is-textual">
<p>${message.content}</p>
</div>
</div>
</div>
</div>
`;
};
/**
* @param {"menu"|"bulk"}
* @param {Menu} menu
* @returns {string}
*/
const renderMenu = (key, menu) => {
// Cell padding according to the longest key
let maxKeyWidth = -1;
for (const action of menu.actions)
maxKeyWidth = Math.max(maxKeyWidth, action.Key.length);
return `
<div class="popup for-${key}">
<div class="tab is-active">
<span class="tab-title">
${key === 'menu' ? 'Menu' : 'Bulk actions'}
</span>
<div class="tab-content">
${menu.actions
.map((a, i) => renderMenuAction(a, maxKeyWidth))
.join('')}
${renderMenuActionCancel()}
</div>
</div>
</div>
`;
};
/**
* @param {TTY} tty
* @returns {string}
*/
const renderTty = (tty) => {
const { history, historyCursor } = tty;
const lines = tty.lines.map(removeEscapeSequences);
let html = `
<div class="popup for-tty">
<div class="tab is-active">
<span class="tab-title">
Shell (${tty.type})
</span>
<div class="tab-content">`;
if (lines.length > 0) {
html += lines
.slice(0, -1)
.map((l) => `<div class="row is-not-interactive is-textual">${l}</div>`)
.join('');
html += `
<div class="row is-not-interactive is-textual">
<div class="cell">${lines[lines.length - 1].trim()}</div>
<input
type="text"
id="tty-input"
value="${
historyCursor >= 0 && historyCursor < history.length
? s(history[historyCursor])
: tty._tmpCommand
? s(tty._tmpCommand)
: ''
}"/>
</div>
`;
}
html += `</div>
</div>`;
return html;
};
/**
* @returns {string}
*/
const renderHelp = () => `
<div class="popup for-help">
<div class="tab is-active">
<span class="tab-title">
Help
</span>
<div class="tab-content">
<div class="row is-not-interactive">
<span class="cell">Tab </span>
<span class="cell">switch panel</span>
</div>
<div class="row is-not-interactive">
<span class="cell">← → </span>
<span class="cell">switch panel / scroll</span>
</div>
<div class="row is-not-interactive">
<span class="cell">↑ ↓ </span>
<span class="cell">switch row / scroll</span>
</div>
<div class="row is-not-interactive">
<span class="cell">[ ] </span>
<span class="cell">switch inspector tab</span>
</div>
<div class="row is-not-interactive">
<span class="cell">- + </span>
<span class="cell">switch layout</span>
</div>
<div class="row is-not-interactive">
<span class="cell">1234 </span>
<span class="cell">go to panel</span>
</div>
<div class="row is-not-interactive"></div>
<div class="row is-not-interactive">
<span class="cell">y n </span>
<span class="cell">confirm/reject</span>
</div>
<div class="row is-not-interactive">
<span class="cell">Enter </span>
<span class="cell">confirm/submit</span>
</div>
<div class="row is-not-interactive">
<span class="cell">Escape </span>
<span class="cell">reject/exit inspector</span>
</div>
<div class="row is-not-interactive"></div>
<div class="row is-not-interactive">
<span class="cell">x </span>
<span class="cell">open menu</span>
</div>
<div class="row is-not-interactive">
<span class="cell">b </span>
<span class="cell">view bulk commands</span>
</div>
<div class="row is-not-interactive"></div>
<div class="row is-not-interactive">
<span class="cell">S </span>
<span class="cell">open system shell</span>
</div>
<div class="row is-not-interactive">
<span class="cell">R </span>
<span class="cell">reload everything/inspector</span>
</div>
<div class="row is-not-interactive">
<span class="cell">G </span>
<span class="cell">open project on Github</span>
</div>
<div class="row is-not-interactive"></div>
<div class="row is-not-interactive">
<span class="cell">Ctrl+C </span>
<span class="cell">clear command (shell)</span>
</div>
<div class="row is-not-interactive">
<span class="cell">Ctrl+L </span>
<span class="cell">clear screen (shell)</span>
</div>
<div class="row is-not-interactive">
<span class="cell">Ctrl+D </span>
<span class="cell">quit (shell)</span>
</div>
<div class="row is-not-interactive">
<span class="cell">↑ ↓ </span>
<span class="cell">cycle history (shell)</span>
</div>
<div class="row is-not-interactive"></div>
<div class="row is-not-interactive">
<span class="cell">q </span>
<span class="cell">cancel/close/quit</span>
</div>
</div>
</div>
</div>
`;
/**
* Main rendering function, responsible for updating the DOM
* from scratch, using the supplied _state argument
*
* @param {state} _state
*/
const renderApp = (_state) => {
let html;
// 0. Determine screen to display
if (!_state.hasEstablishedConnection)
hgetScreen('loading').classList.add('is-active');
else {
hgetScreen('loading').classList.remove('is-active');
if (_state.isAuthenticated)
hgetScreen('dashboard').classList.add('is-active');
}
// 1. Reset DOM
// 1.0. Set app layout
hgetScreen('dashboard').setAttribute(
'data-layout',
_state.appearance.currentLayout
);
// 1.1. Erase overview tabs
hgetScreen('dashboard').querySelector('.left').innerHTML = '';
// 1.2. Erase inspector
hgetScreen('dashboard').querySelector('.right').innerHTML = '';
// 1.3. Erase popup
hgetPopupContainer().innerHTML = '';
// 1.4. Hide popup layer
hgetPopupContainer().classList.remove('is-active');
// 1.5. Hide helper
if (e('.help.is-active'))
q('.help.is-active').classList.remove('is-active');
// 1.6. Hide connection indicators
hgetConnectionIndicators().forEach((i) => i.classList.remove('is-active'));
// 2. Build every tab
html = _state.tabs.map(renderTab).join('');
hgetScreen('dashboard').querySelector('.left').innerHTML = html;
// 3. Build inspector
html = renderInspector(_state.inspector);
hgetScreen('dashboard').querySelector('.right').innerHTML = html;
// 4. Build current popup
if (state.popup) {
hgetPopupContainer().classList.add('is-active');
html = '';
// 4.1. Popup - Prompt
if (_state.prompt.isEnabled) html = renderPrompt(_state.prompt);
// 4.2. Popup - Message
else if (_state.message.isEnabled) html = renderMessage(_state.message);
// 4.3. Popup - Menu
else if (_state.menu.actions.length > 0)
html = renderMenu(_state.popup, _state.menu);
// 4.4. Popup - Tty
else if (_state.tty.isEnabled) html = renderTty(_state.tty);
// 4.5. Popup - Help
else if (_state.popup === 'help') html = renderHelp();
hgetPopupContainer().innerHTML = html;
}
// 5. Set focus on DOM elements
// 5.1. Set focus on inspector
if (_state.inspector.isEnabled)
hgetTab('inspector').classList.add('is-active');
// 5.1.1. Scroll horizontally on the inspector
hgetTab('inspector').querySelector('.tab-content').scrollLeft =
_state.inspector.horizontalScroll;
// 5.1.2. Scroll vertically on the inspector
hgetTab('inspector').querySelector('.tab-content').scrollTop =
_state.inspector.verticalScroll;
// 5.1.3. Scroll down the inspector if it's for logs
if (_state.inspector.content.length > 0) {
if (_state.inspector.content[0].Type === 'lines') {
const _inspector = hgetTab('inspector');
const _content = _inspector.querySelector('.tab-content');
_content.scrollTo(
_state.inspector.horizontalScroll,
_content.scrollHeight
);
}
}
// 5.2. Set focus on tab
if (_state.navigation.currentTab) {
hgetTab(_state.navigation.currentTab).classList.add('is-active');
// 5.2.1. Set focus on row - Tab
const currentRow = hgetTabRow(
_state.navigation.currentTab,
_state.navigation.currentTabsRows[_state.navigation.currentTab]
);
currentRow.classList.add('is-active');
currentRow.scrollIntoView({ block: 'nearest', inline: 'nearest' });
/*
* Disabled part - Works for panels, but not for inspector
* Should be reworked for handling any type of
* content
* Native scroll is used in place
*/
/*
// 5.2.2. Update current tab scroll indicator
const currentTab = hgetTab(_state.navigation.currentTab);
const currentTabContent = currentTab.querySelector('.tab-content');
// 5.2.2.1. Define whether the scrollbar should show
if (currentTabContent.scrollHeight > currentTabContent.clientHeight) {
currentTab.classList.add('is-scrollable');
// 5.2.2.1.1. Define the height and position of the thumb
const thumb = currentTab.querySelector('.thumb');
const trackHeight = currentTab.querySelector('.track').clientHeight;
const rowCount = currentTabContent.children.length;
const rowHeight = currentRow.clientHeight;
const rowIndex =
Array.from(currentTabContent.children).indexOf(currentRow) + 1;
const visibleRowCount = Math.floor(
currentTabContent.clientHeight / rowHeight
);
const stepCount = Math.ceil(rowCount / visibleRowCount) + 1; // + 1 : account for last row mechanism
const stepHeight = trackHeight / stepCount;
thumb.style.top = '0';
thumb.style.height = `${stepHeight}px`;
if (rowIndex > visibleRowCount) {
// This mechanism makes the thumb reach the end only when the last row is focused
if (rowIndex === rowCount)
thumb.style.top = `${stepHeight * (stepCount - 1)}px`;
else
thumb.style.top = `${
stepHeight * Math.floor(rowIndex / visibleRowCount)
}px`;
}
}
*/
}
// 5.3. Set focus on menu
if (_state.isMenuIng) {
// 5.3.1. Set focus on row - Menu
if (_state.menu.actions.length > 0)
hgetPopupRow(
_state.popup,
_state.navigation.currentMenuRow
).classList.add('is-active');
}
// 5.4. Set focus on tty
if (_state.tty.isEnabled && _state.tty.lines.length > 0) {
const ttyInput = hgetTtyInput();
ttyInput.focus();
ttyInput.scrollIntoView({ block: 'nearest', inline: 'nearest' });
// 5.4.1. Focus end of input
setTimeout(() => {
ttyInput.selectionStart = ttyInput.selectionEnd = ttyInput.value.length;
}, _state._delays.default / 2);
}
// 5.5. Set focus on prompt input
if (_state.prompt.isEnabled && _state.prompt.input.isEnabled) {
// Dev-only (case when the server spontaneously tells us we're authenticated)
if (hgetPromptInput()) hgetPromptInput().focus();
}
// 5.6. Set flag on previous/current tab for the "focus" layout
if (_state.navigation.currentTab)
hgetTab(_state.navigation.currentTab).classList.add('is-current');
else if (_state.navigation.previousTab)
hgetTab(_state.navigation.previousTab).classList.add('is-current');
// 6. Set helper
hgetHelper(_state.helper).classList.add('is-active');
// 7. Set connection indicator
hgetConnectionIndicator(
_state.isConnected ? 'connected' : 'disconnected'
).classList.add('is-active');
if (_state.isLoading)
hgetConnectionIndicator('loading').classList.add('is-active');
};
// === Websocket-related methods
/**
* Initiate a Websocket connection with the remote server
*/
const websocketConnect = () => {
const socket = new WebSocket(
`${!wsSSL ? 'ws' : 'wss'}://${wsHost}:${wsPort}/ws`
);
socket.onopen = listenerSocketOpen;
socket.onmessage = listenerSocketMessage;
socket.onerror = listenerSocketError;
socket.onclose = listenerSocketClose;
wsSocket = socket;
};
/**
* Send an object as a JSON string over Websocket
* @param {object} o
*/
const websocketSend = (o) => {
wsSocket.send(JSON.stringify(o));
};
// === State
let state = {
/**
* @type {boolean}
*/
hasEstablishedConnection: false,
/**
* @type {boolean}
*/
isConnected: false,
/**
* @type {boolean}
*/
isAuthenticated: false,
/**
* @type {boolean}
*/
isLoading: false,
/**
* @returns {boolean}
*/
get isMenuIng() {
return state.popup && ['menu', 'bulk'].includes(state.popup);
},
/**
* @typedef {object} Prompt
* @property {string} text
* @property {PromptInput} input
* @property {function} callback
* @property {Array} callbackArgs
* @property {boolean} isEnabled
* @property {boolean} isForAuthentication
*/
/**
* @typedef {object} PromptInput
* @property {boolean} isEnabled
* @property {string} placeholder
* @property {string} name
*/
/**
* @type {Prompt}
*/
prompt: {
text: null,
input: { isEnabled: false, name: null, placeholder: null },
callback: null,
callbackArgs: [],
isEnabled: false,
},
/**
* @typedef {object} Menu
* @property {Array<MenuAction>} actions
*/
/**
* @typedef {object} MenuAction
* @property {string} Prompt
* @property {string} Command
* @property {string} Key
* @property {string} Label
* @property {boolean} RequiresResource
* @property {boolean} RunLocally
*/
/**
* @type {Menu}
*/
menu: {
/**
* @type {Array<MenuAction>}
*/
actions: [],
},
/**
* @type {'default'|'menu'|'prompt'|'prompt-input'|'message'}
*/
helper: 'default',
/**
* @type {"menu"|"bulk"|"prompt"|"message"|"tty"|"help"}
*/
popup: null,
appearance: {
/**
* @type {Array<string>}
*/
availableLayouts: ['default', 'half', 'focus'],
/**
* @type {"default"|"half"|"focus"}
*/
currentLayout: 'default',
},
/**
* @typedef {object} Message
* @property {boolean} isEnabled
* @property {string} category
* @property {string} type
* @property {string} title
* @property {string} content
*/
/**
* @type {Message}
*/
message: {
isEnabled: false,
category: null,
type: null,
title: null,
content: null,
},
/**
* @typedef Navigation
* @property {string} currentTab
* @property {string} previousTab
* @property {object.<string, number>} currentTabsRows - Used for tabs
* @property {number} currentMenuRow
* @property {number} previousMenuRow
*/
/**
* @type {Navigation}
*/
navigation: {
currentTab: null,
previousTab: null,
currentTabsRows: {},
currentMenuRow: null,
previousMenuRow: null,
},
/**
* @typedef Inspector
* @property {boolean} isEnabled
* @property {boolean} wasEnabled
* @property {string} currentTab
* @property {string} previousTab
* @property {Array<string>} availableTabs
* @property {Array<Row|Table|object>} content
* @property {number} horizontalScroll
* @property {number} verticalScroll
*/
/**
* @type Inspector
*/
inspector: {
isEnabled: false,
wasEnabled: false,
currentTab: null,
previousTab: null,
availableTabs: [],
content: [],
horizontalScroll: 0,
verticalScroll: 0,
},
/**
* @typedef Row
* @property {Array<string>} _representation
*/
/**
* @typedef Tab
* @property {string} Key
* @property {string} Title
* @property {Array<Row>} Rows
*/
/**
* @type {Array<Tab>}
*/
tabs: [],
/**
* @typedef TTY
* @property {bool} isEnabled
* @property {Array<string>} lines
* @property {Array<string>} history
* @property {number} historyCursor
* @property {"system"|"container"|"volume"} type
* @property {string} _buffer
* @property {string} _tmpCommand
*/
/**
* @type TTY
*/
tty: {
isEnabled: false,
lines: [],
history: [],
historyCursor: -1,
type: null,
_buffer: '',
_tmpCommand: null,
},
_delays: {
/**
* @type {number}
*/
forAuthentication: 2000,
/**
* @type {number}
*/
forTTYBufferClear: 50,
/**
* @type {number}
*/
forTTYInputFocus: 50,
/**
* @type {number}
*/
default: 250,
},
};
// === State-related handy methods
/**
* @returns {string}
*/
const sgetCurrentTabKey = () => {
return !state.navigation.currentTab && state.navigation.previousTab
? state.navigation.previousTab
: state.navigation.currentTab;
};
/**
* @returns {Tab}
*/
const sgetCurrentTab = () => {
const currentTabKey = sgetCurrentTabKey();
return state.tabs.find((t) => t.Key === currentTabKey);
};
/**
* @returns {Row}
*/
const sgetCurrentRow = () => {
const currentTab = sgetCurrentTab();
return currentTab.Rows[
state.navigation.currentTabsRows[currentTab.Key] - 1
];
};
// === Commands-related methods
/**
* @param {string} cmd
* @returns {boolean}
*/
const cmdAllowed = (cmd) => {
// Prevent running anyrhing when not connected to the remote server
if (!state.isConnected) return false;
// Prevent running anything while loading
if (state.isLoading) return false;
// if (
// state.isLoading &&
// ![
// 'scrollUp',
// 'scrollDown',
// 'scrollLeft',
// 'scrollRight',
// 'nextTab',
// 'previousTab',
// 'nextSubTab',
// 'previousSubTab',
// ].includes(cmd)
// )
// return false;
// Prevent running anything other than submit while unauthenticated
if (!state.isAuthenticated && !['confirm'].includes(cmd)) return false;
// Force yes/no on prompts && messages
if (
(state.prompt.isEnabled ||
state.message.isEnabled ||
(state.popup && !state.isMenuIng)) &&
!['confirm', 'reject', 'quit'].includes(cmd)
)
return false;
// Force yes/no/arrows on menus,
if (
state.isMenuIng &&
!['scrollUp', 'scrollDown', 'confirm', 'reject', 'quit'].includes(cmd)
)
return false;
// Prevent multi-popup
if (
state.popup &&
['menu', 'bulk', 'prompt', 'message', 'shell'].includes(cmd)
)
return false;
// Prevent multi-inspect
if (state.inspector.isEnabled && ['confirm'].includes(cmd)) return false;
return true;
};
/**
* @param {function} cmd
* @param {Array} args
*/
const cmdRun = (cmd, ...args) => {
const cmdString = cmd.name;
const isKnown = cmdString in cmds;
// console.log(cmdString);
if (!isKnown) return;
const isPrivate = cmdString[0] === '_';
const isAllowed = isPrivate ? true : cmdAllowed(cmdString);
if (!isAllowed) return;
cmd(...args);
renderApp(state);
};
// === Commands
const cmds = {
/**
* Private - An empty function used to trigger the render loop
*/
_render: function () {},
/**
* Private - Request the initial data from the server
*/
_init: function () {
websocketSend({ action: 'init' });
},
/**
* Private - Log out
*/
_exit: function () {
state.isAuthenticated = false;
websocketSend({ action: 'auth.logout' });
setTimeout(() => {
cmdRun(cmds._showAuthentication);
}, state._delays.forTTYInputFocus);
},
/**
* Private - Show a blocking prompt to the user
* @param {Prompt} args
*/
_showPrompt: function (args) {
state.popup = 'prompt';
state.helper = args.input ? 'prompt-input' : 'prompt';
state.prompt.text = args.text;
state.prompt.callback = args.callback;
state.prompt.callbackArgs = args.callbackArgs || [];
state.prompt.isEnabled = true;
state.prompt.input = args.input || {
isEnabled: false,
placeholder: null,
name: null,
};
state.prompt.isForAuthentication = args.isForAuthentication || false;
if (state.navigation.currentTab) {
state.navigation.previousTab = state.navigation.currentTab;
state.navigation.currentTab = null;
}
if (state.navigation.currentMenuRow) {
state.navigation.previousMenuRow = state.navigation.currentMenuRow;
state.navigation.currentMenuRow = null;
}
if (state.inspector.isEnabled) {
state.inspector.wasEnabled = true;
state.inspector.isEnabled = false;
}
},
/**
* Private - Clear the last prompt and get back to previous display
*/
_clearPrompt: function () {
state.popup = null;
state.helper = 'default';
state.prompt.text = null;
state.prompt.callback = null;
state.prompt.callbackArgs = [];
state.prompt.isEnabled = false;
state.prompt.isForAuthentication = false;
state.prompt.input = { isEnabled: false, name: null, placeholder: null };
if (!state.inspector.wasEnabled) {
state.navigation.currentTab = state.navigation.previousTab;
state.navigation.currentMenuRow = state.navigation.previousMenuRow;
} else {
state.inspector.isEnabled = state.inspector.wasEnabled;
state.inspector.wasEnabled = false;
}
},
/**
* Private - Show a popup
* @param {string} key
*/
_showPopup: function (key) {
state.popup = key;
if (state.navigation.currentTab) {
state.navigation.previousTab = state.navigation.currentTab;
state.navigation.currentTab = null;
}
if (state.navigation.currentMenuRow) {
state.navigation.previousMenuRow = state.navigation.currentMenuRow;
state.navigation.currentMenuRow = 1;
}
if (state.inspector.isEnabled) {
state.inspector.wasEnabled = true;
state.inspector.isEnabled = false;
}
},
/**
* Private - Clear the last popup and get back to previous display
*/
_clearPopup: function () {
state.popup = null;
state.helper = 'default';
state.menu.actions = [];
if (!state.inspector.wasEnabled) {
state.navigation.currentTab = state.navigation.previousTab;
state.navigation.currentMenuRow = state.navigation.previousMenuRow;
} else {
state.inspector.isEnabled = state.inspector.wasEnabled;
state.inspector.wasEnabled = false;
}
},
/**
* Private - Clear the message popup and get back to the previous display
*/
_clearMessage: function () {
state.popup = null;
state.helper = 'default';
state.message.isEnabled = false;
state.message.type = null;
state.message.title = null;
state.message.content = null;
state.navigation.currentTab = state.navigation.previousTab;
state.navigation.currentMenuRow = state.navigation.previousMenuRow;
},
/**
* Private - Enter inspection mode
*/
_enterInspect: function () {
state.inspector.isEnabled = true;
// No need to check for currentTab as inspection
// is only possible from a row contained inside a tab
state.navigation.previousTab = state.navigation.currentTab;
state.navigation.currentTab = null;
},
/**
* Private - Leave inspection mode and get back to the previous display
*/
_exitInspect: function () {
state.inspector.isEnabled = false;
state.navigation.currentTab = state.navigation.previousTab;
state.navigation.currentMenuRow = state.navigation.previousMenuRow;
},
/**
* Private - Send data through the current Websocket connection
* @param {object} object
*/
_wsSend: function (object) {
websocketSend(object);
},
/**
* Private - Enable tty mode
*/
_ttyStart: function (type) {
state.tty.isEnabled = true;
cmdRun(cmds._showPopup, 'tty');
},
/**
* Private - TTY-only - Clear tty screen
*/
_ttyClear: function () {
let lastLine = state.tty.lines[state.tty.lines.length - 1];
lastLine = lastLine.split('<wbr />')[0];
state.tty.lines = [lastLine];
state.tty._tmpCommand = hgetTtyInput().value;
},
/**
* Private - TTY-only - Erase the current command
*/
_ttyErase: function () {
hgetTtyInput().value = '';
},
/**
* Private - TTY-only - Run a command through the TTY
* @param {string} command
*/
_ttyExec: function (command) {
state.tty.history.push(command);
state.tty.historyCursor = state.tty.history.length;
state.tty._tmpCommand = null;
websocketSend({
action: 'shell.command',
args: { Command: command },
});
},
/**
* Private - TTY-only - Quit and close the TTY session
*/
_ttyQuit: function () {
state.tty.isEnabled = false;
state.tty.lines = [];
state.tty.history = [];
state.tty.historyCursor = [];
cmdRun(cmds._clearPopup);
},
/**
* Private - TTY-only - Set a command from the history
*/
_ttySetHistoryPrevious: function () {
if (state.tty.history.length === 0) return;
if (state.tty.historyCursor > -1) state.tty.historyCursor--;
},
/**
* Private - TTY-only - Set a command from the history
*/
_ttySetHistoryNext: function () {
if (state.tty.history.length === 0) return;
if (state.tty.historyCursor < state.tty.history.length)
state.tty.historyCursor++;
},
/**
* Private - Image-only - Pull an image
* @param {object} args
* @param {string} args.Image
*/
_imagePull: function (args) {
if (!args.Image || args.Image.length === 0) return;
websocketSend({
action: 'image.pull',
args: { Image: args.Image },
});
},
/**
* Private - Image-only - Run an image
* @param {object} args
* @param {string} args.Name (container name)
*/
_imageRun: function (args) {
if (!args.Name || args.Name.length === 0) return;
websocketSend({
action: 'image.run',
args: { Resource: sgetCurrentRow(), Name: args.Name },
});
},
/**
* Private - Get available inspector (sub) tabs for the current tab (containers, images, etc.)
*/
_inspectorTabs: function () {
const currentTabKey = sgetCurrentTabKey();
websocketSend({ action: `${currentTabKey.slice(0, -1)}.inspect.tabs` });
},
/**
* Private - Refresh the inspector data (request new data from the server)
*/
_refreshInspector: function () {
state.inspector.content = [];
state.inspector.horizontalScroll = 0;
state.inspector.verticalScroll = 0;
const currentRow = sgetCurrentRow();
const currentTabKey = sgetCurrentTabKey();
const currentInspectorTab = state.inspector.currentTab;
// Produces something like : <tab>.inspect.<sub-tab>
// Such as : container.inspect.logs
websocketSend({
// prettier-ignore
action: `${currentTabKey.slice(0,-1)}.inspect.${currentInspectorTab.toLowerCase()}`,
args: { Resource: currentRow },
});
},
/**
* Private - Show the authentication popup
*/
_showAuthentication: function () {
cmdRun(cmds._showPrompt, {
input: {
isEnabled: true,
name: 'Password',
placeholder: 'Please fill in your server secret',
},
isForAuthentication: true,
callback: cmds._authenticate,
});
},
/**
* Private - Send password to the server as a mean for authenticating
*/
_authenticate: function () {
const password = hgetPromptInput().value;
if (!password) return;
websocketSend({ action: 'auth.login', args: { Password: password } });
},
/**
* Public - Quit the app / Quit the current popup
* Requires prompt
*/
quit: function () {
if (!state.popup) {
// prettier-ignore
cmdRun(cmds.prompt, {
text: 'Are you sure you want to quit?',
callback: cmds._exit
});
return;
}
if (state.prompt.isEnabled) {
cmdRun(cmds._clearPrompt);
return;
}
if (state.message.isEnabled) {
cmdRun(cmds._clearMessage);
return;
}
cmdRun(cmds._clearPopup);
},
/**
* Public - Show the general help
*/
help: function () {
state.helper = 'message';
cmdRun(cmds._showPopup, 'help');
},
/**
* Public - Show the menu associated with the current tab
*/
menu: function () {
const currentTab = state.inspector.isEnabled
? state.navigation.previousTab
: state.navigation.currentTab;
websocketSend({
action: `${currentTab.slice(0, -1)}.menu`,
args: { Resource: sgetCurrentRow() },
});
},
/**
* Public - Show the bulk menu associated with the current tab
*/
bulk: function () {
const currentTab = state.inspector.isEnabled
? state.navigation.previousTab
: state.navigation.currentTab;
websocketSend({ action: `${currentTab}.bulk` });
cmdRun(cmds._showPopup, 'bulk');
state.helper = 'menu';
},
/**
* Public - Show a confirm prompt
* @param {Prompt} args
*/
prompt: function (args) {
cmdRun(cmds._showPrompt, args);
},
/**
* Public - Confirm the current context
* When prompt : confirm the requested action, and run the callback associated
* When menu/popup : run the action associated with the current row
* When tab : inspect the resource associated with the current row
* When message : close the popup
*/
confirm: function () {
// Prompt confirm (run callback)
if (state.prompt.isEnabled) {
if (!state.prompt.input.isEnabled)
cmdRun(state.prompt.callback, ...state.prompt.callbackArgs);
else
cmdRun(state.prompt.callback, {
[state.prompt.input.name]: hgetPromptInput().value,
});
cmdRun(cmds._clearPrompt);
return;
}
// Message confirm (close popup)
if (state.message.isEnabled) {
cmdRun(cmds._clearMessage);
return;
}
// Menu / Bulk confirm (run action)
if (state.isMenuIng && state.navigation.currentMenuRow > 0) {
// prettier-ignore
const row = hgetPopupRow(state.popup, state.navigation.currentMenuRow);
const attributes = row.dataset;
if ('cancel' in attributes) {
cmdRun(cmds._clearPopup);
return;
}
if (attributes.prompt) {
cmdRun(cmds._showPrompt, {
text: attributes.prompt,
callback: cmds._wsSend,
callbackArgs: [
{
action: attributes.command,
args: { Resource: sgetCurrentRow() },
},
],
});
return;
}
if (!attributes.runLocally) {
if (!attributes.useRow)
cmdRun(cmds._wsSend, { action: attributes.command });
else
cmdRun(cmds._wsSend, {
action: attributes.command,
args: { Resource: sgetCurrentRow() },
});
// Can clear anytime as the command is private ("_wsSend")
cmdRun(cmds._clearPopup);
} else {
// Must clear first to enable running a command out of menu context
cmdRun(cmds._clearPopup);
if (!attributes.useRow) cmdRun(cmds[attributes.command]);
else cmdRun(cmds[attributes.command], sgetCurrentRow());
}
return;
}
// Help / Any other popup (close popup)
if (state.popup) {
cmdRun(cmds._clearPopup);
return;
}
// Tab confirm (inspect)
cmdRun(cmds._enterInspect);
},
/**
* Public - Reject the current context
* When prompt : close the prompt, and ignore the callback associated
* When menu/popup : close the popup
* When tab : do nothing
* When message : close the popup
* When inspect : exit inspect
*/
reject: function () {
if (!state.isAuthenticated) return;
if (state.prompt.isEnabled) {
cmdRun(cmds._clearPrompt);
return;
}
if (state.message.isEnabled) {
cmdRun(cmds._clearMessage);
return;
}
if (state.popup) {
cmdRun(cmds._clearPopup);
return;
}
if (state.inspector.isEnabled) {
cmdRun(cmds._exitInspect);
return;
}
},
/**
* Public - Navigate to the previous tab
*/
previousTab: function () {
if (state.inspector.isEnabled) cmdRun(cmds._exitInspect);
const currentIndex = state.tabs.findIndex(
(t) => t.Key === state.navigation.currentTab
);
let prevIndex = currentIndex - 1;
if (prevIndex === -1) prevIndex = state.tabs.length - 1;
state.navigation.currentTab = state.tabs[prevIndex].Key;
cmdRun(cmds._inspectorTabs);
},
/**
* Public - Navigate to the next tab
*/
nextTab: function () {
if (state.inspector.isEnabled) cmdRun(cmds._exitInspect);
const currentIndex = state.tabs.findIndex(
(t) => t.Key === state.navigation.currentTab
);
let nextIndex = currentIndex + 1;
if (nextIndex > state.tabs.length - 1) nextIndex = 0;
state.navigation.currentTab = state.tabs[nextIndex].Key;
cmdRun(cmds._inspectorTabs);
},
/**
* Public - Navigate to the previous inspector tab
*/
previousSubTab: function () {
const currentIndex = state.inspector.availableTabs.indexOf(
state.inspector.currentTab
);
let prevIndex = currentIndex - 1;
if (prevIndex == -1) prevIndex = state.inspector.availableTabs.length - 1;
state.inspector.currentTab =
state.inspector.availableTabs[
prevIndex % state.inspector.availableTabs.length
];
cmdRun(cmds._refreshInspector);
},
/**
* Public - Navigate to the next inspector tab
*/
nextSubTab: function () {
const currentIndex = state.inspector.availableTabs.indexOf(
state.inspector.currentTab
);
const nextIndex = currentIndex + 1;
state.inspector.currentTab =
state.inspector.availableTabs[
nextIndex % state.inspector.availableTabs.length
];
cmdRun(cmds._refreshInspector);
},
/**
* Public - Activate the next layout for the app
*/
nextLayout: function () {
const currentIndex = state.appearance.availableLayouts.indexOf(
state.appearance.currentLayout
);
if (currentIndex === state.appearance.availableLayouts.length - 1)
state.appearance.currentLayout = state.appearance.availableLayouts[0];
else
state.appearance.currentLayout =
state.appearance.availableLayouts[currentIndex + 1];
},
/**
* Public - Activate the previous layout for the app
*/
previousLayout: function () {
const currentIndex = state.appearance.availableLayouts.indexOf(
state.appearance.currentLayout
);
if (currentIndex === 0)
state.appearance.currentLayout =
state.appearance.availableLayouts[
state.appearance.availableLayouts.length - 1
];
else
state.appearance.currentLayout =
state.appearance.availableLayouts[currentIndex - 1];
},
/**
* Public - Scroll left / Navigate to previous tab
*/
scrollLeft: function () {
if (state.inspector.isEnabled) {
if (state.inspector.horizontalScroll > 0)
state.inspector.horizontalScroll -= 20;
return;
}
cmdRun(cmds.previousTab);
},
/**
* Public - Scroll right / Navigate to next tab
*/
scrollRight: function () {
if (state.inspector.isEnabled) {
const _inspector = hgetTab('inspector');
const _content = _inspector.querySelector('.tab-content');
const maxScroll = _content.scrollWidth - _content.clientWidth;
if (state.inspector.horizontalScroll < maxScroll)
state.inspector.horizontalScroll += 20;
return;
}
cmdRun(cmds.nextTab);
},
/**
* Public - Scroll down / Navigate to next row
*/
scrollDown: function () {
// Menu - Next row
if (state.isMenuIng) {
const availableRows = state.menu.actions;
state.navigation.currentMenuRow += 1;
// +1 is added to account for the extra "cancel" option
if (state.navigation.currentMenuRow > availableRows.length + 1)
state.navigation.currentMenuRow = 1;
return;
}
// Tab - Next row
if (state.navigation.currentTab) {
const availableRows = state.tabs.find(
(t) => t.Key === state.navigation.currentTab
).Rows;
state.navigation.currentTabsRows[state.navigation.currentTab] += 1;
if (
state.navigation.currentTabsRows[state.navigation.currentTab] >
availableRows.length
)
state.navigation.currentTabsRows[state.navigation.currentTab] = 1;
cmdRun(cmds._refreshInspector);
return;
}
// Inspector - Scroll down
if (state.inspector.isEnabled) {
const _inspector = hgetTab('inspector');
const _content = _inspector.querySelector('.tab-content');
const maxScroll = _content.scrollHeight - _content.clientHeight;
if (state.inspector.verticalScroll < maxScroll)
state.inspector.verticalScroll += 20;
}
},
/**
* Public - Scroll up / Navigate to previous row
*/
scrollUp: function () {
// Menu - Previous row
if (state.isMenuIng) {
const availableRows = state.menu.actions;
state.navigation.currentMenuRow -= 1;
// +1 is added to account for the extra "cancel" option
if (state.navigation.currentMenuRow < 1)
state.navigation.currentMenuRow = availableRows.length + 1;
return;
}
// Tab - Previous row
if (state.navigation.currentTab) {
const availableRows = state.tabs.find(
(t) => t.Key === state.navigation.currentTab
).Rows;
state.navigation.currentTabsRows[state.navigation.currentTab] -= 1;
if (state.navigation.currentTabsRows[state.navigation.currentTab] < 1)
state.navigation.currentTabsRows[state.navigation.currentTab] =
availableRows.length;
cmdRun(cmds._refreshInspector);
return;
}
// Inspector - Scroll up
if (state.inspector.isEnabled) {
if (state.inspector.verticalScroll > 0)
state.inspector.verticalScroll -= 20;
}
},
/**
* Public - Display the remove menu of the highlighted resource
*/
remove: function () {
websocketSend({
action: `${sgetCurrentTabKey().slice(0, -1)}.menu.remove`,
args: { Resource: sgetCurrentRow() },
});
},
/**
* Public - Container-only - Pause/Unpause
*/
pause: function () {
if (sgetCurrentTabKey() !== 'containers') return;
websocketSend({
action: `container.pause`,
args: { Resource: sgetCurrentRow() },
});
},
/**
* Public - Container-only - Stop
*/
stop: function () {
if (sgetCurrentTabKey() !== 'containers') return;
websocketSend({
action: `container.stop`,
args: { Resource: sgetCurrentRow() },
});
},
/**
* Public
* - Container-only - Restart
* - Image-only - Run
*/
run_restart: function () {
const currentTabKey = sgetCurrentTabKey();
if (currentTabKey === 'containers')
websocketSend({
action: `container.restart`,
args: { Resource: sgetCurrentRow() },
});
else if (currentTabKey === 'images')
cmdRun(cmds.prompt, {
input: {
isEnabled: true,
name: 'Name',
placeholder: 'Please fill in a name for the new container',
},
callback: cmds._imageRun,
});
},
/**
* Public - Container-only - Exec shell
*/
shellContainer: function () {
if (sgetCurrentTabKey() !== 'containers') return;
websocketSend({
action: `container.shell`,
args: { Resource: sgetCurrentRow() },
});
},
/**
* Public - System-only - Exec shell
*/
shellSystem: function () {
websocketSend({ action: `shell` });
},
/**
* Public - Container-only - Open in browser
*/
browser: function () {
if (sgetCurrentTabKey() !== 'containers') return;
websocketSend({
action: `container.browser`,
args: { Resource: sgetCurrentRow() },
});
},
/**
* Public - Image-only - Open in Docker Hub
*/
hub: function () {
if (sgetCurrentTabKey() !== 'images') return;
window.open(`https://hub.docker.com/r/${sgetCurrentRow().Name}`);
},
/**
* Public - Image-only - Pull
*/
pull: function () {
if (sgetCurrentTabKey() !== 'images') return;
cmdRun(cmds.prompt, {
input: {
isEnabled: true,
name: 'Image',
placeholder: '[repository/]name[:tag]',
},
callback: cmds._imagePull,
});
},
/**
* Public - Volume-only - Browse (Open a terminal on the server, and cd to the volume's mountpoint)
*/
browse: function () {
if (sgetCurrentTabKey() !== 'volumes') return;
websocketSend({
action: `volume.browse`,
args: { Resource: sgetCurrentRow() },
});
},
/**
* Public - Reload the current inspector tab / Reload everything
*/
reload: function () {
if (state.inspector.isEnabled) cmdRun(cmds._refreshInspector);
else cmdRun(cmds._init);
},
/**
* Public - Open Isaiah repository on Github
*/
github: function () {
window.open(`https://github.com/will-moss/isaiah/?from=instance`);
},
/**
* Public - Navigate to the nth tab
*/
firstTab: function () {
if (!state.tabs[0]) return;
state.navigation.currentTab = state.tabs[0].Key;
},
secondTab: function () {
if (!state.tabs[1]) return;
state.navigation.currentTab = state.tabs[1].Key;
},
thirdTab: function () {
if (!state.tabs[2]) return;
state.navigation.currentTab = state.tabs[2].Key;
},
fourthTab: function () {
if (!state.tabs[3]) return;
state.navigation.currentTab = state.tabs[3].Key;
},
};
// === Variables
/**
* @type {string}
*/
const wsHost = window.location.hostname;
/**
* @type {number}
*/
const wsPort = window.location.port;
/**
* @type {boolean}
*/
const wsSSL = window.location.protocol === 'https:';
/**
* @type {number} - Milliseconds
*/
const wsRetryInterval = 1000;
/**
* @type {WebSocket}
*/
let wsSocket = null;
/**
* @type {object.<string, string>}
*/
const kbMap = {
// Navigation
ArrowUp: 'scrollUp',
ArrowDown: 'scrollDown',
ArrowLeft: 'scrollLeft',
ArrowRight: 'scrollRight',
Tab: 'nextTab',
ShiftTab: 'previousTab',
']': 'nextSubTab',
'[': 'previousSubTab',
1: 'firstTab',
2: 'secondTab',
3: 'thirdTab',
4: 'fourthTab',
// Interaction
Enter: 'confirm',
y: 'confirm',
Escape: 'reject',
n: 'reject',
// Menu
x: 'menu',
b: 'bulk',
// Sub commands
q: 'quit',
d: 'remove',
p: 'pause',
s: 'stop',
r: 'run_restart',
E: 'shellContainer',
S: 'shellSystem',
R: 'reload',
P: 'pull',
B: 'browse',
w: 'browser',
h: 'hub',
G: 'github',
'?': 'help',
// Misc
'+': 'nextLayout', // Next layout
'-': 'previousLayout', // Previous layout
};
/**
* @type {string}
*/
const whitespace = '\xa0';
// === Listeners
/**
* Called every time the user presses a key down
* on their keyboard. Will call the internal command
* associated with the key pressed, or take the appropriate
* behavior if tty is enabled
*/
const listenerKeyDown = (evt) => {
if (state.tty.isEnabled) {
listenerTtyKeyDown(evt);
return;
}
if (state.prompt.input.isEnabled) {
listenerPromptInputKeyDown(evt);
return;
}
if (evt.metaKey) return;
let { key } = evt;
if (!key || !(key in kbMap)) return;
if (evt.shiftKey && `Shift${key}` in kbMap) key = `Shift${key}`;
evt.stopPropagation();
evt.preventDefault();
cmdRun(cmds[kbMap[key]]);
};
/**
* Called every time the user presses a key down
* on their keyboard, while in TTY mode. Will
* appropriately perform TTY actions and update the state
*/
const listenerTtyKeyDown = (evt) => {
let { key } = evt;
switch (key) {
// Run command
case 'Enter':
cmdRun(cmds._ttyExec, hgetTtyInput().value);
break;
// Clear screen
case 'l':
if (evt.ctrlKey) cmdRun(cmds._ttyClear);
break;
// Erase command
case 'c':
if (evt.ctrlKey) cmdRun(cmds._ttyErase);
break;
// Quit
case 'd':
if (evt.ctrlKey) cmdRun(cmds._ttyExec, 'exit');
break;
// Previous command
case 'ArrowUp':
cmdRun(cmds._ttySetHistoryPrevious);
break;
case 'ArrowDown':
cmdRun(cmds._ttySetHistoryNext);
break;
case 'Tab':
evt.stopPropagation();
evt.preventDefault();
hgetTtyInput().focus();
break;
}
};
/**
* Called every time the user presses a key down
* on their keyboard, while in Prompt Input mode. Will
* allow the user to input data using any key, while
* Escape and Enter will be used to Leave/Confirm the
* Prompt
*/
const listenerPromptInputKeyDown = (evt) => {
if (evt.metaKey) return;
const { key } = evt;
if (!key || !(key in kbMap)) return;
if (evt.shiftKey && `Shift${key}` in kbMap) key = `Shift${key}`;
// Only allow Escape and Enter
if (!['Escape', 'Enter'].includes(key)) return;
evt.stopPropagation();
evt.preventDefault();
cmdRun(cmds[kbMap[key]]);
};
/**
* Called every time the user clicks with their
* mouse. Will call the internal command
* associated with the DOM element that's been
* targeted
*/
const listenerMouseClick = (evt) => {
evt.preventDefault();
// Prevent doing anything while the server is loading
if (state.isLoading) return;
// Prevent doing anything while the connection is lost
if (!state.isConnected) return;
const { target } = evt;
// 1. Explicit navigation via data-navigate attribute (e.g. data-navigate="tab.containers"/"inspector.Logs"/"row")
if (target.hasAttribute('data-navigate')) {
const [part, key] = target.getAttribute('data-navigate').split('.');
// 1.1. Tab Header
if (part === 'tab') {
if (state.inspector.isEnabled) cmdRun(cmds._exitInspect);
state.navigation.currentTab = key;
cmdRun(cmds._inspectorTabs);
}
// 1.2. Inspector Tab Header
else if (part === 'inspector') {
if (!state.inspector.isEnabled) cmdRun(cmds._enterInspect);
state.inspector.currentTab = key;
cmdRun(cmds._refreshInspector);
}
// 1.3. Tab Row
else if (part === 'row') {
const tab = target.parentNode.parentNode;
if (tab.classList.contains('for-inspector')) return;
const tabHeader = tab.querySelector('.tab-title[data-navigate]');
const tabContent = tab.querySelector('.tab-content');
// 1.3.1. Navigate to the clicked row's parent tab
// prettier-ignore
const [_part, _key] = tabHeader.getAttribute('data-navigate').split('.');
if (_key !== state.navigation.currentTab) {
if (state.inspector.isEnabled) cmdRun(cmds._exitInspect);
state.navigation.currentTab = _key;
}
// 1.3.2. Focus the clicked row and refresh the inspector
const rowIndex = Array.from(tabContent.children).indexOf(target);
state.navigation.currentTabsRows[_key] = rowIndex + 1;
cmdRun(cmds._inspectorTabs);
}
// 1.4. Tab Row's Cell
else if (part === 'cell') {
const tab = target.parentNode.parentNode.parentNode;
// 1.4.1. Clicked in the inspector
if (tab.classList.contains('for-inspector')) {
// 1.4.1.1. Focus the inspector
if (!state.inspector.isEnabled) cmdRun(cmds._enterInspect);
}
// 1.4.2. Clicked in a regular tab
else {
const tabHeader = tab.querySelector('.tab-title[data-navigate]');
const tabContent = tab.querySelector('.tab-content');
const tabRow = target.parentNode;
// 1.4.2.1. Navigate to the clicked cell's row's parent tab
// prettier-ignore
const [_part, _key] = tabHeader.getAttribute('data-navigate').split('.');
if (_key !== state.navigation.currentTab) {
if (state.inspector.isEnabled) cmdRun(cmds._exitInspect);
state.navigation.currentTab = _key;
}
// 1.4.2.2. Focus the clicked row and refresh the inspector
const rowIndex = Array.from(tabContent.children).indexOf(tabRow);
state.navigation.currentTabsRows[_key] = rowIndex + 1;
cmdRun(cmds._inspectorTabs);
}
}
}
// 2. Explicit action via data-action attribute (e.g. data-action="help")
else if (target.hasAttribute('data-action')) {
const action = target.getAttribute('data-action');
cmdRun(cmds[action]);
}
// 3. Explicit command (local or remote) via data-command attribute (menu actions only)
else if (target.hasAttribute('data-command')) {
// 3.1. Focus the clicked row and trigger it
const tabContent = target.parentNode;
const rowIndex = Array.from(tabContent.children).indexOf(target) + 1;
state.navigation.currentMenuRow = rowIndex;
cmdRun(cmds.confirm);
}
// 4. Clicked anywhere else
else {
// 4.0. If tty-ing, focus the tty input
if (state.tty.isEnabled) {
hgetTtyInput().focus();
return;
}
// 4.1. If any popup is activated, while not menuing and not tty-ing, dismiss it
if (!state.isMenuIng && state.popup) cmdRun(cmds.reject);
// 4.2. If menuing, and clicked outside the menu, dismiss it
else if (state.isMenuIng && target.classList.contains('popup-layer'))
cmdRun(cmds.reject);
// 4.3. If menuing, run the 3. scenario (assumption: we clicked a span inside a row)
else {
const tabContent = target.parentNode.parentNode;
const tabRow = target.parentNode;
const rowIndex = Array.from(tabContent.children).indexOf(tabRow) + 1;
state.navigation.currentMenuRow = rowIndex;
cmdRun(cmds.confirm);
}
}
cmdRun(cmds._render);
};
/**
* Called when the browser has succesfully established a
* Websocket connection with the server
*/
const listenerSocketOpen = (evt) => {
state.isConnected = true;
state.hasEstablishedConnection = true;
cmdRun(cmds._showAuthentication);
};
/**
* Called when the Websocket connection was closed
* or encountered an error while connecting to the server
*/
const listenerSocketError = (evt) => {
if (wsSocket) wsSocket.close();
};
/**
* Called when the browser receives a message from the server
* through the Websocket connection established prior
*/
const listenerSocketMessage = (evt) => {
const { data } = evt;
/**
* @typedef Notification
* @property {"init"|"refresh"|"loading"|"report"|"prompt"|"tty"|"auth"} Category
* @property {string} Type
* @property {string} Title
* @property {object} Content
* @property {string} Follow
* @property {boolean} Display
*/
/**
* @type {Notification}
*/
const notification = JSON.parse(data);
switch (notification.Category) {
case 'init':
state.tabs = notification.Content.Tabs;
state.navigation.currentTab = state.tabs[0].Key;
state.navigation.currentTabsRows = state.tabs.reduce(
(a, b) => ({ ...a, [b.Key]: 1 }),
{}
);
state.isLoading = false;
cmdRun(cmds._inspectorTabs);
break;
case 'auth':
if ('Authentication' in notification.Content) {
state.message.category = 'authentication';
state.message.type = notification.Type;
state.message.title = notification.Title;
state.message.content = notification.Content.Authentication.Message;
state.message.isEnabled = true;
state.helper = 'message';
// Authentication error
if ('error' === notification.Type) {
cmdRun(cmds._showPopup, 'message');
setTimeout(() => {
cmdRun(cmds._clearMessage);
cmdRun(cmds._showAuthentication);
}, state._delays.forAuthentication);
}
// Authentication success
else if ('success' === notification.Type) {
// Normal case
if (!notification.Content.Authentication.Spontaneous) {
cmdRun(cmds._showPopup, 'message');
setTimeout(() => {
cmdRun(cmds._clearMessage);
state.isAuthenticated = true;
cmdRun(cmds._init);
}, state._delays.forAuthentication);
}
// Dev-only
else {
cmdRun(cmds._clearMessage);
cmdRun(cmds._clearPrompt);
state.isAuthenticated = true;
cmdRun(cmds._init);
}
}
}
break;
case 'refresh':
if ('Tab' in notification.Content)
if (notification.Content.Tab.Rows.length > 0) {
state.tabs = state.tabs.map((t) =>
t.Key === notification.Content.Tab.Key
? notification.Content.Tab
: t
);
state.navigation.currentTabsRows[notification.Content.Tab.Key] = 1;
} else {
state.tabs = state.tabs.filter(
(t) => t.Key !== notification.Content.Tab.Key
);
state.navigation.currentTab = state.tabs[0].Key;
state.navigation.previousTab = state.tabs[0].Key;
state.navigation.currentTabsRows[state.navigation.currentTab] = 1;
}
if ('Actions' in notification.Content) {
state.menu.actions = notification.Content.Actions;
state.navigation.currentMenuRow = 1;
cmdRun(cmds._showPopup, 'menu');
state.helper = 'menu';
}
if ('Address' in notification.Content) {
window.open(notification.Content.Address, '_blank');
}
if ('Inspector' in notification.Content) {
if ('Tabs' in notification.Content.Inspector) {
state.inspector.availableTabs = notification.Content.Inspector.Tabs;
state.inspector.currentTab = notification.Content.Inspector.Tabs[0];
cmdRun(cmds._refreshInspector);
}
if ('Content' in notification.Content.Inspector) {
// When raw lines are received, append them
if (notification.Content.Inspector.Content[0].Type === 'lines')
state.inspector.content.push(
...notification.Content.Inspector.Content
);
// Else, replace the current content with the one received
else
state.inspector.content = notification.Content.Inspector.Content;
}
}
state.isLoading = false;
break;
case 'loading':
state.isLoading = true;
break;
case 'report':
state.message.category = notification.Category;
state.message.type = notification.Type;
state.message.title = notification.Title;
state.message.content = notification.Content.Message;
state.isLoading = false;
if (notification.Display) {
state.message.isEnabled = true;
state.helper = 'message';
cmdRun(cmds._showPopup, 'message');
}
break;
case 'prompt':
cmdRun(cmds._showPrompt, {
text: notification.Content.Message,
callback: cmds._wsSend,
callbackArgs: [
{
action: notification.Content.Command,
args: { Resource: sgetCurrentRow() },
},
],
});
state.isLoading = false;
break;
case 'tty':
if ('Status' in notification.Content) {
switch (notification.Content.Status) {
case 'started':
state.tty.type = notification.Content.Type;
cmdRun(cmds._ttyStart);
break;
case 'exited':
cmdRun(cmds._ttyQuit);
break;
}
}
if ('Output' in notification.Content) {
const { Output } = notification.Content;
state.tty._buffer += Output;
// Fill a buffer with stdout content until we meet a newline character
if (!['\r', '\n'].some((n) => Output.includes(n))) {
// If no new content was received after X ms, consider it's the end of output, and print it
setTimeout(() => {
if (state.tty._buffer.length > 0) {
state.tty.lines.push(state.tty._buffer);
state.tty._buffer = '';
cmdRun(cmds._render);
}
}, state._delays.forTTYBufferClear);
break;
}
// If the command was run by us, apply a string transform to print it on the same line as last stdout
if (state.tty._buffer.trim().endsWith('#ISAIAH')) {
const command = state.tty._buffer.split('#ISAIAH')[0];
state.tty.lines[state.tty.lines.length - 1] += `<wbr />${command}`;
state.tty._buffer = '';
break;
}
// Regular stdout lines
state.tty.lines.push(
...(state.tty._buffer.includes('\r')
? state.tty._buffer.split('\r\n').filter((l) => l)
: state.tty._buffer.split('\n').filter((l) => l))
);
state.tty._buffer = '';
}
state.isLoading = false;
}
if (notification.Follow)
// Delay added to prevent caching / refreshing data issues with the Docker client
setTimeout(() => {
websocketSend({ action: notification.Follow });
}, state._delays.default);
renderApp(state);
};
/**
* Called when the Websocket connection between the browser
* and the server is closed. Attempts to reconnect and establish
* the Websocket connection again
*/
const listenerSocketClose = (evt) => {
state.isConnected = false;
renderApp(state);
if (!wsSocket || wsSocket.readyState === WebSocket.CLOSED)
setTimeout(websocketConnect, wsRetryInterval);
};
// ===
// === Entry Point
// ===
window.addEventListener('load', () => {
// 1. Connect to server (first execution loop)
websocketConnect();
// 2. Set keyboard listener (second execution loop)
window.addEventListener('keydown', listenerKeyDown);
// 3. Set mouse listener (third execution loop)
window.addEventListener('click', listenerMouseClick);
});
})(window);