refactor: fine tune serial console

Signed-off-by: Andy Lee <andy.lee@suse.com>
This commit is contained in:
Andy Lee 2026-08-04 00:35:06 +08:00
parent 9f9069fb63
commit 12a51b2451
No known key found for this signature in database
GPG Key ID: 39DC4436AE3564D5
2 changed files with 130 additions and 28 deletions

View File

@ -21,22 +21,24 @@ export default {
data() { data() {
return { return {
socket: null, socket: null,
terminal: null, terminal: null,
textDecoder: null, textDecoder: null,
fitAddon: null, fitAddon: null,
searchAddon: null, searchAddon: null,
webglAddon: null, webglAddon: null,
onResize: null, onResize: null,
isOpen: false, isOpen: false,
isOpening: false, isOpening: false,
backlog: [], backlog: [],
firstTime: true, firstTime: true,
queue: [], queue: [],
isDraining: false, isDraining: false,
showSearch: false, drainScheduled: false,
searchQuery: '', drainTimer: null,
searchOptions: { showSearch: false,
searchQuery: '',
searchOptions: {
caseSensitive: false, caseSensitive: false,
regex: false, regex: false,
wholeWord: false, wholeWord: false,
@ -54,12 +56,17 @@ export default {
}, },
computed: { computed: {
serialConsoleUrl() {
return this.value?.getSerialConsolePath || '';
},
xtermConfig() { xtermConfig() {
return { return {
allowProposedApi: true, allowProposedApi: true,
cursorBlink: true, cursorBlink: true,
cursorStyle: 'bar', cursorStyle: 'bar',
cursorWidth: 2, cursorWidth: 2,
scrollback: 5000,
useStyle: true, useStyle: true,
fontFamily: 'JetBrains Mono, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', fontFamily: 'JetBrains Mono, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: 16, fontSize: 16,
@ -72,6 +79,14 @@ export default {
this.close(); this.close();
}, },
watch: {
serialConsoleUrl() {
if (this.terminal) {
this.connect();
}
},
},
async mounted() { async mounted() {
this.textDecoder = new TextDecoder('utf-8'); this.textDecoder = new TextDecoder('utf-8');
await this.setupTerminal(); await this.setupTerminal();
@ -146,6 +161,7 @@ export default {
this.fit(); this.fit();
this.flush(); this.flush();
this.scheduleDrainQueue();
terminal.onData((input) => { terminal.onData((input) => {
const msg = this.str2ab(input); const msg = this.str2ab(input);
@ -191,10 +207,17 @@ export default {
closeSearch() { closeSearch() {
this.showSearch = false; this.showSearch = false;
this.searchAddon?.clearDecorations(); this.searchQuery = '';
this.clearSearchHighlights();
this.terminal?.focus(); this.terminal?.focus();
}, },
clearSearchHighlights() {
this.terminal?.clearSelection();
this.searchAddon?.clearActiveDecoration();
this.searchAddon?.clearDecorations();
},
findNext() { findNext() {
if (!this.searchAddon || !this.searchQuery) { if (!this.searchAddon || !this.searchQuery) {
return; return;
@ -213,7 +236,7 @@ export default {
onSearchInput() { onSearchInput() {
if (!this.searchQuery) { if (!this.searchQuery) {
this.searchAddon?.clearDecorations(); this.clearSearchHighlights();
return; return;
} }
@ -282,7 +305,31 @@ export default {
enqueueMessage(messagePromise) { enqueueMessage(messagePromise) {
this.queue.push(messagePromise); this.queue.push(messagePromise);
this.drainQueue(); this.scheduleDrainQueue();
},
scheduleDrainQueue() {
if (this.drainScheduled || this.isDraining || !this.terminal) {
return;
}
this.drainScheduled = true;
const shouldDrainImmediately = this.queue.length <= 3;
const runDrain = () => {
this.drainScheduled = false;
this.drainTimer = null;
this.drainQueue();
};
if (shouldDrainImmediately) {
runDrain();
return;
}
this.drainTimer = requestAnimationFrame(runDrain);
}, },
async decodeMessageData(data) { async decodeMessageData(data) {
@ -331,7 +378,11 @@ export default {
const output = messages.filter(Boolean).join(''); const output = messages.filter(Boolean).join('');
if (output) { if (output) {
this.terminal.write(output); await this.writeOutputInChunks(output);
}
if (this.queue.length > 0) {
await this.nextFrame();
} }
} }
} finally { } finally {
@ -340,17 +391,43 @@ export default {
// If data arrived between the last while-check and releasing the lock, // If data arrived between the last while-check and releasing the lock,
// immediately schedule another drain so output never gets stuck. // immediately schedule another drain so output never gets stuck.
if (this.queue.length > 0 && this.terminal) { if (this.queue.length > 0 && this.terminal) {
this.drainQueue(); this.scheduleDrainQueue();
} }
} }
}, },
async writeOutputInChunks(output) {
if (!output) {
return;
}
const chunkSize = output.length > 20000 ? 16000 : output.length > 8000 ? 8000 : output.length;
if (chunkSize === output.length) {
this.terminal.write(output);
return;
}
for (let index = 0; index < output.length; index += chunkSize) {
this.terminal.write(output.slice(index, index + chunkSize));
if (index + chunkSize < output.length) {
await this.nextFrame();
}
}
},
nextFrame() {
return new Promise((resolve) => requestAnimationFrame(resolve));
},
clear() { clear() {
this.terminal.clear(); this.terminal.clear();
}, },
getSocketUrl() { getSocketUrl() {
return `${ this.value?.getSerialConsolePath }`; return this.serialConsoleUrl;
}, },
async connect() { async connect() {
@ -432,6 +509,11 @@ export default {
this.onResize = null; this.onResize = null;
} }
if (this.drainTimer) {
cancelAnimationFrame(this.drainTimer);
this.drainTimer = null;
}
if ( this.socket ) { if ( this.socket ) {
this.socket.disconnect(); this.socket.disconnect();
} }
@ -544,13 +626,22 @@ export default {
.shell-body { .shell-body {
flex: 1 1 auto; flex: 1 1 auto;
height: 100%; height: 100%;
width: 100%;
min-height: 0; min-height: 0;
padding: 0 10px; padding: 0 10px;
box-sizing: border-box; box-sizing: border-box;
} }
.terminal.xterm { .terminal.xterm,
.terminal.xterm .xterm-viewport,
.terminal.xterm .xterm-screen,
.terminal.xterm .xterm-scrollable-element {
width: 100%;
height: 100%; height: 100%;
} }
.terminal.xterm .xterm-scrollable-element {
min-height: 100%;
}
} }
</style> </style>

View File

@ -1,10 +1,9 @@
<script> <script>
import Loading from '@shell/components/Loading';
import { HCI } from '../../../../../types'; import { HCI } from '../../../../../types';
import SerialConsole from '../../../../../components/SerialConsole'; import SerialConsole from '../../../../../components/SerialConsole';
export default { export default {
components: { SerialConsole, Loading }, components: { SerialConsole },
async fetch() { async fetch() {
this.rows = await this.$store.dispatch('harvester/findAll', { type: HCI.VMI }); this.rows = await this.$store.dispatch('harvester/findAll', { type: HCI.VMI });
@ -39,11 +38,9 @@ export default {
<template> <template>
<div class="serial-console-page"> <div class="serial-console-page">
<Loading v-if="$fetchState.pending" />
<SerialConsole <SerialConsole
v-else
ref="serialConsole" ref="serialConsole"
v-model:value="vmi" :value="vmi || {}"
/> />
</div> </div>
</template> </template>
@ -61,9 +58,23 @@ export default {
position: fixed; position: fixed;
inset: 0; inset: 0;
min-height: 0; min-height: 0;
background: var(--body-bg);
color: var(--terminal-text);
&::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 6px;
pointer-events: none;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.25));
}
.harvester-shell-container { .harvester-shell-container {
height: 100%; height: 100%;
background: var(--terminal-bg);
} }
} }
</style> </style>