mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-10-16 15:06:04 +08:00
feat(dashboard): add log search and keyword highlighting to console (#10327)
* feat(dashboard): add log search and keyword highlighting to console - Add a debounced search field to ConsoleDisplayer, gated behind a new showSearch prop so it stays opt-in for other callers. - Filter visible log lines by keyword, case-insensitively and ignoring ANSI color codes, while keeping the existing level/category filters. - Highlight matched keywords in the prefix, level and message spans. - Enable the search box on the Console page via show-search. - Add features/console.search translations for en-US, ja-JP, ru-RU and zh-CN. * fix(dashboard): align log search highlighting with ANSI-stripped text Strip ANSI escape sequences in appendHighlightedText before locating keyword matches, using the same stripped text as matchesKeyword. This keeps highlight offsets aligned so color-coded log lines highlight the actual keyword instead of wrapping the wrong characters or including control codes.
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
<script setup>
|
||||
import { useCommonStore } from "@/stores/common";
|
||||
import { logApi } from "@/api/v1";
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
import { normalizeTextInput } from "@/utils/inputValue";
|
||||
import { EventSourcePolyfill } from "event-source-polyfill";
|
||||
|
||||
const { tm } = useModuleI18n("features/console");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -10,8 +14,9 @@ import { EventSourcePolyfill } from "event-source-polyfill";
|
||||
class="console-displayer-wrapper"
|
||||
:class="{ 'console-displayer-wrapper--workspace': workspaceMode }"
|
||||
>
|
||||
<div class="filter-controls mb-2" v-if="showLevelBtns">
|
||||
<div class="filter-controls mb-2" v-if="showLevelBtns || showSearch">
|
||||
<v-chip-group
|
||||
v-if="showLevelBtns"
|
||||
v-model="selectedLevels"
|
||||
class="log-level-filters"
|
||||
column
|
||||
@@ -32,6 +37,21 @@ import { EventSourcePolyfill } from "event-source-polyfill";
|
||||
{{ level }}
|
||||
</v-chip>
|
||||
</v-chip-group>
|
||||
<v-text-field
|
||||
v-if="showSearch"
|
||||
:model-value="searchInput"
|
||||
@update:model-value="searchInput = normalizeTextInput($event)"
|
||||
class="log-search-field"
|
||||
density="compact"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
hide-details
|
||||
single-line
|
||||
clearable
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
:aria-label="tm('search.label')"
|
||||
:placeholder="tm('search.placeholder')"
|
||||
></v-text-field>
|
||||
<v-spacer></v-spacer>
|
||||
<slot name="header-actions"></slot>
|
||||
<v-btn
|
||||
@@ -73,6 +93,10 @@ export default {
|
||||
CRITICAL: "purple",
|
||||
},
|
||||
localLogCache: [],
|
||||
searchInput: "",
|
||||
// Trimmed keyword; an empty string disables filtering.
|
||||
searchKeyword: "",
|
||||
searchTimer: null,
|
||||
eventSource: null,
|
||||
retryTimer: null,
|
||||
retryAttempts: 0,
|
||||
@@ -99,6 +123,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showSearch: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
autoScroll: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
@@ -118,6 +146,22 @@ export default {
|
||||
hideUserChat() {
|
||||
this.refreshDisplay();
|
||||
},
|
||||
searchInput: {
|
||||
handler(value) {
|
||||
if (this.searchTimer) {
|
||||
clearTimeout(this.searchTimer);
|
||||
}
|
||||
this.searchTimer = setTimeout(() => {
|
||||
this.searchTimer = null;
|
||||
const keyword = (value || "").trim();
|
||||
if (keyword === this.searchKeyword) {
|
||||
return;
|
||||
}
|
||||
this.searchKeyword = keyword;
|
||||
this.refreshDisplay();
|
||||
}, 250);
|
||||
},
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
await this.fetchLogHistory();
|
||||
@@ -137,6 +181,10 @@ export default {
|
||||
clearTimeout(this.retryTimer);
|
||||
this.retryTimer = null;
|
||||
}
|
||||
if (this.searchTimer) {
|
||||
clearTimeout(this.searchTimer);
|
||||
this.searchTimer = null;
|
||||
}
|
||||
this.retryAttempts = 0;
|
||||
},
|
||||
methods: {
|
||||
@@ -246,7 +294,8 @@ export default {
|
||||
|
||||
if (
|
||||
this.isLevelSelected(log.level) &&
|
||||
!this.isHiddenByCategory(log)
|
||||
!this.isHiddenByCategory(log) &&
|
||||
this.matchesKeyword(log)
|
||||
) {
|
||||
if (fragment) {
|
||||
fragment.appendChild(this.buildLogElement(log.data));
|
||||
@@ -301,6 +350,52 @@ export default {
|
||||
return this.hideUserChat && log && log.category === "user_chat";
|
||||
},
|
||||
|
||||
matchesKeyword(log) {
|
||||
if (!this.searchKeyword) {
|
||||
return true;
|
||||
}
|
||||
const text = (log.data || "")
|
||||
.replace(/\u001b\[[0-9;]*m/g, "")
|
||||
.toLowerCase();
|
||||
return text.includes(this.searchKeyword.toLowerCase());
|
||||
},
|
||||
|
||||
// Appends `text` to `element`, wrapping keyword matches in a highlight span.
|
||||
appendHighlightedText(element, text) {
|
||||
const keyword = this.searchKeyword;
|
||||
if (!keyword || !text) {
|
||||
element.textContent = text || "";
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip ANSI escape sequences first so highlight offsets line up with
|
||||
// the same stripped text used by `matchesKeyword`, and so no control
|
||||
// codes are rendered.
|
||||
const cleanText = text.replace(/\u001b\[[0-9;]*m/g, "");
|
||||
const lowerText = cleanText.toLowerCase();
|
||||
const lowerKeyword = keyword.toLowerCase();
|
||||
let cursor = 0;
|
||||
let index = lowerText.indexOf(lowerKeyword);
|
||||
|
||||
while (index !== -1) {
|
||||
if (index > cursor) {
|
||||
element.appendChild(
|
||||
document.createTextNode(cleanText.slice(cursor, index)),
|
||||
);
|
||||
}
|
||||
const highlight = document.createElement("span");
|
||||
highlight.className = "console-log-highlight";
|
||||
highlight.textContent = cleanText.slice(index, index + keyword.length);
|
||||
element.appendChild(highlight);
|
||||
cursor = index + keyword.length;
|
||||
index = lowerText.indexOf(lowerKeyword, cursor);
|
||||
}
|
||||
|
||||
if (cursor < cleanText.length) {
|
||||
element.appendChild(document.createTextNode(cleanText.slice(cursor)));
|
||||
}
|
||||
},
|
||||
|
||||
refreshDisplay() {
|
||||
const termElement = document.getElementById("term");
|
||||
if (!termElement) return;
|
||||
@@ -312,7 +407,8 @@ export default {
|
||||
this.localLogCache.forEach((logItem) => {
|
||||
if (
|
||||
this.isLevelSelected(logItem.level) &&
|
||||
!this.isHiddenByCategory(logItem)
|
||||
!this.isHiddenByCategory(logItem) &&
|
||||
this.matchesKeyword(logItem)
|
||||
) {
|
||||
fragment.appendChild(this.buildLogElement(logItem.data));
|
||||
}
|
||||
@@ -345,7 +441,7 @@ export default {
|
||||
/\[(DEBG|INFO|WARN|ERRO|CRIT|DEBUG|WARNING|ERROR|CRITICAL)\]/,
|
||||
);
|
||||
if (!levelMatch) {
|
||||
element.textContent = `${log}`;
|
||||
this.appendHighlightedText(element, log);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -356,15 +452,15 @@ export default {
|
||||
|
||||
const prefixSpan = document.createElement("span");
|
||||
prefixSpan.className = "console-log-prefix";
|
||||
prefixSpan.textContent = prefix;
|
||||
this.appendHighlightedText(prefixSpan, prefix);
|
||||
|
||||
const levelSpan = document.createElement("span");
|
||||
levelSpan.className = "console-log-level";
|
||||
levelSpan.textContent = levelMatch[0];
|
||||
this.appendHighlightedText(levelSpan, levelMatch[0]);
|
||||
|
||||
const messageSpan = document.createElement("span");
|
||||
messageSpan.className = "console-log-message";
|
||||
messageSpan.textContent = message;
|
||||
this.appendHighlightedText(messageSpan, message);
|
||||
|
||||
element.classList.add("console-log-line--structured");
|
||||
element.appendChild(prefixSpan);
|
||||
@@ -426,6 +522,12 @@ export default {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.log-search-field {
|
||||
flex: 0 1 260px;
|
||||
max-width: 260px;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.console-term {
|
||||
background-color: #1e1e1e;
|
||||
border-radius: 8px;
|
||||
@@ -520,6 +622,11 @@ export default {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
:deep(.console-log-highlight) {
|
||||
background: rgba(255, 213, 79, 0.35);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.console-displayer-wrapper--workspace {
|
||||
border-radius: 14px;
|
||||
@@ -541,9 +648,15 @@ export default {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.console-displayer-wrapper--workspace .log-search-field {
|
||||
flex: 1 1 100%;
|
||||
max-width: none;
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.console-displayer-wrapper--workspace :deep(.console-header-actions) {
|
||||
flex: 1 1 100%;
|
||||
order: 3;
|
||||
order: 4;
|
||||
}
|
||||
|
||||
.console-displayer-wrapper--workspace .fullscreen-btn {
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
"enabled": "User chat hidden",
|
||||
"disabled": "Hide user chat"
|
||||
},
|
||||
"search": {
|
||||
"label": "Search logs",
|
||||
"placeholder": "Filter logs by keyword"
|
||||
},
|
||||
"pipInstall": {
|
||||
"button": "Install pip Package",
|
||||
"dialogTitle": "Install Pip Package",
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
"enabled": "ユーザーの会話を除外しました",
|
||||
"disabled": "ユーザーの会話を除外"
|
||||
},
|
||||
"search": {
|
||||
"label": "ログを検索",
|
||||
"placeholder": "キーワードでログを絞り込む"
|
||||
},
|
||||
"pipInstall": {
|
||||
"button": "pip パッケージをインストール",
|
||||
"dialogTitle": "pip パッケージをインストール",
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
"enabled": "Чаты пользователей скрыты",
|
||||
"disabled": "Скрыть чаты пользователей"
|
||||
},
|
||||
"search": {
|
||||
"label": "Поиск по логам",
|
||||
"placeholder": "Фильтровать логи по ключевому слову"
|
||||
},
|
||||
"pipInstall": {
|
||||
"button": "Установить pip-пакет",
|
||||
"dialogTitle": "Установка Pip-пакета",
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
"enabled": "已排除用户对话",
|
||||
"disabled": "排除用户对话"
|
||||
},
|
||||
"search": {
|
||||
"label": "搜索日志",
|
||||
"placeholder": "按关键词过滤日志"
|
||||
},
|
||||
"pipInstall": {
|
||||
"button": "安装 pip 库",
|
||||
"dialogTitle": "安装 Pip 库",
|
||||
|
||||
@@ -55,6 +55,7 @@ async function pipInstall() {
|
||||
<ConsoleDisplayer
|
||||
class="console-display"
|
||||
workspace-mode
|
||||
show-search
|
||||
:auto-scroll="autoScrollEnabled"
|
||||
:hide-user-chat="hideUserChatEnabled"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user