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:
一张白纸
2026-10-02 22:32:23 +08:00
committed by GitHub
parent 9f65a019e5
commit ab7de72de3
6 changed files with 138 additions and 8 deletions
@@ -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 库",
+1
View File
@@ -55,6 +55,7 @@ async function pipInstall() {
<ConsoleDisplayer
class="console-display"
workspace-mode
show-search
:auto-scroll="autoScrollEnabled"
:hide-user-chat="hideUserChatEnabled"
>