Files
toolbox/resources/js/components/log-analysis/LogAnalysis.vue

956 lines
40 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>