From 02f54ea208efa363d6fa0d5cba843e37cc005053 Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Fri, 15 May 2026 09:37:24 +0000 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=96=B0=E5=A2=9E=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E8=AF=A6=E6=83=85=E5=92=8C=E6=97=A5=E5=BF=97=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E9=A1=B5=E9=9D=A2=EF=BC=8C=E5=8C=85=E5=90=AB=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E7=9A=84=E8=A7=86=E5=9B=BE=E7=BB=84=E4=BB=B6=E5=92=8C?= =?UTF-8?q?=E4=B8=9A=E5=8A=A1=E8=84=9A=E6=9C=AC=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/views/LogDetailView.vue | 363 +++++++++++++++++++++++ web/src/views/LogsView.vue | 263 +++++++++++++++++ web/src/views/scripts/LogsView.js | 470 ++++++++++++++++++++++++++++++ 3 files changed, 1096 insertions(+) create mode 100644 web/src/views/LogDetailView.vue create mode 100644 web/src/views/LogsView.vue create mode 100644 web/src/views/scripts/LogsView.js diff --git a/web/src/views/LogDetailView.vue b/web/src/views/LogDetailView.vue new file mode 100644 index 0000000..888745a --- /dev/null +++ b/web/src/views/LogDetailView.vue @@ -0,0 +1,363 @@ + + + + + diff --git a/web/src/views/LogsView.vue b/web/src/views/LogsView.vue new file mode 100644 index 0000000..75d3812 --- /dev/null +++ b/web/src/views/LogsView.vue @@ -0,0 +1,263 @@ + + + + diff --git a/web/src/views/scripts/LogsView.js b/web/src/views/scripts/LogsView.js new file mode 100644 index 0000000..b281c97 --- /dev/null +++ b/web/src/views/scripts/LogsView.js @@ -0,0 +1,470 @@ +import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import { useRouter } from 'vue-router' + +import LogTrendChart from '../../components/charts/LogTrendChart.vue' +import DonutChart from '../../components/charts/DonutChart.vue' +import { fetchAgentRuns } from '../../services/agentAssets.js' +import { fetchSystemLogEntries } from '../../services/systemLogs.js' +import { useSystemState } from '../../composables/useSystemState.js' +import { useToast } from '../../composables/useToast.js' +import { isManagerUser } from '../../utils/accessControl.js' + +const POLL_INTERVAL_MS = 5000 + +const SOURCE_LABELS = { + schedule: '定时任务', + system_event: '系统事件', + user_message: '用户触发' +} + +function formatDateTime(value) { + if (!value) { + return '未结束' + } + + const date = new Date(value) + if (Number.isNaN(date.getTime())) { + return String(value) + } + + return date.toLocaleString('zh-CN', { hour12: false }) +} + +function resolveStatusLabel(status) { + if (status === 'running') { + return '运行中' + } + if (status === 'succeeded') { + return '已完成' + } + if (status === 'failed') { + return '失败' + } + if (status === 'blocked') { + return '待确认' + } + return status || '未知' +} + +function resolveStatusTone(status) { + if (status === 'running') { + return 'warning' + } + if (status === 'succeeded') { + return 'success' + } + if (status === 'failed') { + return 'danger' + } + if (status === 'blocked') { + return 'muted' + } + return 'muted' +} + +function resolveRunSourceLabel(source) { + return SOURCE_LABELS[source] || source || '未标记' +} + +function resolveRunModuleLabel(run) { + const routeJson = run?.route_json || {} + if (routeJson.job_type === 'llm_wiki_sync') { + return '知识归纳' + } + if (routeJson.selected_agent) { + return String(routeJson.selected_agent) + } + if (routeJson.folder) { + return String(routeJson.folder) + } + return resolveRunSourceLabel(run?.source) +} + +function resolveRunTitle(run) { + const routeJson = run?.route_json || {} + if (routeJson.job_type === 'llm_wiki_sync') { + return `LLM Wiki 归纳 · ${routeJson.folder || '未指定目录'}` + } + return `Hermes 调用 · ${resolveRunModuleLabel(run)}` +} + +function resolveRunLevel(run) { + const progress = run?.route_json?.progress || {} + if (run?.status === 'failed' || run?.error_message) { + return 'ERROR' + } + if (run?.status === 'blocked' || Number(progress.failed_documents || 0) > 0) { + return 'WARN' + } + if (run?.status === 'running') { + return 'INFO' + } + return 'INFO' +} + +function resolveLevelTone(level) { + if (level === 'ERROR') { + return 'danger' + } + if (level === 'WARN') { + return 'warning' + } + if (level === 'INFO') { + return 'info' + } + return 'muted' +} + +function formatSummary(summary) { + const text = String(summary || '').trim() + if (!text) { + return '暂无摘要。' + } + if (text.length <= 64) { + return text + } + return `${text.slice(0, 64)}...` +} + +function resolveSystemLevelTone(level) { + if (level === 'ERROR' || level === 'CRITICAL') { + return 'danger' + } + if (level === 'WARNING' || level === 'WARN') { + return 'warning' + } + if (level === 'INFO') { + return 'info' + } + return 'muted' +} + +function resolveSystemOutcomeTone(outcome) { + if (outcome === '失败') { + return 'danger' + } + if (outcome === '异常' || outcome === '告警') { + return 'warning' + } + if (outcome === '成功') { + return 'success' + } + return 'muted' +} + +function formatHourBucketLabel(date) { + return `${String(date.getHours()).padStart(2, '0')}:00` +} + +function buildTrendSeries(runs) { + const parsedTimes = runs + .map((run) => new Date(run?.started_at)) + .filter((date) => !Number.isNaN(date.getTime())) + const latest = parsedTimes.length ? new Date(Math.max(...parsedTimes.map((date) => date.getTime()))) : new Date() + latest.setMinutes(0, 0, 0) + + const buckets = Array.from({ length: 8 }, (_, index) => { + const date = new Date(latest) + date.setHours(latest.getHours() - (7 - index)) + return { + key: date.toISOString(), + label: formatHourBucketLabel(date), + total: 0, + failed: 0 + } + }) + const bucketMap = new Map(buckets.map((bucket) => [bucket.key, bucket])) + + for (const run of runs) { + const date = new Date(run?.started_at) + if (Number.isNaN(date.getTime())) { + continue + } + date.setMinutes(0, 0, 0) + const bucket = bucketMap.get(date.toISOString()) + if (!bucket) { + continue + } + bucket.total += 1 + if (run.status === 'failed') { + bucket.failed += 1 + } + } + + return { + buckets, + labels: buckets.map((bucket) => bucket.label), + totals: buckets.map((bucket) => bucket.total), + failures: buckets.map((bucket) => bucket.failed) + } +} + +export default { + name: 'LogsView', + components: { + LogTrendChart, + DonutChart + }, + emits: ['summary-change'], + setup(_, { emit }) { + const router = useRouter() + const { currentUser } = useSystemState() + const { toast } = useToast() + + const activeTab = ref('hermes') + const hermesLoading = ref(false) + const systemLogLoading = ref(false) + const hermesRuns = ref([]) + const systemSearchKeyword = ref('') + const systemLevelFilter = ref('') + const systemEventTypeFilter = ref('') + const systemLogEntries = ref([]) + const currentPage = ref(1) + const pageSize = ref(10) + const pageSizes = [10, 20, 50] + const pageSizeOpen = ref(false) + let pollTimer = 0 + + const isAdmin = computed(() => isManagerUser(currentUser.value)) + const filteredHermesRuns = computed(() => hermesRuns.value) + const systemLevelOptions = computed(() => + Array.from(new Set(systemLogEntries.value.map((entry) => entry.level).filter(Boolean))) + ) + const systemEventTypeOptions = computed(() => + Array.from(new Set(systemLogEntries.value.map((entry) => entry.event_type).filter(Boolean))) + ) + const filteredSystemLogEntries = computed(() => { + const keyword = systemSearchKeyword.value.trim().toLowerCase() + return systemLogEntries.value.filter((entry) => { + if (systemLevelFilter.value && entry.level !== systemLevelFilter.value) { + return false + } + if (systemEventTypeFilter.value && entry.event_type !== systemEventTypeFilter.value) { + return false + } + if (!keyword) { + return true + } + + const haystack = [ + entry.summary, + entry.message, + entry.logger, + entry.request_id, + entry.path, + entry.event_type, + entry.outcome, + entry.source_file + ] + .filter(Boolean) + .join(' ') + .toLowerCase() + return haystack.includes(keyword) + }) + }) + const hermesRunCount = computed(() => hermesRuns.value.length) + const runningRunCount = computed(() => hermesRuns.value.filter((run) => run.status === 'running').length) + const completedRunCount = computed(() => hermesRuns.value.filter((run) => run.status === 'succeeded').length) + const failedRunCount = computed(() => hermesRuns.value.filter((run) => run.status === 'failed').length) + const trendSeries = computed(() => buildTrendSeries(filteredHermesRuns.value)) + const levelDistribution = computed(() => { + const items = [ + { level: 'INFO', count: 0, color: '#3b82f6' }, + { level: 'WARN', count: 0, color: '#f59e0b' }, + { level: 'ERROR', count: 0, color: '#ef4444' } + ] + + for (const run of filteredHermesRuns.value) { + const item = items.find((candidate) => candidate.level === resolveRunLevel(run)) + if (item) { + item.count += 1 + } + } + + const total = items.reduce((sum, item) => sum + item.count, 0) + return { + items: items.map((item) => ({ + name: item.level, + value: item.count, + color: item.color, + display: total ? `${item.count} (${Math.round((item.count / total) * 100)}%)` : '0' + })), + total + } + }) + const activeRows = computed(() => + activeTab.value === 'hermes' ? filteredHermesRuns.value : filteredSystemLogEntries.value + ) + const totalCount = computed(() => activeRows.value.length) + const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value))) + const visiblePageItems = computed(() => { + if (totalPages.value <= 6) { + return Array.from({ length: totalPages.value }, (_, index) => index + 1) + } + return [1, 2, 3, 4, 5, 'ellipsis', totalPages.value] + }) + const visibleHermesRuns = computed(() => { + const start = (currentPage.value - 1) * pageSize.value + return filteredHermesRuns.value.slice(start, start + pageSize.value) + }) + const visibleSystemLogEntries = computed(() => { + const start = (currentPage.value - 1) * pageSize.value + return filteredSystemLogEntries.value.slice(start, start + pageSize.value) + }) + + function changePageSize(size) { + pageSize.value = size + pageSizeOpen.value = false + currentPage.value = 1 + } + + async function loadHermesRuns() { + if (!isAdmin.value) { + return + } + + hermesLoading.value = true + try { + const payload = await fetchAgentRuns({ agent: 'hermes', limit: 100 }) + hermesRuns.value = Array.isArray(payload) ? payload : [] + } catch (error) { + toast(error.message || 'Hermes 日志加载失败。') + } finally { + hermesLoading.value = false + } + } + + function selectRun(runId) { + router.push({ + name: 'app-log-detail', + params: { logKind: 'hermes', logId: runId } + }) + } + + async function loadSystemLogs() { + if (!isAdmin.value) { + return + } + + systemLogLoading.value = true + try { + const payload = await fetchSystemLogEntries(300) + systemLogEntries.value = Array.isArray(payload) ? payload : [] + } catch (error) { + toast(error.message || '系统日志加载失败。') + } finally { + systemLogLoading.value = false + } + } + + function selectSystemLog(entryId) { + router.push({ + name: 'app-log-detail', + params: { logKind: 'system', logId: entryId } + }) + } + + function startPolling() { + stopPolling() + pollTimer = window.setInterval(() => { + loadHermesRuns() + loadSystemLogs() + }, POLL_INTERVAL_MS) + } + + function stopPolling() { + if (pollTimer) { + window.clearInterval(pollTimer) + pollTimer = 0 + } + } + + watch( + [ + activeTab, + systemSearchKeyword, + systemLevelFilter, + systemEventTypeFilter + ], + () => { + currentPage.value = 1 + } + ) + + watch(totalPages, (value) => { + if (currentPage.value > value) { + currentPage.value = value + } + }) + + watch( + () => [ + hermesRunCount.value, + runningRunCount.value, + completedRunCount.value, + failedRunCount.value + ], + ([total, running, completed, failed]) => { + emit('summary-change', { total, running, completed, failed }) + }, + { immediate: true } + ) + + onMounted(async () => { + await loadHermesRuns() + await loadSystemLogs() + startPolling() + }) + + onBeforeUnmount(() => { + stopPolling() + }) + + return { + activeTab, + changePageSize, + completedRunCount, + failedRunCount, + filteredHermesRuns, + filteredSystemLogEntries, + formatDateTime, + formatSummary, + hermesLoading, + hermesRunCount, + hermesRuns, + isAdmin, + levelDistribution, + loadHermesRuns, + loadSystemLogs, + currentPage, + pageSize, + pageSizeOpen, + pageSizes, + resolveLevelTone, + resolveRunLevel, + resolveRunModuleLabel, + resolveRunSourceLabel, + resolveRunTitle, + resolveStatusLabel, + resolveStatusTone, + resolveSystemLevelTone, + resolveSystemOutcomeTone, + runningRunCount, + selectRun, + selectSystemLog, + systemEventTypeFilter, + systemEventTypeOptions, + systemLevelFilter, + systemLevelOptions, + systemLogEntries, + systemLogLoading, + systemSearchKeyword, + totalCount, + totalPages, + trendSeries, + visiblePageItems, + visibleHermesRuns, + visibleSystemLogEntries + } + } +}