diff --git a/assets/sai-terminal.js b/assets/sai-terminal.js
index eb16f79..736b933 100644
--- a/assets/sai-terminal.js
+++ b/assets/sai-terminal.js
@@ -20,10 +20,82 @@ class SaiTerminal {
this.titleBar = document.createElement('div');
this.titleBar.className = 'sai-terminal-titlebar';
+ let titleLeft = document.createElement('div');
+ titleLeft.style.display = 'flex';
+ titleLeft.style.alignItems = 'center';
+
+ this.burgerBtn = document.createElement('button');
+ this.burgerBtn.className = 'sai-terminal-burger';
+ this.burgerBtn.innerHTML = '☰';
+
+ this.burgerMenu = document.createElement('div');
+ this.burgerMenu.className = 'sai-terminal-menu';
+
+ this.menuItemDark = document.createElement('div');
+ this.menuItemDark.className = 'sai-terminal-menu-item';
+ this.menuItemDark.textContent = 'Theme: Dark';
+ this.menuItemDark.onclick = () => { this.setTheme('dark'); this.burgerMenu.classList.remove('show'); };
+
+ this.menuItemLight = document.createElement('div');
+ this.menuItemLight.className = 'sai-terminal-menu-item';
+ this.menuItemLight.textContent = 'Theme: Light';
+ this.menuItemLight.onclick = () => { this.setTheme('light'); this.burgerMenu.classList.remove('show'); };
+
+ this.burgerMenu.appendChild(this.menuItemDark);
+ this.burgerMenu.appendChild(this.menuItemLight);
+
+ this.burgerBtn.onclick = (e) => {
+ e.stopPropagation();
+ this.burgerMenu.classList.toggle('show');
+ if (this.updateFocusState) this.updateFocusState();
+ };
+
+ const closeMenuListener = (e) => {
+ if (this.el && !this.burgerBtn.contains(e.target) && !this.burgerMenu.contains(e.target)) {
+ if (this.burgerMenu.classList.contains('show')) {
+ this.burgerMenu.classList.remove('show');
+ if (this.updateFocusState) this.updateFocusState();
+ }
+ }
+ };
+ document.addEventListener('click', closeMenuListener);
+ this._closeMenuListener = closeMenuListener;
+
+ titleLeft.appendChild(this.burgerBtn);
+ this.titleBar.appendChild(this.burgerMenu);
+
+ if (this.options.platform) {
+ let plat_parts = this.options.platform.split('/');
+ let plat_os = plat_parts[0] || 'generic';
+ let plat_arch = plat_parts[1] || 'generic';
+ let plat_tc = plat_parts[2] || 'generic';
+
+ this.titleIcons = document.createElement('div');
+ this.titleIcons.className = 'sai-terminal-icons';
+ this.titleIcons.style.display = 'flex';
+ this.titleIcons.style.gap = '4px';
+ this.titleIcons.style.marginRight = '8px';
+ this.titleIcons.innerHTML =
+ `<img class="ip1 zup" src="/sai/${plat_os}.svg">` +
+ `<img class="ip1 tread1" src="/sai/arch-${plat_arch}.svg">` +
+ `<img class="ip1 tread2" src="/sai/tc-${plat_tc}.svg">`;
+
+ const images = this.titleIcons.querySelectorAll('img');
+ images.forEach(img => {
+ img.onerror = () => {
+ img.src = '/sai/generic.svg';
+ img.onerror = null;
+ };
+ });
+ titleLeft.appendChild(this.titleIcons);
+ }
+
this.titleText = document.createElement('span');
this.titleText.className = 'sai-terminal-title';
this.titleText.textContent = this.options.title || "Terminal";
- this.titleBar.appendChild(this.titleText);
+ titleLeft.appendChild(this.titleText);
+
+ this.titleBar.appendChild(titleLeft);
this.closeBtn = document.createElement('button');
this.closeBtn.className = 'sai-terminal-close';
@@ -38,6 +110,13 @@ class SaiTerminal {
this.contentEl = document.createElement('div');
this.contentEl.className = 'sai-terminal-content';
+ this.loaderEl = document.createElement('div');
+ this.loaderEl.className = 'sai-terminal-loader';
+ const spinner = document.createElement('div');
+ spinner.className = 'sai-terminal-spinner';
+ this.loaderEl.appendChild(spinner);
+ this.contentEl.appendChild(this.loaderEl);
+
this.outputEl = document.createElement('div');
this.outputEl.className = 'sai-terminal-output';
this.contentEl.appendChild(this.outputEl);
@@ -57,21 +136,45 @@ class SaiTerminal {
this.core = new TerminalCore(80, 24);
- // Setup ResizeObserver to dynamically resize
this.resizeObserver = new ResizeObserver(entries => {
for (let entry of entries) {
- // Approximate character size (e.g., 8x16)
- // This would be better if dynamically measured
- const cols = Math.max(10, Math.floor(entry.contentRect.width / 8.5));
- const rows = Math.max(5, Math.floor(entry.contentRect.height / 16));
+ const measureEl = document.createElement('span');
+ measureEl.textContent = 'W'.repeat(100);
+ measureEl.style.position = 'absolute';
+ measureEl.style.visibility = 'hidden';
+ measureEl.style.whiteSpace = 'pre';
+ this.outputEl.appendChild(measureEl);
+ const rect = measureEl.getBoundingClientRect();
+ this._charSize = {
+ width: (rect.width / 100) || 8.5,
+ height: Math.max(16, rect.height)
+ };
+ this.outputEl.removeChild(measureEl);
+
+ const cols = Math.max(10, Math.floor(entry.contentRect.width / this._charSize.width));
+ const rows = Math.max(5, Math.floor(entry.contentRect.height / this._charSize.height));
if (cols !== this.core.cols || rows !== this.core.rows) {
this.core.resize(cols, rows);
this.render();
+ if (this.options.onResize) {
+ this.options.onResize(cols, rows);
+ }
}
}
});
this.resizeObserver.observe(this.contentEl);
+ // Re-evaluate size when custom fonts (like Iosevka) finish loading
+ if (document.fonts && document.fonts.ready) {
+ document.fonts.ready.then(() => {
+ if (this.el.parentNode) {
+ // Force resize observer to re-trigger
+ this.resizeObserver.disconnect();
+ this.resizeObserver.observe(this.contentEl);
+ }
+ });
+ }
+
// Dragging logic
let isDragging = false;
let dragStartX, dragStartY;
@@ -105,21 +208,21 @@ class SaiTerminal {
});
this.inputEl.addEventListener('keydown', (e) => {
- if (e.ctrlKey) {
- if (e.key === 'c') {
- this.options.onData('\x03');
- e.preventDefault();
- } else if (e.key === 'd') {
- this.options.onData('\x04');
- e.preventDefault();
- } else if (e.key === 'z') {
- this.options.onData('\x1a');
- e.preventDefault();
- } else if (e.key === 'l') {
- this.options.onData('\x0c');
+ if (e.ctrlKey && !e.altKey && !e.metaKey && e.key.length === 1) {
+ const charCode = e.key.toLowerCase().charCodeAt(0);
+ if (charCode >= 97 && charCode <= 122) {
+ if (charCode === 99 && window.getSelection().toString()) {
+ return; // Let browser natively copy text
+ }
+ if (charCode === 118) { // 'v'
+ return; // Let browser natively paste text
+ }
+ this.options.onData(String.fromCharCode(charCode - 96));
e.preventDefault();
+ return;
}
- } else if (e.key === 'Enter') {
+ }
+ if (e.key === 'Enter') {
this.options.onData('\r');
e.preventDefault();
} else if (e.key === 'Backspace') {
@@ -144,32 +247,260 @@ class SaiTerminal {
}
});
+ this.inputEl.addEventListener('paste', (e) => {
+ let text = (e.clipboardData || window.clipboardData).getData('text');
+ if (text) {
+ this.options.onData(text);
+ }
+ e.preventDefault();
+ });
+
+ this.updateFocusState = () => {
+ let menuOpen = this.burgerMenu && this.burgerMenu.classList.contains('show');
+ let sel = window.getSelection();
+ let hasSelection = sel && !sel.isCollapsed && this.el.contains(sel.anchorNode);
+ if (document.activeElement === this.inputEl || isSelecting || menuOpen || hasSelection) {
+ this.el.style.opacity = '1';
+ } else {
+ this.el.style.opacity = '0.7';
+ }
+ };
+ const updateFocusState = this.updateFocusState;
+
// click to focus input
this.el.addEventListener('click', () => {
- const selection = window.getSelection();
- if (!selection.toString()) {
- this.inputEl.focus();
+ let sel = window.getSelection();
+ if (!sel || sel.isCollapsed) {
+ this.inputEl.focus({ preventScroll: true });
+ }
+ });
+
+ this.inputEl.addEventListener('focus', updateFocusState);
+ this.inputEl.addEventListener('blur', updateFocusState);
+
+ // Handle scrolling
+ this.contentEl.addEventListener('wheel', (e) => {
+ let amount = Math.sign(e.deltaY) * 3;
+ if (amount !== 0) {
+ this.core.scroll(-amount);
+ this.render();
+ }
+ e.preventDefault();
+ e.stopPropagation();
+ }, { passive: false });
+
+ let isSelecting = false;
+ let selectScrollInterval = null;
+ let selectMouseY = 0;
+
+ this.contentEl.addEventListener('mousedown', (e) => {
+ isSelecting = true;
+ updateFocusState();
+ });
+
+ document.addEventListener('mousemove', (e) => {
+ if (!isSelecting) return;
+ selectMouseY = e.clientY;
+
+ const rect = this.contentEl.getBoundingClientRect();
+ if (selectMouseY < rect.top) {
+ if (!selectScrollInterval) {
+ selectScrollInterval = setInterval(() => {
+ let oldOffset = this.core.scrollOffset;
+ this.core.scroll(1);
+ let diff = this.core.scrollOffset - oldOffset;
+ if (diff !== 0) {
+ let selPos = this.getSelectionPos();
+ this.render();
+ if (selPos) {
+ let shift = diff * (this.core.cols + 1);
+ selPos.anchor += shift;
+ let maxPos = this.core.rows * (this.core.cols + 1);
+ selPos.anchor = Math.max(0, Math.min(maxPos, selPos.anchor));
+ selPos.focus = Math.max(0, Math.min(maxPos, selPos.focus));
+ this.setSelectionPos(selPos);
+ }
+ }
+ }, 50);
+ }
+ } else if (selectMouseY > rect.bottom) {
+ if (!selectScrollInterval) {
+ selectScrollInterval = setInterval(() => {
+ let oldOffset = this.core.scrollOffset;
+ this.core.scroll(-1);
+ let diff = this.core.scrollOffset - oldOffset;
+ if (diff !== 0) {
+ let selPos = this.getSelectionPos();
+ this.render();
+ if (selPos) {
+ let shift = diff * (this.core.cols + 1);
+ selPos.anchor += shift;
+ let maxPos = this.core.rows * (this.core.cols + 1);
+ selPos.anchor = Math.max(0, Math.min(maxPos, selPos.anchor));
+ selPos.focus = Math.max(0, Math.min(maxPos, selPos.focus));
+ this.setSelectionPos(selPos);
+ }
+ }
+ }, 50);
+ }
+ } else {
+ if (selectScrollInterval) {
+ clearInterval(selectScrollInterval);
+ selectScrollInterval = null;
+ }
+ }
+ });
+
+ document.addEventListener('mouseup', () => {
+ if (isSelecting) {
+ isSelecting = false;
+ if (selectScrollInterval) {
+ clearInterval(selectScrollInterval);
+ selectScrollInterval = null;
+ }
+ updateFocusState();
}
});
+
+ // Set initial unfocused opacity
+ this.el.style.opacity = '0.7';
+
+ // Load theme from localStorage
+ this.setTheme(localStorage.getItem('sai-terminal-theme') || 'dark');
+
+ // Initial focus
+ setTimeout(() => this.inputEl.focus({ preventScroll: true }), 100);
+ }
+
+ setTheme(theme) {
+ if (theme === 'light') {
+ this.el.classList.add('theme-light');
+ this.el.classList.remove('theme-dark');
+ } else {
+ this.el.classList.add('theme-dark');
+ this.el.classList.remove('theme-light');
+ }
+ localStorage.setItem('sai-terminal-theme', theme);
}
write(channel, data) {
+ if (this.loaderEl) {
+ this.loaderEl.remove();
+ this.loaderEl = null;
+ }
if (channel === 1 || channel === 2 || channel === 0) {
this.core.write(data);
this.render();
}
}
+ getSelectionPos() {
+ const sel = window.getSelection();
+ if (!sel.rangeCount || sel.isCollapsed) return null;
+ if (!this.outputEl.contains(sel.anchorNode) || !this.outputEl.contains(sel.focusNode)) return null;
+
+ const getPos = (targetNode, targetOffset) => {
+ let pos = 0;
+ const walk = (node) => {
+ if (node === targetNode) {
+ if (node.nodeType === Node.TEXT_NODE) return pos + targetOffset;
+ let childNodes = node.childNodes;
+ for (let i = 0; i < targetOffset && i < childNodes.length; i++) {
+ if (childNodes[i].nodeType === Node.TEXT_NODE) pos += childNodes[i].length;
+ else if (childNodes[i].nodeName === 'BR') pos += 1;
+ else pos += childNodes[i].textContent.length;
+ }
+ return pos;
+ }
+ if (node.nodeType === Node.TEXT_NODE) {
+ pos += node.length;
+ } else if (node.nodeName === 'BR') {
+ pos += 1;
+ } else {
+ for (let child of node.childNodes) {
+ let res = walk(child);
+ if (res !== null) return res;
+ }
+ }
+ return null;
+ };
+ return walk(this.outputEl);
+ };
+
+ let a = getPos(sel.anchorNode, sel.anchorOffset);
+ let f = getPos(sel.focusNode, sel.focusOffset);
+ if (a === null || f === null) return null;
+ return { anchor: a, focus: f };
+ }
+
+ setSelectionPos(selPos) {
+ if (!selPos) return;
+ const sel = window.getSelection();
+
+ const findNodeAndOffset = (targetPos) => {
+ let pos = 0;
+ let foundNode = null;
+ let foundOffset = 0;
+
+ const walk = (node) => {
+ if (foundNode) return;
+ if (node.nodeType === Node.TEXT_NODE) {
+ if (pos + node.length >= targetPos) {
+ foundNode = node;
+ foundOffset = targetPos - pos;
+ return;
+ }
+ pos += node.length;
+ } else if (node.nodeName === 'BR') {
+ if (pos === targetPos) {
+ foundNode = node.parentNode;
+ foundOffset = Array.prototype.indexOf.call(node.parentNode.childNodes, node);
+ return;
+ }
+ pos += 1;
+ } else {
+ for (let child of node.childNodes) {
+ walk(child);
+ }
+ }
+ };
+ walk(this.outputEl);
+
+ if (!foundNode) {
+ let last = this.outputEl.lastChild;
+ while (last && last.lastChild) last = last.lastChild;
+ if (last && last.nodeType === Node.TEXT_NODE) {
+ foundNode = last;
+ foundOffset = last.length;
+ } else {
+ foundNode = this.outputEl;
+ foundOffset = this.outputEl.childNodes.length;
+ }
+ }
+ return { node: foundNode, offset: foundOffset };
+ };
+
+ let a = findNodeAndOffset(selPos.anchor);
+ let f = findNodeAndOffset(selPos.focus);
+
+ if (a.node && f.node && sel.setBaseAndExtent) {
+ sel.setBaseAndExtent(a.node, a.offset, f.node, f.offset);
+ }
+ }
+
render() {
this.outputEl.innerHTML = this.core.renderHtml();
}
focus() {
- this.inputEl.focus();
+ this.inputEl.focus({ preventScroll: true });
}
destroy() {
this.resizeObserver.disconnect();
+ if (this._closeMenuListener) {
+ document.removeEventListener('click', this._closeMenuListener);
+ }
if (this.el.parentNode) {
this.el.parentNode.removeChild(this.el);
}
diff --git a/assets/sai.css b/assets/sai.css
index 2f32543..bdbf454 100644
--- a/assets/sai.css
+++ b/assets/sai.css
@@ -932,33 +932,33 @@ img.branch {
.ansi-bold { font-weight: bold; }
.ansi-underline { text-decoration: underline; }
-.ansi-fg-black { color: #000; }
-.ansi-fg-red { color: #A00; }
-.ansi-fg-green { color: #0A0; }
-.ansi-fg-yellow { color: #A50; }
-.ansi-fg-blue { color: #00A; }
-.ansi-fg-magenta { color: #A0A; }
-.ansi-fg-cyan { color: #0AA; }
-.ansi-fg-white { color: #AAA; }
+.ansi-fg-black { color: var(--term-black, #000); }
+.ansi-fg-red { color: var(--term-red, #A00); }
+.ansi-fg-green { color: var(--term-green, #0A0); }
+.ansi-fg-yellow { color: var(--term-yellow, #A50); }
+.ansi-fg-blue { color: var(--term-blue, #55F); }
+.ansi-fg-magenta { color: var(--term-magenta, #A0A); }
+.ansi-fg-cyan { color: var(--term-cyan, #0AA); }
+.ansi-fg-white { color: var(--term-white, #AAA); }
/* Bright colors for bold text */
-.ansi-bold .ansi-fg-black { color: #555; }
-.ansi-bold .ansi-fg-red { color: #F55; }
-.ansi-bold .ansi-fg-green { color: #5F5; }
-.ansi-bold .ansi-fg-yellow { color: #FF5; }
-.ansi-bold .ansi-fg-blue { color: #55F; }
-.ansi-bold .ansi-fg-magenta { color: #F5F; }
-.ansi-bold .ansi-fg-cyan { color: #5FF; }
-.ansi-bold .ansi-fg-white { color: #FFF; }
-
-.ansi-bg-black { background-color: #000; }
-.ansi-bg-red { background-color: #A00; }
-.ansi-bg-green { background-color: #0A0; }
-.ansi-bg-yellow { background-color: #A50; }
-.ansi-bg-blue { background-color: #00A; }
-.ansi-bg-magenta { background-color: #A0A; }
-.ansi-bg-cyan { background-color: #0AA; }
-.ansi-bg-white { background-color: #AAA; }
+.ansi-bold .ansi-fg-black { color: var(--term-bold-black, #555); }
+.ansi-bold .ansi-fg-red { color: var(--term-bold-red, #F55); }
+.ansi-bold .ansi-fg-green { color: var(--term-bold-green, #5F5); }
+.ansi-bold .ansi-fg-yellow { color: var(--term-bold-yellow, #FF5); }
+.ansi-bold .ansi-fg-blue { color: var(--term-bold-blue, #77F); }
+.ansi-bold .ansi-fg-magenta { color: var(--term-bold-magenta, #F5F); }
+.ansi-bold .ansi-fg-cyan { color: var(--term-bold-cyan, #5FF); }
+.ansi-bold .ansi-fg-white { color: var(--term-bold-white, #FFF); }
+
+.ansi-bg-black { background-color: var(--term-black, #000); }
+.ansi-bg-red { background-color: var(--term-red, #A00); }
+.ansi-bg-green { background-color: var(--term-green, #0A0); }
+.ansi-bg-yellow { background-color: var(--term-yellow, #A50); }
+.ansi-bg-blue { background-color: var(--term-blue, #55F); }
+.ansi-bg-magenta { background-color: var(--term-magenta, #A0A); }
+.ansi-bg-cyan { background-color: var(--term-cyan, #0AA); }
+.ansi-bg-white { background-color: var(--term-white, #AAA); }
.step-progress {
display: flex;
@@ -1510,19 +1510,28 @@ div.ibuil.vm-builder {
}
.sai-terminal {
+ --term-bg: #1e1e1e;
+ --term-fg: #00ff00;
+ --term-out: #ddd;
+ --term-cursor-bg: rgba(0, 255, 0, 0.7);
+ --term-cursor-fg: #1e1e1e;
+ --term-title-bg: #333;
+ --term-title-fg: #fff;
+ --term-border: #555;
+
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 600px;
height: 400px;
- background: #1e1e1e;
- color: #00ff00;
+ background: var(--term-bg);
+ color: var(--term-fg);
font-family: 'Iosevka', monospace;
font-size: 14px;
display: flex;
flex-direction: column;
- border: 2px solid #555;
+ border: 2px solid var(--term-border);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
z-index: 9999;
@@ -1530,9 +1539,22 @@ div.ibuil.vm-builder {
overflow: hidden;
}
+.sai-terminal.theme-light {
+ --term-bg: #f5f5f5;
+ --term-fg: #000;
+ --term-out: #333;
+ --term-cursor-bg: rgba(0, 0, 0, 0.5);
+ --term-cursor-fg: #fff;
+ --term-title-bg: #ddd;
+ --term-title-fg: #000;
+ --term-border: #aaa;
+ --term-blue: #00A;
+ --term-bold-blue: #55F;
+}
+
.sai-terminal-titlebar {
- background: #333;
- color: #fff;
+ background: var(--term-title-bg);
+ color: var(--term-title-fg);
padding: 5px 10px;
display: flex;
justify-content: space-between;
@@ -1540,7 +1562,50 @@ div.ibuil.vm-builder {
cursor: move;
user-select: none;
font-family: sans-serif;
- border-bottom: 1px solid #555;
+ border-bottom: 1px solid var(--term-border);
+}
+
+.sai-terminal-burger {
+ background: transparent;
+ border: none;
+ color: inherit;
+ font-size: 16px;
+ cursor: pointer;
+ margin-right: 8px;
+ padding: 2px 6px;
+}
+.sai-terminal-burger:hover {
+ background: rgba(128, 128, 128, 0.3);
+ border-radius: 3px;
+}
+
+.sai-terminal-menu {
+ position: absolute;
+ top: 30px;
+ left: 10px;
+ background: var(--term-bg);
+ border: 1px solid var(--term-border);
+ border-radius: 4px;
+ padding: 5px 0;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.5);
+ z-index: 10000;
+ display: none;
+ min-width: 120px;
+ font-family: sans-serif;
+ color: var(--term-out);
+}
+
+.sai-terminal-menu.show {
+ display: block;
+}
+
+.sai-terminal-menu-item {
+ padding: 5px 15px;
+ cursor: pointer;
+}
+
+.sai-terminal-menu-item:hover {
+ background: var(--term-border);
}
.sai-terminal-title {
@@ -1553,6 +1618,33 @@ div.ibuil.vm-builder {
flex-direction: column;
padding: 10px;
overflow: hidden;
+ position: relative;
+}
+
+.sai-terminal-loader {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: #333;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 100;
+}
+
+.sai-terminal-spinner {
+ width: 40px;
+ height: 40px;
+ border: 4px solid rgba(255, 255, 255, 0.3);
+ border-radius: 50%;
+ border-top-color: #fff;
+ animation: sai-spin 1s ease-in-out infinite;
+}
+
+@keyframes sai-spin {
+ to { transform: rotate(360deg); }
}
.sai-terminal-output {
@@ -1560,7 +1652,7 @@ div.ibuil.vm-builder {
overflow: hidden;
margin: 0;
white-space: pre;
- color: #ddd;
+ color: var(--term-out);
line-height: 16px;
}
@@ -1605,6 +1697,6 @@ div.ibuil.vm-builder {
}
.term-cursor {
- background-color: rgba(0, 255, 0, 0.7);
- color: #1e1e1e;
+ background-color: var(--term-cursor-bg);
+ color: var(--term-cursor-fg);
}
diff --git a/assets/sai.js b/assets/sai.js
index ff18543..4ba1a1a 100644
--- a/assets/sai.js
+++ b/assets/sai.js
@@ -404,6 +404,18 @@ active_terminals = {};
var segment_stack = [];
var seg_counter = 0;
+window.addEventListener('beforeunload', () => {
+ for (const task_uuid in active_terminals) {
+ const closeMsg = {
+ schema: "com.warmcat.sai.closeshell",
+ task_uuid: task_uuid
+ };
+ if (typeof sai !== 'undefined' && sai && sai.readyState === WebSocket.OPEN) {
+ sai.send(JSON.stringify(closeMsg));
+ }
+ }
+});
+
/* Global caches for reconcilation */
var pcon_topology = {};
var pcon_energy_cache = {};
@@ -1669,7 +1681,7 @@ function createBuilderDiv(plat) {
});
}
- if (authd && auth_is_admin && plat.online) {
+ if (authd && auth_is_admin) {
menuItems.push({
label: "<span class='builder-shell-btn'>Open Shell</span>",
callback: () => {
@@ -1685,6 +1697,7 @@ function createBuilderDiv(plat) {
const term = new SaiTerminal(document.body, {
title: "Terminal: " + plat.name,
+ platform: plat.platform,
onData: (data) => {
const ptyMsg = {
schema: "com.warmcat.sai.ptydata",
@@ -1696,6 +1709,19 @@ function createBuilderDiv(plat) {
};
sai.send(JSON.stringify(ptyMsg));
},
+ onResize: (cols, rows) => {
+ const resizeMsg = {
+ schema: "com.warmcat.sai.ptydata",
+ builder_name: plat.name,
+ task_uuid: task_uuid,
+ channel: 0,
+ cols: cols,
+ rows: rows,
+ data: "",
+ len: 0
+ };
+ sai.send(JSON.stringify(resizeMsg));
+ },
onClose: () => {
const closeMsg = {
schema: "com.warmcat.sai.closeshell",
@@ -2426,9 +2452,16 @@ function ws_open_sai()
case "com.warmcat.sai.ptydata":
if (jso.task_uuid) {
- const term = active_terminals[jso.task_uuid];
+ let term = active_terminals[jso.task_uuid];
if (!term) {
+ let platformStr = null;
+ if (typeof last_builder_list !== 'undefined' && jso.builder_name) {
+ const b = last_builder_list.find(x => x.name === jso.builder_name);
+ if (b) platformStr = b.platform;
+ }
term = new SaiTerminal(document.body, {
+ title: "Terminal: " + (jso.builder_name || "Unknown"),
+ platform: platformStr,
onData: function(input) {
const msg = {
schema: "com.warmcat.sai.ptydata",
@@ -2439,6 +2472,18 @@ function ws_open_sai()
};
sai.send(JSON.stringify(msg));
},
+ onResize: function(cols, rows) {
+ const msg = {
+ schema: "com.warmcat.sai.ptydata",
+ task_uuid: jso.task_uuid,
+ channel: 0,
+ cols: cols,
+ rows: rows,
+ len: 0,
+ data: ""
+ };
+ sai.send(JSON.stringify(msg));
+ },
onClose: function() {
const msg = {
schema: "com.warmcat.sai.closeshell",
diff --git a/assets/terminal-core.js b/assets/terminal-core.js
index 71dc3fd..cebbaeb 100644
--- a/assets/terminal-core.js
+++ b/assets/terminal-core.js
@@ -7,6 +7,9 @@ class TerminalCore {
this.cols = cols;
this.rows = rows;
this.buffer = []; // Array of { char: ' ', fg: null, bg: null, bold: false }
+ this.scrollback = []; // Array of pushed-out rows
+ this.maxScrollback = 10000;
+ this.scrollOffset = 0; // Number of lines scrolled back (0 = bottom)
this.cx = 0;
this.cy = 0;
@@ -17,6 +20,7 @@ class TerminalCore {
}
initBuffer() {
+ this.wrapNext = false;
this.buffer = [];
for (let r = 0; r < this.rows; r++) {
let row = [];
@@ -28,6 +32,7 @@ class TerminalCore {
}
resize(cols, rows) {
+ this.wrapNext = false;
let newBuffer = [];
for (let r = 0; r < rows; r++) {
let row = [];
@@ -48,27 +53,38 @@ class TerminalCore {
}
write(data) {
+ if (this.scrollOffset > 0 && data.length > 0) {
+ this.scrollOffset = 0;
+ }
for (let i = 0; i < data.length; i++) {
let char = data[i];
if (char === '\n') {
this.cy++;
this.cx = 0;
+ this.wrapNext = false;
if (this.cy >= this.rows) {
this.scrollUp();
this.cy = this.rows - 1;
}
} else if (char === '\r') {
this.cx = 0;
+ this.wrapNext = false;
} else if (char === '\b') {
if (this.cx > 0) this.cx--;
+ this.wrapNext = false;
} else if (char === '\t') {
this.cx = (this.cx + 8) - (this.cx % 8);
- if (this.cx >= this.cols) this.cx = this.cols - 1;
+ if (this.cx >= this.cols) {
+ this.cx = this.cols - 1;
+ this.wrapNext = true;
+ } else {
+ this.wrapNext = false;
+ }
} else if (char === '\x07') {
// Bell
} else if (char === '\x1b') { // ESC
- if (data[i + 1] === '[') {
+ if (i + 1 < data.length && data[i + 1] === '[') {
// CSI
let j = i + 2;
let paramStr = "";
@@ -81,31 +97,43 @@ class TerminalCore {
this.handleCSI(cmd, paramStr);
i = j;
}
- } else if (data[i+1] === ']') {
+ } else if (i + 1 < data.length && data[i+1] === ']') {
// OSC
let j = i + 2;
while (j < data.length && data[j] !== '\x07' && data[j] !== '\x1b') j++;
- if (data[j] === '\x1b' && data[j+1] === '\\') j++;
+ if (j < data.length && data[j] === '\x1b' && j + 1 < data.length && data[j+1] === '\\') j++;
i = j;
+ } else if (i + 1 < data.length && (data[i+1] === '(' || data[i+1] === ')' || data[i+1] === '*' || data[i+1] === '+')) {
+ // Designate G0-G3 Character Set (3 bytes)
+ i += 2;
} else {
- i++; // Skip unknown ESC sequence
+ i++; // Skip unknown 2-byte ESC sequence
}
} else {
- this.buffer[this.cy][this.cx] = {
- char: char,
- fg: this.currentStyle.fg,
- bg: this.currentStyle.bg,
- bold: this.currentStyle.bold
- };
- this.cx++;
- if (this.cx >= this.cols) {
+ if (this.wrapNext) {
this.cx = 0;
this.cy++;
+ this.wrapNext = false;
if (this.cy >= this.rows) {
this.scrollUp();
this.cy = this.rows - 1;
}
}
+
+ if (this.cy >= this.rows) this.cy = this.rows - 1;
+
+ this.buffer[this.cy][this.cx] = {
+ char: char,
+ fg: this.currentStyle.fg,
+ bg: this.currentStyle.bg,
+ bold: this.currentStyle.bold
+ };
+
+ if (this.cx < this.cols - 1) {
+ this.cx++;
+ } else {
+ this.wrapNext = true;
+ }
}
}
}
@@ -113,6 +141,11 @@ class TerminalCore {
handleCSI(cmd, paramStr) {
if (paramStr.startsWith('?')) paramStr = paramStr.substring(1);
let params = paramStr.split(';').map(p => parseInt(p, 10));
+
+ if (cmd !== 'm') {
+ this.wrapNext = false;
+ }
+
switch (cmd) {
case 'A': // Cursor Up
this.cy = Math.max(0, this.cy - (params[0] || 1)); break;
@@ -158,6 +191,31 @@ class TerminalCore {
for (let c = 0; c < this.cols; c++) this.buffer[this.cy][c] = {...this.defaultCell};
}
break;
+ case '@': // Insert Character
+ let nInsert = params[0] || 1;
+ for (let c = this.cols - 1; c >= this.cx + nInsert; c--) {
+ this.buffer[this.cy][c] = {...this.buffer[this.cy][c - nInsert]};
+ }
+ for (let c = this.cx; c < this.cx + nInsert && c < this.cols; c++) {
+ this.buffer[this.cy][c] = {...this.defaultCell};
+ }
+ break;
+ case 'P': // Delete Character
+ let nDelete = params[0] || 1;
+ if (nDelete > this.cols - this.cx) nDelete = this.cols - this.cx;
+ for (let c = this.cx; c < this.cols - nDelete; c++) {
+ this.buffer[this.cy][c] = {...this.buffer[this.cy][c + nDelete]};
+ }
+ for (let c = Math.max(this.cx, this.cols - nDelete); c < this.cols; c++) {
+ this.buffer[this.cy][c] = {...this.defaultCell};
+ }
+ break;
+ case 'X': // Erase Character
+ let nErase = params[0] || 1;
+ for (let c = this.cx; c < this.cx + nErase && c < this.cols; c++) {
+ this.buffer[this.cy][c] = {...this.defaultCell};
+ }
+ break;
case 'm': // SGR
for (let i = 0; i < params.length; i++) {
let code = params[i] || 0;
@@ -171,23 +229,48 @@ class TerminalCore {
}
scrollUp() {
- this.buffer.shift();
+ let shiftedRow = this.buffer.shift();
+ this.scrollback.push(shiftedRow);
+ if (this.scrollback.length > this.maxScrollback) {
+ this.scrollback.shift();
+ }
let row = [];
for (let c = 0; c < this.cols; c++) row.push({...this.defaultCell});
this.buffer.push(row);
}
+ scroll(amount) {
+ this.scrollOffset += amount;
+ if (this.scrollOffset < 0) this.scrollOffset = 0;
+ if (this.scrollOffset > this.scrollback.length) this.scrollOffset = this.scrollback.length;
+ }
+
renderHtml() {
let html = '';
let lastStyle = null;
const colorMap = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'];
+ let viewBuffer = [];
+ if (this.scrollOffset > 0) {
+ let startIdx = this.scrollback.length - this.scrollOffset;
+ for (let i = 0; i < this.rows; i++) {
+ let idx = startIdx + i;
+ if (idx < this.scrollback.length) {
+ viewBuffer.push(this.scrollback[idx]);
+ } else {
+ viewBuffer.push(this.buffer[idx - this.scrollback.length]);
+ }
+ }
+ } else {
+ viewBuffer = this.buffer;
+ }
+
for (let r = 0; r < this.rows; r++) {
for (let c = 0; c < this.cols; c++) {
- let cell = this.buffer[r][c];
+ let cell = viewBuffer[r][c];
- let isCursor = (r === this.cy && c === this.cx);
+ let isCursor = (this.scrollOffset === 0 && r === this.cy && c === this.cx);
let styleKey = `${cell.fg}-${cell.bg}-${cell.bold}-${isCursor}`;
if (styleKey !== lastStyle) {
@@ -208,7 +291,7 @@ class TerminalCore {
}
html += (cell.char === ' ' || cell.char === '') ? ' ' : this.escapeHtml(cell.char);
}
- html += '<br>';
+ if (r < this.rows - 1) html += '<br>';
}
if (lastStyle !== null) html += '</span>';
return html;
diff --git a/src/builder/b-nspawn.c b/src/builder/b-nspawn.c
index fa361e5..881eb5c 100644
--- a/src/builder/b-nspawn.c
+++ b/src/builder/b-nspawn.c
@@ -645,6 +645,7 @@ saib_spawn_script(struct sai_nspawn *ns)
info.timeout_us = 30 * 60 * LWS_US_PER_SEC;
info.reap_cb = sai_lsp_reap_cb;
info.pty_mode = 1;
+ info.disable_ctrlc = 1;
memset(&ns->res, 0, sizeof(ns->res));
info.res = &ns->res;
#if defined(__linux__)
@@ -697,6 +698,7 @@ sai_shell_reap_cb(void *opaque, const lws_spawn_resource_us_t *res, siginfo_t *s
lws_dll2_remove(&sh->list);
free(sh);
}
+ saib_reassess_idle_situation();
}
static int
@@ -757,6 +759,23 @@ callback_sai_shell_stdwsi(struct lws *wsi, enum lws_callback_reasons reason,
lwsl_notice("%s: PTYDATA queueing %d bytes from shell %s to server\n", __func__, (int)len, sh->task_uuid);
saib_srv_queue_tx(sh->spm->ss, lj + LWS_PRE, (size_t)n, LWSSS_FLAG_SOM | LWSSS_FLAG_EOM);
+
+ if (lws_buflist2_total_len(&sh->spm->bl_to_srv) > (LWS_BUFLIST_OOM_LIMIT - (256 * 1024))) {
+ /* buflist is getting full, backpressure ALL active shell stdwsi for this connection */
+ lws_start_foreach_dll_safe(struct lws_dll2 *, d, d1, builder.shell_owner.head) {
+ struct sai_shell *s = lws_container_of(d, struct sai_shell, list);
+ if (s->spm == sh->spm) {
+ for (int i = 0; i < 3; i++) {
+ if (!s->stdwsi_paused[i] && s->stdwsi[i]) {
+ s->stdwsi_paused[i] = 1;
+ lws_rx_flow_control(s->stdwsi[i], 0); /* 0 disables RX */
+ lwsl_notice("%s: Backpressure applied to shell %s ch %d (tot %zu)\n",
+ __func__, s->task_uuid, i, lws_buflist2_total_len(&sh->spm->bl_to_srv));
+ }
+ }
+ }
+ } lws_end_foreach_dll_safe(d, d1);
+ }
break;
default:
@@ -802,6 +821,7 @@ saib_shell_spawn(struct sai_plat_server *spm, const char *task_uuid)
info.timeout_us = 30 * 60 * LWS_US_PER_SEC;
info.reap_cb = sai_shell_reap_cb;
info.pty_mode = 1;
+ info.disable_ctrlc = 0;
info.opaque = sh;
info.owner = &builder.lsp_owner;
info.plsp = &sh->lsp;
@@ -817,5 +837,6 @@ saib_shell_spawn(struct sai_plat_server *spm, const char *task_uuid)
}
lwsl_notice("%s: Spawned shell successfully for %s\n", __func__, task_uuid);
+ saib_reassess_idle_situation();
return 0;
}
diff --git a/src/builder/b-power.c b/src/builder/b-power.c
index 086f87e..b8fc1b6 100644
--- a/src/builder/b-power.c
+++ b/src/builder/b-power.c
@@ -203,8 +203,14 @@ saib_reassess_idle_situation()
} lws_end_foreach_dll_safe(mp, mp1);
+ if (builder.shell_owner.head) {
+ lwsl_notice("%s: Builder has %d active shell sessions\n", __func__, builder.shell_owner.count);
+ lws_sul_cancel(&builder.sul_idle);
+ in_use = 1;
+ }
+
if (in_use) {
- lwsl_warn("%s: cancelling idle grace time as ongoing task steps\n", __func__);
+ lwsl_warn("%s: cancelling idle grace time as ongoing task steps or shells\n", __func__);
return 0;
}
@@ -217,7 +223,7 @@ saib_reassess_idle_situation()
*/
if (lws_dll2_is_detached(&builder.sul_idle.list)) {
- int grace_secs = builder.event_affinity_active ? 1 : (int)(SAI_IDLE_GRACE_US / LWS_US_PER_SEC);
+ int grace_secs = builder.one_shot_active ? 2 : (builder.event_affinity_active ? 15 : (int)(SAI_IDLE_GRACE_US / LWS_US_PER_SEC));
lwsl_notice("%s: %s: NO STAY and NO TASKS: starting %d sec idle grace time before auto-power-off\n",
__func__, builder.host, grace_secs);
lws_sul_schedule(builder.context, 0, &builder.sul_idle,
@@ -310,9 +316,6 @@ saib_stay_init(void)
if (!builder.url_sai_power)
return 0;
- if (!suspender_exists)
- return LWSSSSRET_OK;
-
snprintf(builder.path, sizeof(builder.path) - 1, "%s/stay/%s",
builder.url_sai_power, builder.host);
diff --git a/src/builder/b-private.h b/src/builder/b-private.h
index 0562a71..87a2591 100644
--- a/src/builder/b-private.h
+++ b/src/builder/b-private.h
@@ -104,6 +104,7 @@ struct sai_shell {
struct sai_plat_server *spm;
struct lws_spawn_piped *lsp;
struct lws *stdwsi[3];
+ char stdwsi_paused[3];
int user_cancel;
};
diff --git a/src/builder/b-task.c b/src/builder/b-task.c
index 3c938f8..4fb6c23 100644
--- a/src/builder/b-task.c
+++ b/src/builder/b-task.c
@@ -367,7 +367,7 @@ saib_task_destroy(struct sai_nspawn *ns)
* Schedule informing all the servers we're connected to
*/
- if (!m) {
+ if (!m && !builder.shell_owner.head) {
#if defined(__APPLE__)
if (!saib_need_wakelock()) {
lwsl_notice("%s: last task finished, scheduling wakelock release\n", __func__);
diff --git a/src/builder/b-ws-server.c b/src/builder/b-ws-server.c
index 9644f20..43f1623 100644
--- a/src/builder/b-ws-server.c
+++ b/src/builder/b-ws-server.c
@@ -29,6 +29,7 @@
#include <libwebsockets.h>
#include <string.h>
#include <signal.h>
+#include <sys/ioctl.h>
#include "b-private.h"
@@ -309,35 +310,46 @@ saib_m_rx(void *userobj, const uint8_t *in, size_t len, int flags)
break;
}
- case SAIB_RX_PTYDATA:
- {
- sai_ptydata_t *pd = (sai_ptydata_t *)a.dest;
- struct sai_shell *sh = NULL;
+ case SAIB_RX_PTYDATA:
+ {
+ sai_ptydata_t *pd = (sai_ptydata_t *)a.dest;
+ struct sai_shell *sh = NULL;
+
+ lws_start_foreach_dll(struct lws_dll2 *, d, builder.shell_owner.head) {
+ struct sai_shell *s = lws_container_of(d, struct sai_shell, list);
+ if (!strcmp(s->task_uuid, pd->task_uuid)) {
+ sh = s;
+ break;
+ }
+ } lws_end_foreach_dll(d);
- lws_start_foreach_dll(struct lws_dll2 *, d, builder.shell_owner.head) {
- struct sai_shell *s = lws_container_of(d, struct sai_shell, list);
- if (!strcmp(s->task_uuid, pd->task_uuid)) {
- sh = s;
- break;
- }
- } lws_end_foreach_dll(d);
+ if (sh && sh->lsp && (pd->data || (pd->cols && pd->rows))) {
+ int fd = lws_spawn_get_fd_stdxxx(sh->lsp, 0);
+ if (fd >= 0) {
+ if (pd->cols && pd->rows) {
+ struct winsize ws;
+ memset(&ws, 0, sizeof(ws));
+ ws.ws_col = (unsigned short)pd->cols;
+ ws.ws_row = (unsigned short)pd->rows;
+ if (ioctl(fd, TIOCSWINSZ, &ws) < 0)
+ lwsl_err("%s: failed to set pty size\n", __func__);
+ }
- if (sh && sh->lsp && pd->data) {
- int fd = lws_spawn_get_fd_stdxxx(sh->lsp, 0);
- if (fd >= 0) {
- char dec[1024];
- int dl = lws_b64_decode_string(pd->data, dec, sizeof(dec));
- if (dl > 0) {
- lwsl_notice("%s: PTYDATA received from server (len %d), writing to shell %s\n", __func__, dl, pd->task_uuid);
- if (write(fd, dec, LWS_POSIX_LENGTH_CAST(dl)) < 0)
- lwsl_err("%s: failed to write to pty\n", __func__);
+ if (pd->data && pd->len > 0) {
+ char dec[1024];
+ int dl = lws_b64_decode_string(pd->data, dec, sizeof(dec));
+ if (dl > 0) {
+ // lwsl_notice("%s: PTYDATA received from server (len %d), writing to shell %s\n", __func__, dl, pd->task_uuid);
+ if (write(fd, dec, LWS_POSIX_LENGTH_CAST(dl)) < 0)
+ lwsl_err("%s: failed to write to pty\n", __func__);
+ }
+ }
}
+ } else {
+ lwsl_warn("%s: PTYDATA for unknown shell %s\n", __func__, pd->task_uuid);
}
- } else {
- lwsl_warn("%s: PTYDATA for unknown shell %s\n", __func__, pd->task_uuid);
+ break;
}
- break;
- }
case SAIB_RX_RESOURCE_REPLY:
reso = (sai_resource_t *)a.dest;
@@ -453,6 +465,21 @@ saib_m_tx(void *userobj, lws_ss_tx_ordinal_t ord, uint8_t *buf, size_t *len,
}
} lws_end_foreach_dll_safe(d2, d3);
} lws_end_foreach_dll_safe(d, d1);
+
+ /* unpause any backpressured shell stdwsi */
+ lws_start_foreach_dll_safe(struct lws_dll2 *, d, d1, builder.shell_owner.head) {
+ struct sai_shell *sh = lws_container_of(d, struct sai_shell, list);
+ if (sh->spm == spm) {
+ for (int i = 0; i < 3; i++) {
+ if (sh->stdwsi_paused[i] && sh->stdwsi[i]) {
+ lwsl_notice("%s: Unpausing shell %s ch %d (tot %zu)\n", __func__, sh->task_uuid, i,
+ lws_buflist2_total_len(&spm->bl_to_srv));
+ sh->stdwsi_paused[i] = 0;
+ lws_rx_flow_control(sh->stdwsi[i], 1 | LWS_RXFLOW_REASON_USER_BOOL);
+ }
+ }
+ }
+ } lws_end_foreach_dll_safe(d, d1);
}
}
@@ -754,6 +781,29 @@ saib_m_state(void *userobj, void *sh, lws_ss_constate_t state,
&builder.sai_plat_owner))
return -1;
+ {
+ sai_active_shells_t ash;
+ struct lwsac *ac = NULL;
+
+ memset(&ash, 0, sizeof(ash));
+ lws_start_foreach_dll(struct lws_dll2 *, d, builder.shell_owner.head) {
+ struct sai_shell *sh = lws_container_of(d, struct sai_shell, list);
+ sai_active_shell_t *s = lwsac_use_zero(&ac, sizeof(*s), 512);
+ if (s) {
+ lws_strncpy(s->task_uuid, sh->task_uuid, sizeof(s->task_uuid));
+ lws_dll2_add_tail(&s->list, &ash.shells);
+ }
+ } lws_end_foreach_dll(d);
+
+ if (ash.shells.head) {
+ saib_srv_queue_json_fragments_helper(spm->ss,
+ lsm_schema_map_active_shells,
+ LWS_ARRAY_SIZE(lsm_schema_map_active_shells),
+ &ash);
+ }
+ lwsac_free(&ac);
+ }
+
return 0;
case LWSSSCS_DISCONNECTED:
diff --git a/src/common/include/private.h b/src/common/include/private.h
index ed827b6..5e10bb4 100644
--- a/src/common/include/private.h
+++ b/src/common/include/private.h
@@ -136,6 +136,15 @@ typedef struct sai_active_task_info {
char git_hash[65];
} sai_active_task_info_t;
+typedef struct sai_active_shell {
+ lws_dll2_t list;
+ char task_uuid[65];
+} sai_active_shell_t;
+
+typedef struct sai_active_shells {
+ lws_dll2_owner_t shells;
+} sai_active_shells_t;
+
typedef struct sai_load_report {
lws_dll2_t list; /* For queuing on sai_plat_server */
char builder_name[64];
@@ -358,6 +367,8 @@ typedef struct sai_ptydata {
int channel;
char *data;
size_t len;
+ unsigned int cols;
+ unsigned int rows;
} sai_ptydata_t;
/*
@@ -857,8 +868,10 @@ extern const lws_struct_map_t
lsm_openshell[2],
lsm_schema_openshell[1],
lsm_closeshell[1],
+ lsm_schema_map_active_shells[1],
+ lsm_schema_active_shells[1],
lsm_schema_closeshell[1],
- lsm_ptydata[5],
+ lsm_ptydata[7],
lsm_schema_ptydata[1],
lsm_rebuild[1],
lsm_schema_rebuild[1],
diff --git a/src/common/struct-metadata.c b/src/common/struct-metadata.c
index 43f0496..6e22df7 100644
--- a/src/common/struct-metadata.c
+++ b/src/common/struct-metadata.c
@@ -40,6 +40,19 @@ const lws_struct_map_t lsm_active_task_info[] = {
LSM_CARRAY (sai_active_task_info_t, git_hash, "git_hash"),
};
+const lws_struct_map_t lsm_active_shell[] = {
+ LSM_CARRAY (sai_active_shell_t, task_uuid, "task_uuid"),
+};
+
+const lws_struct_map_t lsm_schema_active_shells[] = {
+ LSM_LIST (sai_active_shells_t, shells, sai_active_shell_t, list,
+ NULL, lsm_active_shell, "shells"),
+};
+
+const lws_struct_map_t lsm_schema_map_active_shells[] = {
+ LSM_SCHEMA(sai_active_shells_t, NULL, lsm_schema_active_shells, "com.warmcat.sai.active_shells")
+};
+
const lws_struct_map_t lsm_load_report_members[] = {
LSM_CARRAY (sai_load_report_t, builder_name, "builder_name"),
LSM_SIGNED (sai_load_report_t, core_count, "core_count"),
@@ -237,6 +250,8 @@ const lws_struct_map_t lsm_ptydata[] = {
LSM_SIGNED (sai_ptydata_t, channel, "channel"),
LSM_STRING_PTR (sai_ptydata_t, data, "data"),
LSM_UNSIGNED (sai_ptydata_t, len, "len"),
+ LSM_UNSIGNED (sai_ptydata_t, cols, "cols"),
+ LSM_UNSIGNED (sai_ptydata_t, rows, "rows"),
};
const lws_struct_map_t lsm_schema_ptydata[] = {
diff --git a/src/server/s-private.h b/src/server/s-private.h
index 2540887..84883b0 100644
--- a/src/server/s-private.h
+++ b/src/server/s-private.h
@@ -211,6 +211,13 @@ typedef struct sais_plat {
int unmet_count;
} sais_plat_t;
+typedef struct sai_shell_session {
+ lws_dll2_t list;
+ char task_uuid[65];
+ char builder_name[96];
+ lws_dll2_owner_t ptydata_owner;
+} sai_shell_session_t;
+
struct vhd {
struct lws_context *context;
struct lws_vhost *vhost;
@@ -224,6 +231,7 @@ struct vhd {
struct lws_dll2_owner sai_powers;
struct lws_dll2_owner pending_plats;
lws_dll2_owner_t powering_up_list; /* sai_powering_up_plat_t */
+ lws_dll2_owner_t shell_sessions; /* sai_shell_session_t */
struct lwsac *ac_plats;
diff --git a/src/server/s-task.c b/src/server/s-task.c
index a1108a3..b0262c7 100644
--- a/src/server/s-task.c
+++ b/src/server/s-task.c
@@ -618,6 +618,18 @@ sais_platforms_with_tasks_pending(struct vhd *vhd)
} lws_end_foreach_dll(p);
+ /*
+ * Also account for any in-memory interactive shell sessions
+ */
+ lws_start_foreach_dll(struct lws_dll2 *, p_sh, vhd->shell_sessions.head) {
+ sai_shell_session_t *sh = lws_container_of(p_sh, sai_shell_session_t, list);
+ const char *plat = strchr(sh->builder_name, '.');
+ if (plat)
+ sais_find_or_add_pending_plat(vhd, plat + 1, 1, 1);
+ else
+ sais_find_or_add_pending_plat(vhd, sh->builder_name, 1, 1);
+ } lws_end_foreach_dll(p_sh);
+
sais_notify_all_sai_power(vhd);
/*
diff --git a/src/server/s-ws-builder.c b/src/server/s-ws-builder.c
index 2699112..2db6fc6 100644
--- a/src/server/s-ws-builder.c
+++ b/src/server/s-ws-builder.c
@@ -74,6 +74,8 @@ static const lws_struct_map_t lsm_schema_map_ba[] = {
"com.warmcat.sai.build-metric"),
LSM_SCHEMA (sai_ptydata_t, NULL, lsm_ptydata,
"com.warmcat.sai.ptydata"),
+ LSM_SCHEMA (sai_active_shells_t, NULL, lsm_schema_active_shells,
+ "com.warmcat.sai.active_shells"),
};
enum {
@@ -85,6 +87,7 @@ enum {
SAIM_WSSCH_BUILDER_RESOURCE_REQ,
SAIM_WSSCH_BUILDER_METRIC,
SAIM_WSSCH_BUILDER_PTYDATA,
+ SAIM_WSSCH_BUILDER_ACTIVE_SHELLS,
};
static void
@@ -960,16 +963,42 @@ sais_ws_json_rx_builder(struct vhd *vhd, struct pss *pss, uint8_t *buf, size_t b
goto bail;
}
} lws_end_foreach_dll(p);
- #if 0
- lws_start_foreach_dll(struct lws_dll2 *, p, vhd->server.builder_owner.head) {
- sp = lws_container_of(p, sai_plat_t, sai_plat_list);
- if (sp->wsi == pss->wsi) {
- /* This platform belongs to the connection that sent the message */
- if (sais_allocate_task(vhd, pss, sp, sp->platform) < 0)
- goto bail;
- }
- } lws_end_foreach_dll(p);
- #endif
+ /*
+ * Also, if there are any pending in-memory shell sessions for this builder,
+ * send them down now!
+ */
+ lws_start_foreach_dll(struct lws_dll2 *, p_sh, vhd->shell_sessions.head) {
+ sai_shell_session_t *sh = lws_container_of(p_sh, sai_shell_session_t, list);
+ lws_start_foreach_dll(struct lws_dll2 *, p, vhd->server.builder_owner.head) {
+ sai_plat_t *sp = lws_container_of(p, sai_plat_t, sai_plat_list);
+ const char *sh_plat = strchr(sh->builder_name, '.');
+ if (sh_plat) sh_plat++; else sh_plat = sh->builder_name;
+
+ const char *sp_plat = strchr(sp->name, '.');
+ if (sp_plat) sp_plat++; else sp_plat = sp->name;
+
+ if (sp->wsi == pss->wsi &&
+ (!strcmp(sh->builder_name, sp->name) || !strcmp(sh_plat, sp_plat))) {
+ sai_openshell_t *s = malloc(sizeof(*s));
+ if (s) {
+ memset(s, 0, sizeof(*s));
+ lws_strncpy(s->task_uuid, sh->task_uuid, sizeof(s->task_uuid));
+ lws_strncpy(s->builder_name, sp->name, sizeof(s->builder_name));
+ lws_dll2_add_tail(&s->list, &pss->openshell_owner);
+
+ lws_start_foreach_dll_safe(struct lws_dll2 *, pd, pd1, sh->ptydata_owner.head) {
+ sai_ptydata_t *pdy = lws_container_of(pd, sai_ptydata_t, list);
+ lws_dll2_remove(pd);
+ lws_strncpy(pdy->builder_name, sp->name, sizeof(pdy->builder_name));
+ lws_dll2_add_tail(&pdy->list, &pss->ptydata_owner);
+ } lws_end_foreach_dll_safe(pd, pd1);
+
+ lws_callback_on_writable(pss->wsi);
+ }
+ break;
+ }
+ } lws_end_foreach_dll(p);
+ } lws_end_foreach_dll(p_sh);
/*
* If we did allocate a task in pss->a.ac, responsibility of
@@ -1027,6 +1056,46 @@ sais_ws_json_rx_builder(struct vhd *vhd, struct pss *pss, uint8_t *buf, size_t b
lwsac_free(&pss->a.ac);
break;
+ case SAIM_WSSCH_BUILDER_ACTIVE_SHELLS:
+ {
+ sai_active_shells_t *ash = (sai_active_shells_t *)pss->a.dest;
+ lws_start_foreach_dll(struct lws_dll2 *, d, ash->shells.head) {
+ sai_active_shell_t *s = lws_container_of(d, sai_active_shell_t, list);
+ int found = 0;
+ lws_start_foreach_dll(struct lws_dll2 *, d2, vhd->shell_sessions.head) {
+ sai_shell_session_t *sh = lws_container_of(d2, sai_shell_session_t, list);
+ if (!strcmp(sh->task_uuid, s->task_uuid)) {
+ found = 1;
+ break;
+ }
+ } lws_end_foreach_dll(d2);
+
+ if (!found) {
+ sai_shell_session_t *nsh = malloc(sizeof(*nsh));
+ if (nsh) {
+ memset(nsh, 0, sizeof(*nsh));
+ lws_strncpy(nsh->task_uuid, s->task_uuid, sizeof(nsh->task_uuid));
+ if (vhd->server.builder_owner.head) {
+ lws_start_foreach_dll(struct lws_dll2 *, p, vhd->server.builder_owner.head) {
+ sai_plat_t *sp = lws_container_of(p, sai_plat_t, sai_plat_list);
+ if (sp->wsi == pss->wsi) {
+ lws_strncpy(nsh->builder_name, sp->name, sizeof(nsh->builder_name));
+ break;
+ }
+ } lws_end_foreach_dll(p);
+ }
+ lws_dll2_add_tail(&nsh->list, &vhd->shell_sessions);
+ lwsl_notice("%s: Rediscovered shell %s for builder %s\n", __func__, nsh->task_uuid, nsh->builder_name);
+ }
+ }
+ } lws_end_foreach_dll(d);
+
+ sais_platforms_with_tasks_pending(vhd);
+
+ lwsac_free(&pss->a.ac);
+ break;
+ }
+
case SAIM_WSSCH_BUILDER_LOADREPORT:
/*
@@ -1432,7 +1501,7 @@ sais_ws_json_rx_builder(struct vhd *vhd, struct pss *pss, uint8_t *buf, size_t b
info.len = used;
info.ss_flags = LWSSS_FLAG_SOM | LWSSS_FLAG_EOM;
- lwsl_notice("%s: PTYDATA received from builder shell %s, relaying %d bytes to web\n", __func__, pd->task_uuid, (int)used);
+ // lwsl_notice("%s: PTYDATA received from builder shell %s, relaying %d bytes to web\n", __func__, pd->task_uuid, (int)used);
if (sais_websrv_broadcast_REQUIRES_LWS_PRE(vhd->h_ss_websrv, &info) < 0)
lwsl_warn("%s: unable to broadcast to web\n", __func__);
diff --git a/src/server/s-ws-web.c b/src/server/s-ws-web.c
index 517e79b..3ca4cf3 100644
--- a/src/server/s-ws-web.c
+++ b/src/server/s-ws-web.c
@@ -693,32 +693,36 @@ websrvss_ws_rx(void *userobj, const uint8_t *buf, size_t len, int flags)
lwsl_notice("%s: OPENSHELL received from web for %s, passing to builder\n", __func__, os->builder_name);
- sp = sais_builder_from_uuid(m->vhd, os->builder_name);
- if (!sp) {
- lwsl_err("%s: unknown builder %s for openshell\n",
- __func__, os->builder_name);
- lwsac_free(&a.ac);
- break;
- }
-
if (!os->task_uuid[0])
sai_uuid16_create(m->vhd->context, os->task_uuid);
- lws_start_foreach_dll(struct lws_dll2 *, p, m->vhd->builders.head) {
- struct pss *pss = lws_container_of(p, struct pss, same);
+ /* Add it to in-memory shell sessions list */
+ sai_shell_session_t *sh = malloc(sizeof(*sh));
+ if (sh) {
+ memset(sh, 0, sizeof(*sh));
+ lws_strncpy(sh->task_uuid, os->task_uuid, sizeof(sh->task_uuid));
+ lws_strncpy(sh->builder_name, os->builder_name, sizeof(sh->builder_name));
+ lws_dll2_add_tail(&sh->list, &m->vhd->shell_sessions);
+ }
- if (pss->wsi == sp->wsi) {
- sai_openshell_t *s = malloc(sizeof(*s));
- if (s) {
- *s = *os;
- lws_dll2_add_tail(&s->list, &pss->openshell_owner);
- lws_callback_on_writable(pss->wsi);
- }
- break;
- }
- } lws_end_foreach_dll(p);
+ sp = sais_builder_from_uuid(m->vhd, os->builder_name);
+ if (sp) {
+ lws_start_foreach_dll(struct lws_dll2 *, p, m->vhd->builders.head) {
+ struct pss *pss = lws_container_of(p, struct pss, same);
+ if (pss->wsi == sp->wsi) {
+ sai_openshell_t *s = malloc(sizeof(*s));
+ if (s) {
+ *s = *os;
+ lws_dll2_add_tail(&s->list, &pss->openshell_owner);
+ lws_callback_on_writable(pss->wsi);
+ }
+ break;
+ }
+ } lws_end_foreach_dll(p);
+ }
+ sais_platforms_with_tasks_pending(m->vhd);
lwsac_free(&a.ac);
break;
@@ -727,7 +731,26 @@ websrvss_ws_rx(void *userobj, const uint8_t *buf, size_t len, int flags)
case SAIS_WS_WEBSRV_RX_CLOSESHELL:
{
sai_closeshell_t *cs = (sai_closeshell_t *)a.dest;
+
+ lws_start_foreach_dll_safe(struct lws_dll2 *, d, d1, m->vhd->shell_sessions.head) {
+ sai_shell_session_t *sh = lws_container_of(d, sai_shell_session_t, list);
+ if (!strcmp(sh->task_uuid, cs->task_uuid)) {
+ lws_start_foreach_dll_safe(struct lws_dll2 *, pd, pd1, sh->ptydata_owner.head) {
+ sai_ptydata_t *pdy = lws_container_of(pd, sai_ptydata_t, list);
+ lws_dll2_remove(&pdy->list);
+ if (pdy->data) free(pdy->data);
+ free(pdy);
+ } lws_end_foreach_dll_safe(pd, pd1);
+
+ lws_dll2_remove(&sh->list);
+ free(sh);
+ break;
+ }
+ } lws_end_foreach_dll_safe(d, d1);
+
sais_task_cancel(m->vhd, cs->task_uuid, 0);
+ sais_platforms_with_tasks_pending(m->vhd);
+
lwsac_free(&a.ac);
break;
}
@@ -739,12 +762,31 @@ websrvss_ws_rx(void *userobj, const uint8_t *buf, size_t len, int flags)
sp = sais_builder_from_uuid(m->vhd, pd->builder_name);
if (!sp) {
- lwsl_err("%s: PTYDATA from web: unknown builder %s\n", __func__, pd->builder_name);
+ /* Builder offline, buffer it! */
+ lws_start_foreach_dll(struct lws_dll2 *, d, m->vhd->shell_sessions.head) {
+ sai_shell_session_t *sh = lws_container_of(d, sai_shell_session_t, list);
+ if (!strcmp(sh->task_uuid, pd->task_uuid)) {
+ sai_ptydata_t *s = malloc(sizeof(*s));
+ if (s) {
+ size_t slen = pd->data ? strlen(pd->data) : 0;
+ *s = *pd;
+ s->data = pd->data ? malloc(slen + 1) : NULL;
+ if (s->data || !pd->data) {
+ if (pd->data)
+ memcpy((char *)s->data, pd->data, slen + 1);
+ lws_dll2_add_tail(&s->list, &sh->ptydata_owner);
+ } else
+ free(s);
+ }
+ break;
+ }
+ } lws_end_foreach_dll(d);
+
lwsac_free(&a.ac);
break;
}
- lwsl_notice("%s: PTYDATA received from web (len %d), passing to builder %s\n", __func__, (int)pd->len, pd->builder_name);
+ // lwsl_notice("%s: PTYDATA received from web (len %d), passing to builder %s\n", __func__, (int)pd->len, pd->builder_name);
lws_start_foreach_dll(struct lws_dll2 *, p, m->vhd->builders.head) {
struct pss *pss = lws_container_of(p, struct pss, same);
@@ -752,11 +794,12 @@ websrvss_ws_rx(void *userobj, const uint8_t *buf, size_t len, int flags)
if (pss->wsi == sp->wsi) {
sai_ptydata_t *s = malloc(sizeof(*s));
if (s) {
- size_t slen = strlen(pd->data);
+ size_t slen = pd->data ? strlen(pd->data) : 0;
*s = *pd;
- s->data = malloc(slen + 1);
- if (s->data) {
- memcpy((char *)s->data, pd->data, slen + 1);
+ s->data = pd->data ? malloc(slen + 1) : NULL;
+ if (s->data || !pd->data) {
+ if (pd->data)
+ memcpy((char *)s->data, pd->data, slen + 1);
lws_dll2_add_tail(&s->list, &pss->ptydata_owner);
lws_callback_on_writable(pss->wsi);
} else
diff --git a/src/virt/v-http-api.c b/src/virt/v-http-api.c
index f9cd831..91f3887 100644
--- a/src/virt/v-http-api.c
+++ b/src/virt/v-http-api.c
@@ -73,7 +73,7 @@ callback_virt_http(struct lws *wsi, enum lws_callback_reasons reason,
if (found_vm) {
/* Extend the safety timeout since the VM is alive and communicating */
lws_sul_schedule(virt.context, 0, &found_vm->sul_timeout,
- saiv_vm_timeout_cb, 5 * 60 * LWS_US_PER_SEC);
+ saiv_vm_timeout_cb, 30 * LWS_US_PER_SEC);
}
/* We never return stay = true for ephemeral VMs */
diff --git a/src/web/w-comms.c b/src/web/w-comms.c
index e012760..7eb11d2 100644
--- a/src/web/w-comms.c
+++ b/src/web/w-comms.c
@@ -163,6 +163,28 @@ w_callback_ws(struct lws *wsi, enum lws_callback_reasons reason, void *user,
vhd->context = lws_get_context(wsi);
vhd->vhost = lws_get_vhost(wsi);
+ {
+ const struct lws_protocol_vhost_options *pvo = (const struct lws_protocol_vhost_options *)in;
+ const char *jwk_path = "/etc/sai/web/auth.jwk";
+
+ lws_strncpy(vhd->cookie_name, "auth_session", sizeof(vhd->cookie_name));
+
+ while (pvo) {
+ if (!strcmp(pvo->name, "jwt-auth-jwk-path"))
+ jwk_path = pvo->value;
+ if (!strcmp(pvo->name, "cookie-name"))
+ lws_strncpy(vhd->cookie_name, pvo->value, sizeof(vhd->cookie_name));
+ pvo = pvo->next;
+ }
+ lws_strncpy(vhd->jwk_path, jwk_path, sizeof(vhd->jwk_path));
+ if (!lws_jwk_load(&vhd->jwk, vhd->jwk_path, NULL, NULL)) {
+ vhd->has_jwk = 1;
+ lwsl_notice("Loaded JWT jwk from %s\n", vhd->jwk_path);
+ } else {
+ lwsl_err("FAILED to load JWT jwk from %s\n", vhd->jwk_path);
+ }
+ }
+
if (lws_pvo_get_str(in, "database", &vhd->sqlite3_path_lhs)) {
lwsl_err("%s: database pvo required\n", __func__);
return -1;
@@ -483,6 +505,36 @@ http_resp:
}
#endif
+ pss->authorized = 0;
+ if (vhd->has_jwk) {
+#if defined(LWS_WITH_JOSE)
+ const char *reason = "unknown";
+ struct lws_jwt_auth *ja = lws_jwt_auth_create(wsi, &vhd->jwk, vhd->cookie_name, NULL, NULL, &reason);
+ if (ja) {
+ if (lws_jwt_auth_query_grant(ja, "*") >= 1 || lws_jwt_auth_query_grant(ja, "com.warmcat.sai") >= 1) {
+ pss->authorized = 1;
+ lwsl_wsi_notice(wsi, "Authorized WebSocket connection (admin/grant)");
+ } else {
+ lwsl_wsi_err(wsi, "JWT validation passed, but no grant found");
+ }
+ lws_jwt_auth_destroy(&ja);
+ } else {
+ if (reason && !strcmp(reason, "Cookie not found")) {
+ char cookies[512];
+ int n = lws_hdr_copy(wsi, cookies, sizeof(cookies), WSI_TOKEN_HTTP_COOKIE);
+ if (n > 0)
+ lwsl_wsi_err(wsi, "JWT auth failed (cookie: '%s'): Cookie not found (available cookies: '%s') (using JWK from %s)", vhd->cookie_name, cookies, vhd->jwk_path);
+ else
+ lwsl_wsi_err(wsi, "JWT auth failed (cookie: '%s'): Cookie not found (no cookies sent by browser) (using JWK from %s)", vhd->cookie_name, vhd->jwk_path);
+ } else {
+ lwsl_wsi_err(wsi, "JWT auth failed (cookie: '%s'): %s (using JWK from %s)", vhd->cookie_name, reason ? reason : "unknown", vhd->jwk_path);
+ }
+ }
+#endif
+ } else {
+ lwsl_wsi_err(wsi, "Cannot validate JWT because no JWK was loaded (expected at %s)", vhd->jwk_path);
+ }
+
if (!memcmp((char *)start, "/sai", 4))
start += 4;
diff --git a/src/web/w-private.h b/src/web/w-private.h
index b2e0ae7..2e9e057 100644
--- a/src/web/w-private.h
+++ b/src/web/w-private.h
@@ -124,12 +124,18 @@ struct pss {
unsigned int announced:1;
unsigned int bulk_binary_data:1;
unsigned int toggle_favour_sch:1;
+ unsigned int authorized:1;
};
struct vhd {
struct lws_context *context;
struct lws_vhost *vhost;
+ struct lws_jwk jwk;
+ int has_jwk;
+ char cookie_name[64];
+ char jwk_path[256];
+
/* pss lists */
struct lws_dll2_owner browsers;
diff --git a/src/web/w-ws-browser.c b/src/web/w-ws-browser.c
index cfa5d40..842565e 100644
--- a/src/web/w-ws-browser.c
+++ b/src/web/w-ws-browser.c
@@ -641,6 +641,22 @@ saiw_ws_json_rx_browser(struct vhd *vhd, struct pss *pss, uint8_t *buf,
* matched on
*/
+ if (!pss->authorized && (
+ a.top_schema_index == SAIM_WS_BROWSER_RX_TASKRESET ||
+ a.top_schema_index == SAIM_WS_BROWSER_RX_TASKREBUILDLASTSTEP ||
+ a.top_schema_index == SAIM_WS_BROWSER_RX_EVENTRESET ||
+ a.top_schema_index == SAIM_WS_BROWSER_RX_EVENTDELETE ||
+ a.top_schema_index == SAIM_WS_BROWSER_RX_TASKCANCEL ||
+ a.top_schema_index == SAIM_WS_BROWSER_RX_REBUILD ||
+ a.top_schema_index == SAIM_WS_BROWSER_RX_PLATRESET ||
+ a.top_schema_index == SAIM_WS_BROWSER_RX_BUILDERDELETE ||
+ a.top_schema_index == SAIM_WS_BROWSER_RX_OPENSHELL ||
+ a.top_schema_index == SAIM_WS_BROWSER_RX_CLOSESHELL ||
+ a.top_schema_index == SAIM_WS_BROWSER_RX_PTYDATA)) {
+ lwsl_notice("%s: Unauthorized attempt to execute administrative action (schema %d)\n", __func__, a.top_schema_index);
+ goto soft_error;
+ }
+
switch (a.top_schema_index) {
case SAIM_WS_BROWSER_RX_TASKINFO: