#feature: add AI log analysis & some bugfix

This commit is contained in:
2026-01-14 13:58:50 +08:00
parent e479ed02ea
commit ae6c169f5f
33 changed files with 3898 additions and 164 deletions

View File

@@ -46,6 +46,12 @@
ref="messageDispatch"
/>
<!-- SLS 日志分析页面 -->
<log-analysis
v-else-if="currentPage === 'log-analysis'"
:is-admin="isAdmin"
/>
<!-- 系统设置页面 -->
<system-settings v-else-if="currentPage === 'settings'" :is-admin="isAdmin" />
@@ -66,6 +72,7 @@ import JiraWorklog from '../jira/JiraWorklog.vue';
import MessageSync from '../message-sync/MessageSync.vue';
import EventConsumerSync from '../message-sync/EventConsumerSync.vue';
import MessageDispatch from '../message-sync/MessageDispatch.vue';
import LogAnalysis from '../log-analysis/LogAnalysis.vue';
import SystemSettings from './SystemSettings.vue';
import OperationLogs from './OperationLogs.vue';
import IpUserMappings from './IpUserMappings.vue';
@@ -81,6 +88,7 @@ export default {
MessageSync,
EventConsumerSync,
MessageDispatch,
LogAnalysis,
SystemSettings,
OperationLogs,
IpUserMappings
@@ -129,6 +137,7 @@ export default {
'message-sync': '消息同步',
'event-consumer-sync': '事件消费者同步对比',
'message-dispatch': '消息分发异常查询',
'log-analysis': 'SLS 日志分析',
'settings': '系统设置',
'logs': '操作日志',
'ip-mappings': 'IP 用户映射'
@@ -155,6 +164,8 @@ export default {
page = 'event-consumer-sync';
} else if (path === '/message-dispatch') {
page = 'message-dispatch';
} else if (path === '/log-analysis') {
page = 'log-analysis';
} else if (path === '/settings') {
page = 'settings';
} else if (path === '/logs') {

View File

@@ -190,6 +190,30 @@
消息分发异常
</a>
<a
href="#"
@click.prevent="setActiveMenu('log-analysis')"
:class="[
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
activeMenu === 'log-analysis'
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
]"
>
<svg
:class="[
'mr-3 h-5 w-5 transition-colors duration-200',
activeMenu === 'log-analysis' ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
SLS 日志分析
</a>
<a
href="#"
@click.prevent="setActiveMenu('settings')"
@@ -350,6 +374,8 @@ export default {
menu = 'event-consumer-sync';
} else if (path === '/message-dispatch') {
menu = 'message-dispatch';
} else if (path === '/log-analysis') {
menu = 'log-analysis';
} else if (path === '/settings') {
menu = 'settings';
} else if (path === '/logs') {

View File

@@ -313,6 +313,12 @@ export default {
bValue = b.parent_task ? b.parent_task.key : '';
}
// 特殊处理日期时间排序:结合 date 和 time 字段
if (this.sortField === 'date') {
aValue = `${a.date || ''} ${a.time || '00:00'}`;
bValue = `${b.date || ''} ${b.time || '00:00'}`;
}
// 处理空值
if (!aValue && !bValue) return 0;
if (!aValue) return this.sortDirection === 'asc' ? -1 : 1;

View File

@@ -0,0 +1,955 @@
<template>
<div class="p-6 h-full overflow-auto">
<!-- 页面标题 -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">SLS 日志分析</h1>
<p class="text-gray-600 mt-1">查询和分析阿里云 SLS 日志支持 AI 智能分析</p>
</div>
<!-- 状态提示 -->
<div v-if="!config.sls_configured" class="mb-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div class="flex items-center text-yellow-700">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
<span>SLS 服务未配置请在 .env 中设置 SLS_* 配置项</span>
</div>
</div>
<div v-if="!config.ai_configured" class="mb-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div class="flex items-center text-yellow-700">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
<span>AI 服务未配置请在下方配置 AI 提供商</span>
</div>
</div>
<!-- 查询区域 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
<h2 class="text-lg font-semibold text-gray-700 mb-4">查询条件</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
<!-- 开始时间 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">开始时间</label>
<input
v-model="queryParams.from"
type="datetime-local"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
</div>
<!-- 结束时间 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">结束时间</label>
<input
v-model="queryParams.to"
type="datetime-local"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
</div>
<!-- 分析模式 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">分析模式</label>
<select
v-model="queryParams.mode"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="logs">仅日志分析</option>
<option value="logs+code">日志 + 代码分析</option>
</select>
</div>
<!-- 快捷时间 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">快捷选择</label>
<div class="flex gap-2">
<button @click="setQuickTime('1h')" class="px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 rounded-md">1小时</button>
<button @click="setQuickTime('6h')" class="px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 rounded-md">6小时</button>
<button @click="setQuickTime('24h')" class="px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 rounded-md">24小时</button>
</div>
</div>
</div>
<!-- 查询语句 -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">
SLS 查询语句
<span class="text-xs text-gray-500 font-normal ml-2">
(AI 分析时留空默认查询 ERROR WARNING 日志)
</span>
</label>
<input
v-model="queryParams.query"
type="text"
placeholder='留空默认: ERROR or WARNING或自定义如: * | where level = "ERROR"'
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<div class="mt-1 text-xs text-gray-500">
<details class="cursor-pointer">
<summary class="text-blue-600 hover:text-blue-800">查询语法说明</summary>
<div class="mt-2 p-3 bg-gray-50 rounded border border-gray-200 space-y-1">
<p><strong>全文搜索</strong></p>
<p class="ml-2"> <code class="bg-white px-1">*</code> - 查询所有日志</p>
<p class="ml-2"> <code class="bg-white px-1">ERROR</code> - 搜索包含 ERROR 的日志</p>
<p class="ml-2"> <code class="bg-white px-1">ERROR and timeout</code> - 同时包含两个关键词</p>
<p class="ml-2"> <code class="bg-white px-1">ERROR or WARNING</code> - 包含任一关键词</p>
<p class="ml-2"> <code class="bg-white px-1">ERROR not success</code> - 包含 ERROR 但不包含 success</p>
<p class="mt-2"><strong>SQL 分析语法需要字段索引</strong></p>
<p class="ml-2"> <code class="bg-white px-1">* | where level = "ERROR"</code> - 筛选 level 字段</p>
<p class="ml-2"> <code class="bg-white px-1">* | where level in ("ERROR", "WARNING")</code> - 多个值</p>
<p class="ml-2"> <code class="bg-white px-1">* | where status >= 500</code> - 数值比较</p>
</div>
</details>
</div>
</div>
<!-- Logstore 选择 -->
<div v-if="availableLogstores.length > 0" class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">
选择 Logstore ({{ queryParams.logstores.length }}/{{ availableLogstores.length }})
</label>
<div class="flex flex-wrap gap-2">
<label
v-for="logstore in availableLogstores"
:key="logstore"
class="inline-flex items-center px-3 py-2 border rounded-md cursor-pointer transition-colors"
:class="queryParams.logstores.includes(logstore)
? 'bg-blue-50 border-blue-500 text-blue-700'
: 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'"
>
<input
type="checkbox"
:value="logstore"
v-model="queryParams.logstores"
class="mr-2"
>
<span class="text-sm font-medium">{{ logstore }}</span>
</label>
</div>
<div class="mt-2 flex gap-2">
<button
@click="queryParams.logstores = [...availableLogstores]"
class="text-sm text-blue-600 hover:text-blue-800"
>
全选
</button>
<button
@click="queryParams.logstores = []"
class="text-sm text-blue-600 hover:text-blue-800"
>
清空
</button>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex gap-3">
<button
@click="handleQuery"
:disabled="loading || !config.sls_configured"
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
>
<svg v-if="loading" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
查询日志
</button>
<button
@click="handleAnalyze"
:disabled="analyzing || !config.sls_configured || !config.ai_configured"
class="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
>
<svg v-if="analyzing" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
AI 分析
</button>
</div>
</div>
<!-- Tab 切换 -->
<div class="mb-4 border-b border-gray-200">
<nav class="flex space-x-8">
<button
@click="activeTab = 'results'"
:class="[
'py-2 px-1 border-b-2 font-medium text-sm',
activeTab === 'results'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
]"
>
查询结果
</button>
<button
@click="activeTab = 'analysis'"
:class="[
'py-2 px-1 border-b-2 font-medium text-sm',
activeTab === 'analysis'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
]"
>
AI 分析结果
</button>
<button
@click="activeTab = 'history'"
:class="[
'py-2 px-1 border-b-2 font-medium text-sm',
activeTab === 'history'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
]"
>
历史报告
</button>
<button
@click="activeTab = 'config'"
:class="[
'py-2 px-1 border-b-2 font-medium text-sm',
activeTab === 'config'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
]"
>
AI 配置
</button>
</nav>
</div>
<!-- 错误提示 -->
<div v-if="error" class="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
{{ error }}
</div>
<!-- 查询结果 Tab -->
<div v-if="activeTab === 'results'" class="bg-white rounded-lg shadow-sm border border-gray-200">
<div v-if="!queryResult" class="p-8 text-center text-gray-500">
请输入查询条件并点击"查询日志"
</div>
<div v-else>
<!-- 统计信息 -->
<div class="p-4 border-b border-gray-200 bg-gray-50">
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<div class="flex gap-6">
<span class="text-sm text-gray-600">总日志数: <strong>{{ queryResult.total }}</strong></span>
<span v-for="(count, level) in queryResult.statistics?.by_level" :key="level" class="text-sm">
<span :class="getLevelClass(level)">{{ level }}: {{ count }}</span>
</span>
</div>
</div>
<!-- Logstore 分组统计 -->
<div v-if="queryResult.statistics?.by_logstore" class="flex flex-wrap gap-2">
<span class="text-sm text-gray-600 font-medium"> Logstore:</span>
<span
v-for="(data, logstore) in queryResult.statistics.by_logstore"
:key="logstore"
class="text-sm px-2 py-1 rounded"
:class="data.success ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
>
{{ logstore }}: {{ data.count }}
<span v-if="!data.success" class="text-xs">(失败)</span>
</span>
</div>
</div>
</div>
<!-- 日志列表 -->
<div class="overflow-x-auto max-h-96">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50 sticky top-0">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">时间</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">级别</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">应用</th>
<th v-if="queryResult.statistics?.by_logstore" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Logstore</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">消息</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="(log, index) in queryResult.logs" :key="index" class="hover:bg-gray-50">
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500">{{ log.time }}</td>
<td class="px-4 py-2 whitespace-nowrap">
<span :class="getLevelBadgeClass(log.level)" class="px-2 py-1 rounded text-xs font-medium">
{{ log.level }}
</span>
</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-600">{{ log.app_name }}</td>
<td v-if="queryResult.statistics?.by_logstore" class="px-4 py-2 whitespace-nowrap text-sm text-gray-600">
<span class="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs">{{ log._logstore || '-' }}</span>
</td>
<td class="px-4 py-2 text-sm text-gray-600 max-w-md truncate" :title="log.message">{{ log.message }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- AI 分析结果 Tab -->
<div v-if="activeTab === 'analysis'" class="bg-white rounded-lg shadow-sm border border-gray-200">
<div v-if="!analysisResult" class="p-8 text-center text-gray-500">
请在历史报告中选择一个已完成的分析报告查看结果
</div>
<div v-else class="p-4">
<!-- 状态提示 -->
<div v-if="analysisResult.status === 'pending'" class="mb-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div class="flex items-center text-yellow-700">
<svg class="animate-spin h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>分析任务正在处理中请稍候...</span>
<button @click="refreshReport(analysisResult.id)" class="ml-4 text-blue-600 hover:text-blue-800 text-sm">
刷新状态
</button>
</div>
</div>
<div v-else-if="analysisResult.status === 'failed'" class="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<div class="text-red-700">
<strong>分析失败:</strong> {{ analysisResult.error_message }}
</div>
</div>
<!-- 元数据 -->
<div class="mb-4 p-3 bg-gray-50 rounded-lg text-sm text-gray-600">
<span>分析时间: {{ analysisResult.metadata?.analyzed_at || analysisResult.created_at }}</span>
<span class="ml-4">总日志: {{ analysisResult.metadata?.total_logs ?? analysisResult.total_logs ?? 0 }}</span>
<span v-if="analysisResult.metadata?.execution_time_ms" class="ml-4">耗时: {{ analysisResult.metadata.execution_time_ms }}ms</span>
<span class="ml-4">
状态:
<span :class="getStatusClass(analysisResult.status)" class="px-2 py-0.5 rounded text-xs font-medium">
{{ getStatusText(analysisResult.status) }}
</span>
</span>
</div>
<!-- 按应用展示结果 -->
<div v-if="analysisResult.status === 'completed' && analysisResult.results">
<div v-for="(result, appName) in analysisResult.results" :key="appName" class="mb-6 border border-gray-200 rounded-lg">
<div class="p-4 bg-gray-50 border-b border-gray-200 flex items-center justify-between">
<h3 class="font-semibold text-gray-800">{{ appName }}</h3>
<span :class="getImpactClass(result.impact)" class="px-3 py-1 rounded-full text-sm font-medium">
{{ result.impact || 'N/A' }}
</span>
</div>
<div class="p-4">
<div v-if="result.error" class="text-red-600">
分析失败: {{ result.error }}
</div>
<div v-else>
<p class="text-gray-700 mb-4">{{ result.summary }}</p>
<!-- 异常列表 -->
<div v-if="result.core_anomalies?.length" class="space-y-3">
<h4 class="font-medium text-gray-700">异常列表 ({{ result.core_anomalies.length }})</h4>
<div v-for="(anomaly, idx) in result.core_anomalies" :key="idx"
class="p-3 bg-gray-50 rounded-lg border-l-4"
:class="getAnomalyBorderClass(anomaly.type)">
<div class="flex items-center gap-2 mb-2">
<span class="px-2 py-0.5 bg-gray-200 rounded text-xs">{{ anomaly.type }}</span>
<span class="px-2 py-0.5 bg-blue-100 text-blue-700 rounded text-xs">{{ anomaly.classification }}</span>
<span class="text-sm text-gray-500">x{{ anomaly.count }}</span>
</div>
<p class="text-sm text-gray-700 mb-1"><strong>可能原因:</strong> {{ anomaly.possible_cause }}</p>
<p class="text-sm text-gray-600"><strong>建议:</strong> {{ anomaly.suggestion }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else-if="analysisResult.status === 'completed' && (!analysisResult.results || Object.keys(analysisResult.results).length === 0)" class="text-center text-gray-500 py-8">
未找到匹配的日志或分析结果为空
</div>
</div>
</div>
<!-- 历史报告 Tab -->
<div v-if="activeTab === 'history'" class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="p-4 border-b border-gray-200 flex items-center justify-between">
<h3 class="font-semibold text-gray-700">历史报告</h3>
<button @click="loadHistory" :disabled="historyLoading" class="text-sm text-blue-600 hover:text-blue-800 flex items-center">
<svg v-if="historyLoading" class="animate-spin h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
刷新
</button>
</div>
<div v-if="historyLoading && !historyReports.length" class="p-8 text-center text-gray-500">
加载中...
</div>
<div v-else-if="!historyReports.length" class="p-8 text-center text-gray-500">
暂无历史报告
</div>
<div v-else class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">时间范围</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">模式</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">日志数</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">状态</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">创建时间</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">操作</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="report in historyReports" :key="report.id" class="hover:bg-gray-50">
<td class="px-4 py-2 text-sm text-gray-500">#{{ report.id }}</td>
<td class="px-4 py-2 text-sm text-gray-600">{{ report.from_time }} ~ {{ report.to_time }}</td>
<td class="px-4 py-2 text-sm text-gray-600">{{ report.mode }}</td>
<td class="px-4 py-2 text-sm text-gray-600">{{ report.total_logs }}</td>
<td class="px-4 py-2">
<span :class="getStatusClass(report.status)" class="px-2 py-1 rounded text-xs font-medium">
{{ getStatusText(report.status) }}
</span>
</td>
<td class="px-4 py-2 text-sm text-gray-500">{{ report.created_at }}</td>
<td class="px-4 py-2">
<button @click="viewReport(report.id)" class="text-blue-600 hover:text-blue-800 text-sm">
查看详情
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- AI 配置 Tab -->
<div v-if="activeTab === 'config'" class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-700 mb-4">AI 提供商配置</h3>
<!-- 当前激活的提供商 -->
<div v-if="config.active_ai_provider" class="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg">
<span class="text-green-700">当前使用: {{ config.active_ai_provider.name || config.active_ai_provider.key }}</span>
</div>
<!-- 提供商列表 -->
<div class="space-y-4 mb-6">
<div v-for="(provider, key) in config.ai_providers" :key="key"
class="p-4 border border-gray-200 rounded-lg"
:class="{'border-green-500 bg-green-50': config.active_ai_provider?.key === key}">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<strong>{{ provider.name || key }}</strong>
<span class="text-sm text-gray-500">{{ provider.model }}</span>
</div>
<div class="flex gap-2">
<button
v-if="config.active_ai_provider?.key !== key"
@click="setActiveProvider(key)"
class="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
>
激活
</button>
<button
v-if="key !== 'default'"
@click="deleteProvider(key)"
class="px-3 py-1 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200"
>
删除
</button>
</div>
</div>
<div class="text-sm text-gray-600">
<span>Endpoint: {{ provider.endpoint }}</span>
</div>
</div>
</div>
<!-- 添加新提供商 -->
<div v-if="isAdmin" class="border-t border-gray-200 pt-4">
<h4 class="font-medium text-gray-700 mb-3">添加新的 AI 提供商</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">名称</label>
<input v-model="newProvider.name" type="text" placeholder="例如: OpenAI" class="w-full px-3 py-2 border border-gray-300 rounded-md">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Key (唯一标识)</label>
<input v-model="newProvider.key" type="text" placeholder="例如: openai" class="w-full px-3 py-2 border border-gray-300 rounded-md">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Endpoint</label>
<input v-model="newProvider.endpoint" type="text" placeholder="https://api.openai.com/v1" class="w-full px-3 py-2 border border-gray-300 rounded-md">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">API Key</label>
<input v-model="newProvider.api_key" type="password" placeholder="sk-xxx" class="w-full px-3 py-2 border border-gray-300 rounded-md">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Model</label>
<input v-model="newProvider.model" type="text" placeholder="gpt-4o" class="w-full px-3 py-2 border border-gray-300 rounded-md">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Temperature</label>
<input v-model.number="newProvider.temperature" type="number" step="0.1" min="0" max="2" class="w-full px-3 py-2 border border-gray-300 rounded-md">
</div>
</div>
<button
@click="addProvider"
:disabled="!newProvider.key || !newProvider.endpoint || !newProvider.api_key || !newProvider.model"
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
添加提供商
</button>
</div>
<!-- 测试连接 -->
<div class="border-t border-gray-200 pt-4 mt-4">
<button @click="testAiConnection" :disabled="testingAi" class="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700">
{{ testingAi ? '测试中...' : '测试 AI 连接' }}
</button>
<span v-if="aiTestResult" :class="aiTestResult.success ? 'text-green-600' : 'text-red-600'" class="ml-3">
{{ aiTestResult.message }}
</span>
</div>
<!-- 定时任务设置 -->
<div v-if="isAdmin" class="border-t border-gray-200 pt-4 mt-4">
<h4 class="font-medium text-gray-700 mb-3">定时任务设置</h4>
<div class="p-4 bg-gray-50 rounded-lg space-y-4">
<!-- 每日分析任务 -->
<div class="flex items-center justify-between">
<div>
<span class="text-sm font-medium text-gray-700">每日自动分析</span>
<p class="text-xs text-gray-500 mt-1">每天凌晨 2 点自动分析过去 24 小时的 ERROR 日志并推送到钉钉</p>
</div>
<button
type="button"
@click="config.settings.daily_schedule_enabled = !config.settings.daily_schedule_enabled; updateScheduleSettings()"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
config.settings.daily_schedule_enabled ? 'bg-blue-600' : 'bg-gray-200'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
config.settings.daily_schedule_enabled ? 'translate-x-5' : 'translate-x-0'
]"
></span>
</button>
</div>
<!-- 4 小时分析任务 -->
<div class="flex items-center justify-between border-t border-gray-200 pt-4">
<div>
<span class="text-sm font-medium text-gray-700">高频分析 4 小时</span>
<p class="text-xs text-gray-500 mt-1"> 4 小时自动分析过去 6 小时的 ERROR 日志并推送到钉钉</p>
</div>
<button
type="button"
@click="config.settings.schedule_enabled = !config.settings.schedule_enabled; updateScheduleSettings()"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
config.settings.schedule_enabled ? 'bg-blue-600' : 'bg-gray-200'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
config.settings.schedule_enabled ? 'translate-x-5' : 'translate-x-0'
]"
></span>
</button>
</div>
<div v-if="config.settings.daily_schedule_enabled || config.settings.schedule_enabled" class="text-sm text-gray-600 border-t border-gray-200 pt-4">
<p class="text-xs text-gray-500">
注意需要配置 Laravel 调度器才能生效请确保服务器已配置 cron 任务<br>
<code class="bg-gray-100 px-1 rounded">* * * * * cd /path/to/project && php artisan schedule:run >> /dev/null 2>&1</code>
</p>
<p class="text-xs text-gray-500 mt-2">
同时需要启动队列处理器来执行后台任务<br>
<code class="bg-gray-100 px-1 rounded">php artisan queue:work</code>
</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'LogAnalysis',
props: {
isAdmin: {
type: Boolean,
default: false
}
},
data() {
return {
activeTab: 'results',
loading: false,
analyzing: false,
error: '',
queryParams: {
from: this.getDefaultFrom(),
to: this.getDefaultTo(),
query: '',
mode: 'logs',
logstores: []
},
availableLogstores: [],
config: {
sls_configured: false,
ai_configured: false,
ai_providers: {},
active_ai_provider: null,
app_env_map: {},
settings: {
schedule_enabled: false
}
},
queryResult: null,
analysisResult: null,
historyReports: [],
historyLoading: false,
newProvider: {
key: '',
name: '',
endpoint: '',
api_key: '',
model: 'gpt-4o',
temperature: 0.3,
timeout: 120,
max_tokens: 4096,
enabled: true
},
testingAi: false,
aiTestResult: null
};
},
async mounted() {
await this.loadConfig();
await this.loadLogstores();
},
watch: {
activeTab(newTab) {
if (newTab === 'history' && !this.historyReports.length) {
this.loadHistory();
}
}
},
methods: {
// 格式化日期为本地时间字符串 (YYYY-MM-DDTHH:mm)
formatLocalDateTime(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
},
getDefaultFrom() {
const date = new Date();
date.setHours(date.getHours() - 1);
return this.formatLocalDateTime(date);
},
getDefaultTo() {
return this.formatLocalDateTime(new Date());
},
setQuickTime(period) {
const now = new Date();
const from = new Date();
if (period === '1h') from.setHours(now.getHours() - 1);
else if (period === '6h') from.setHours(now.getHours() - 6);
else if (period === '24h') from.setHours(now.getHours() - 24);
this.queryParams.from = this.formatLocalDateTime(from);
this.queryParams.to = this.formatLocalDateTime(now);
},
async loadConfig() {
try {
const response = await fetch('/api/log-analysis/config');
const data = await response.json();
if (data.success) {
this.config = data.data;
}
} catch (error) {
console.error('Failed to load config:', error);
}
},
async loadLogstores() {
try {
const response = await fetch('/api/log-analysis/logstores');
const data = await response.json();
if (data.success) {
this.availableLogstores = data.data.logstores;
// 默认选中所有 logstore
this.queryParams.logstores = [...this.availableLogstores];
}
} catch (error) {
console.error('Failed to load logstores:', error);
}
},
async handleQuery() {
this.loading = true;
this.error = '';
this.queryResult = null;
try {
const response = await fetch('/api/log-analysis/query', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.queryParams)
});
const data = await response.json();
if (data.success) {
this.queryResult = data.data;
this.activeTab = 'results';
} else {
this.error = data.message;
}
} catch (error) {
this.error = error.message;
} finally {
this.loading = false;
}
},
async handleAnalyze() {
this.analyzing = true;
this.error = '';
try {
const response = await fetch('/api/log-analysis/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.queryParams)
});
const data = await response.json();
if (data.success) {
// 切换到历史报告 tab 并刷新列表
this.activeTab = 'history';
await this.loadHistory();
// 自动查看刚创建的报告
if (data.data?.report_id) {
await this.viewReport(data.data.report_id);
}
} else {
this.error = data.message;
}
} catch (error) {
this.error = error.message;
} finally {
this.analyzing = false;
}
},
async loadHistory() {
this.historyLoading = true;
try {
const response = await fetch('/api/log-analysis/reports');
const data = await response.json();
if (data.success) {
this.historyReports = data.data.reports;
}
} catch (error) {
console.error('Failed to load history:', error);
} finally {
this.historyLoading = false;
}
},
async viewReport(id) {
try {
const response = await fetch(`/api/log-analysis/reports/${id}`);
const data = await response.json();
if (data.success) {
this.analysisResult = data.data;
this.activeTab = 'analysis';
}
} catch (error) {
this.error = error.message;
}
},
async setActiveProvider(key) {
try {
const response = await fetch('/api/log-analysis/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ active_ai_provider: key })
});
const data = await response.json();
if (data.success) {
await this.loadConfig();
} else {
this.error = data.message;
}
} catch (error) {
this.error = error.message;
}
},
async addProvider() {
const providers = { ...this.config.ai_providers };
providers[this.newProvider.key] = {
name: this.newProvider.name,
endpoint: this.newProvider.endpoint,
api_key: this.newProvider.api_key,
model: this.newProvider.model,
temperature: this.newProvider.temperature,
timeout: this.newProvider.timeout,
max_tokens: this.newProvider.max_tokens,
enabled: true
};
try {
const response = await fetch('/api/log-analysis/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ai_providers: providers })
});
const data = await response.json();
if (data.success) {
await this.loadConfig();
this.newProvider = {
key: '', name: '', endpoint: '', api_key: '',
model: 'gpt-4o', temperature: 0.3, timeout: 120, max_tokens: 4096, enabled: true
};
} else {
this.error = data.message;
}
} catch (error) {
this.error = error.message;
}
},
async deleteProvider(key) {
if (!confirm(`确定删除提供商 "${key}" 吗?`)) return;
const providers = { ...this.config.ai_providers };
delete providers[key];
try {
const response = await fetch('/api/log-analysis/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ai_providers: providers })
});
const result = await response.json();
if (result.success) {
await this.loadConfig();
}
} catch (error) {
this.error = error.message;
}
},
async testAiConnection() {
this.testingAi = true;
this.aiTestResult = null;
try {
const response = await fetch('/api/log-analysis/test-ai');
this.aiTestResult = await response.json();
} catch (error) {
this.aiTestResult = { success: false, message: error.message };
} finally {
this.testingAi = false;
}
},
async updateScheduleSettings() {
try {
const settings = { ...this.config.settings };
const response = await fetch('/api/log-analysis/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings })
});
const data = await response.json();
if (!data.success) {
this.error = data.message;
// Revert the toggle
this.config.settings.schedule_enabled = !this.config.settings.schedule_enabled;
}
} catch (error) {
this.error = error.message;
// Revert the toggle
this.config.settings.schedule_enabled = !this.config.settings.schedule_enabled;
}
},
getLevelClass(level) {
const classes = {
'ERROR': 'text-red-600',
'WARN': 'text-yellow-600',
'WARNING': 'text-yellow-600',
'INFO': 'text-blue-600',
'DEBUG': 'text-gray-600'
};
return classes[level?.toUpperCase()] || 'text-gray-600';
},
getLevelBadgeClass(level) {
const classes = {
'ERROR': 'bg-red-100 text-red-700',
'WARN': 'bg-yellow-100 text-yellow-700',
'WARNING': 'bg-yellow-100 text-yellow-700',
'INFO': 'bg-blue-100 text-blue-700',
'DEBUG': 'bg-gray-100 text-gray-700'
};
return classes[level?.toUpperCase()] || 'bg-gray-100 text-gray-700';
},
getImpactClass(impact) {
const classes = {
'high': 'bg-red-100 text-red-700',
'medium': 'bg-yellow-100 text-yellow-700',
'low': 'bg-green-100 text-green-700'
};
return classes[impact] || 'bg-gray-100 text-gray-700';
},
getAnomalyBorderClass(type) {
const classes = {
'error': 'border-red-500',
'warning': 'border-yellow-500',
'performance': 'border-orange-500',
'security': 'border-purple-500'
};
return classes[type] || 'border-gray-500';
},
getStatusClass(status) {
const classes = {
'pending': 'bg-yellow-100 text-yellow-700',
'completed': 'bg-green-100 text-green-700',
'failed': 'bg-red-100 text-red-700'
};
return classes[status] || 'bg-gray-100 text-gray-700';
},
getStatusText(status) {
const texts = {
'pending': '处理中',
'completed': '已完成',
'failed': '失败'
};
return texts[status] || status;
},
async refreshReport(id) {
try {
const response = await fetch(`/api/log-analysis/reports/${id}`);
const data = await response.json();
if (data.success) {
this.analysisResult = data.data;
// 如果还在处理中5秒后自动刷新
if (data.data.status === 'pending') {
setTimeout(() => {
if (this.analysisResult?.id === id && this.analysisResult?.status === 'pending') {
this.refreshReport(id);
}
}, 5000);
}
}
} catch (error) {
this.error = error.message;
}
}
}
};
</script>