Feature: Rewrite virutal console to support new line break and colored highlighting

This commit is contained in:
Thomas Basler
2025-04-18 13:13:07 +02:00
parent 8372deaf7c
commit 98e6d05e4e
8 changed files with 166 additions and 48 deletions

View File

@@ -380,7 +380,8 @@
"VirtualDebugConsole": "Εικονική κονσόλα εντοπισμού σφαλμάτων",
"EnableAutoScroll": "Ενεργοποίηση αυτόματης κύλισης",
"ClearConsole": "Εκκαθάριση κονσόλας",
"CopyToClipboard": "Αντιγραφή στο πρόχειρο"
"CopyToClipboard": "Αντιγραφή στο πρόχειρο",
"Download": "Download"
},
"inverterchannelinfo": {
"String": "Είσοδος {num}",

View File

@@ -380,7 +380,8 @@
"VirtualDebugConsole": "Consola de Depuración Virtual",
"EnableAutoScroll": "Habilitar Desplazamiento Automático",
"ClearConsole": "Limpiar Consola",
"CopyToClipboard": "Copiar al Portapapeles"
"CopyToClipboard": "Copiar al Portapapeles",
"Download": "Download"
},
"inverterchannelinfo": {
"String": "Cadena {num}",

View File

@@ -380,7 +380,8 @@
"VirtualDebugConsole": "Virtual Debug Console",
"EnableAutoScroll": "Abilita AutoScroll",
"ClearConsole": "Pulisci Console",
"CopyToClipboard": "Copia nella clipboard"
"CopyToClipboard": "Copia nella clipboard",
"Download": "Download"
},
"inverterchannelinfo": {
"String": "Stringa {num}",

View File

@@ -380,7 +380,8 @@
"VirtualDebugConsole": "Wirtualna konsola debugowania",
"EnableAutoScroll": "Włącz automatyczne przewijani",
"ClearConsole": "Wyczyść konsolę",
"CopyToClipboard": "Kopiuj do schowka"
"CopyToClipboard": "Kopiuj do schowka",
"Download": "Download"
},
"inverterchannelinfo": {
"String": "Numer panelu {num}",

View File

@@ -365,7 +365,8 @@
"VirtualDebugConsole": "Virtuelle Debug-Konsole",
"EnableAutoScroll": "Automatisches Scrollen aktivieren",
"ClearConsole": "Konsole leeren",
"CopyToClipboard": "In die Zwischenablage kopieren"
"CopyToClipboard": "In die Zwischenablage kopieren",
"Download": "Herunterladen"
},
"inverterchannelinfo": {
"String": "String {num}",

View File

@@ -365,7 +365,8 @@
"VirtualDebugConsole": "Virtual Debug Console",
"EnableAutoScroll": "Enable Auto Scroll",
"ClearConsole": "Clear Console",
"CopyToClipboard": "Copy to clipboard"
"CopyToClipboard": "Copy to clipboard",
"Download": "Download"
},
"inverterchannelinfo": {
"String": "String {num}",

View File

@@ -347,7 +347,8 @@
"VirtualDebugConsole": "Console de débogage",
"EnableAutoScroll": "Activer le défilement automatique",
"ClearConsole": "Vider la console",
"CopyToClipboard": "Copier dans le presse-papiers"
"CopyToClipboard": "Copier dans le presse-papiers",
"Download": "Download"
},
"inverterchannelinfo": {
"String": "Ligne {num}",

View File

@@ -9,7 +9,7 @@
type="checkbox"
role="switch"
id="autoScroll"
v-model="isAutoScroll"
v-model="autoScroll"
/>
<label class="form-check-label" for="autoScroll">
{{ $t('console.EnableAutoScroll') }}
@@ -24,10 +24,19 @@
<button type="button" class="btn btn-secondary" :onClick="copyConsole">
{{ $t('console.CopyToClipboard') }}
</button>
<button type="button" class="btn btn-secondary" :onClick="exportConsole">
{{ $t('console.Download') }}
</button>
</div>
</div>
</div>
<textarea id="console" class="form-control" rows="24" v-model="consoleBuffer" readonly></textarea>
<div id="log" ref="logRef" class="log-output" @scroll="handleScroll">
<div v-for="(line, index) in lines" :key="index" class="log-line" :class="getLineClass(line.text)">
<span class="timestamp">[{{ formatTimestamp(line.timestamp) }}]</span>
{{ line.text }}
</div>
</div>
</CardElement>
</BasePage>
</template>
@@ -38,6 +47,11 @@ import CardElement from '@/components/CardElement.vue';
import { authUrl } from '@/utils/authentication';
import { defineComponent } from 'vue';
interface LogLine {
text: string;
timestamp: Date;
}
export default defineComponent({
components: {
BasePage,
@@ -48,9 +62,16 @@ export default defineComponent({
socket: {} as WebSocket,
heartInterval: 0,
dataLoading: true,
consoleBuffer: '',
isAutoScroll: true,
endWithNewline: false,
autoScroll: true,
lines: [] as LogLine[],
buffer: '',
levelMap: {
'E (': 'error',
'W (': 'warning',
'I (': 'info',
'D (': 'debug',
'V (': 'verbose',
} as Record<string, string>,
};
},
created() {
@@ -60,16 +81,6 @@ export default defineComponent({
unmounted() {
this.closeSocket();
},
watch: {
consoleBuffer() {
if (this.isAutoScroll) {
const textarea = this.$el.querySelector('#console');
setTimeout(() => {
textarea.scrollTop = textarea.scrollHeight;
}, 0);
}
},
},
methods: {
initSocket() {
console.log('Starting connection to WebSocket Server');
@@ -84,15 +95,25 @@ export default defineComponent({
this.socket.onmessage = (event) => {
console.log(event);
let outstr = String(event.data);
let removedNewline = false;
if (outstr.endsWith('\n')) {
outstr = outstr.substring(0, outstr.length - 1);
removedNewline = true;
}
this.consoleBuffer +=
(this.endWithNewline ? this.getOutDate() : '') + outstr.replaceAll('\n', '\n' + this.getOutDate());
this.endWithNewline = removedNewline;
this.buffer += event.data;
const splitLines = this.buffer.split('\n');
this.buffer = splitLines.pop() || ''; // Save the incomplete line
splitLines.forEach((line) => {
this.lines.push({
text: line,
timestamp: new Date(), // assign time of message arrival
});
this.$nextTick(() => {
if (this.autoScroll) {
const el = this.$refs.logRef as HTMLDivElement;
if (el) {
el.scrollTop = el.scrollHeight;
}
}
});
});
this.heartCheck(); // Reset heartbeat detection
};
@@ -106,6 +127,25 @@ export default defineComponent({
this.closeSocket();
};
},
getLineClass(line: string): string {
for (const tag in this.levelMap) {
if (line.includes(tag)) {
return this.levelMap[tag];
}
}
return 'default';
},
handleScroll() {
const el = this.$refs.logRef as HTMLDivElement;
if (!el) {
return;
}
const threshold = 20; // px from bottom to consider "at bottom"
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - threshold;
this.autoScroll = atBottom;
},
// Send heartbeat packets regularly * 5s Send a heartbeat
heartCheck() {
if (this.heartInterval) {
@@ -133,40 +173,111 @@ export default defineComponent({
clearInterval(this.heartInterval);
}
},
getOutDate(): string {
const u = new Date();
formatTimestamp(date: Date): string {
return (
('0' + u.getHours()).slice(-2) +
('0' + date.getHours()).slice(-2) +
':' +
('0' + u.getMinutes()).slice(-2) +
('0' + date.getMinutes()).slice(-2) +
':' +
('0' + u.getSeconds()).slice(-2) +
('0' + date.getSeconds()).slice(-2) +
'.' +
(u.getMilliseconds() / 1000).toFixed(3).slice(2, 5) +
' > '
(date.getMilliseconds() / 1000).toFixed(3).slice(2, 5)
);
},
clearConsole() {
this.consoleBuffer = '';
this.lines = [];
this.buffer = '';
},
copyConsole() {
const input = document.createElement('textarea');
input.innerHTML = this.consoleBuffer;
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
const content = this.lines
.map((line) => `[${this.formatTimestamp(line.timestamp)}] ${line.text}`)
.join('\n');
navigator.clipboard
.writeText(content)
.then(() => {
console.log('Copied to clipboard!');
})
.catch((err) => {
console.error('Failed to copy:', err);
});
},
exportConsole() {
const content = this.lines
.map((line) => `[${this.formatTimestamp(line.timestamp)}] ${line.text}`)
.join('\n');
const timestamp = this.getFileTimestamp();
const filename = `opendtu-log-${timestamp}.txt`;
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
},
getFileTimestamp(): string {
const now = new Date();
const pad = (n: number) => n.toString().padStart(2, '0');
const year = now.getFullYear();
const month = pad(now.getMonth() + 1);
const day = pad(now.getDate());
const hour = pad(now.getHours());
const min = pad(now.getMinutes());
const sec = pad(now.getSeconds());
return `${year}-${month}-${day}_${hour}-${min}-${sec}`;
},
},
});
</script>
<style>
#console {
background-color: #0c0c0c;
color: #cccccc;
.log-output {
height: 500px;
max-height: 500px;
overflow-y: auto;
padding: 8px;
background: #111;
border: 1px solid #333;
border-radius: 6px;
font-family: courier new;
font-size: 0.875em;
}
.log-line {
margin: 3px 0;
white-space: pre-wrap;
}
.timestamp {
color: #888;
margin-right: 8px;
}
.error {
color: #ff5555;
}
.warning {
color: #ffcc00;
}
.info {
color: #a8ff60;
}
.debug {
color: #57c7ff;
}
.verbose {
color: #bbbbbb;
}
.default {
color: #ddd;
}
</style>