#feature: add AI log analysis & some bugfix
This commit is contained in:
20
.env.example
20
.env.example
@@ -106,3 +106,23 @@ GIT_MONITOR_PROJECTS="service,portal-be,agent-be"
|
||||
|
||||
# Admin IP whitelist (comma separated, supports wildcard: 192.168.* or 192.168.1.*)
|
||||
TOOLBOX_ADMIN_IPS=
|
||||
|
||||
# Alibaba Cloud SLS Configuration
|
||||
SLS_ENDPOINT=cn-hangzhou.log.aliyuncs.com
|
||||
SLS_PROJECT=your-project
|
||||
# 支持单个或多个 logstore(多个用逗号分隔)
|
||||
SLS_LOGSTORE=your-logstore
|
||||
SLS_ACCESS_KEY_ID=
|
||||
SLS_ACCESS_KEY_SECRET=
|
||||
SLS_SECURITY_TOKEN=
|
||||
SLS_QUERY_TIMEOUT=60
|
||||
|
||||
# AI Service Configuration (OpenAI compatible) - 可在页面上配置多个 AI 服务并切换
|
||||
# 以下为默认配置,实际使用时可在 configs 表中配置 log_analysis.ai_providers
|
||||
AI_ENDPOINT=https://api.openai.com/v1
|
||||
AI_API_KEY=
|
||||
AI_MODEL=gpt-4o
|
||||
AI_TEMPERATURE=0.3
|
||||
AI_TIMEOUT=120
|
||||
AI_MAX_TOKENS=4096
|
||||
|
||||
|
||||
275
app/Clients/AiClient.php
Normal file
275
app/Clients/AiClient.php
Normal file
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
|
||||
namespace App\Clients;
|
||||
|
||||
use App\Services\ConfigService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use RuntimeException;
|
||||
|
||||
class AiClient
|
||||
{
|
||||
private ConfigService $configService;
|
||||
private ?array $currentProvider = null;
|
||||
|
||||
public function __construct(ConfigService $configService)
|
||||
{
|
||||
$this->configService = $configService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有配置的 AI 提供商
|
||||
*/
|
||||
public function getProviders(): array
|
||||
{
|
||||
$providers = $this->configService->get('log_analysis.ai_providers', []);
|
||||
|
||||
// 如果数据库没有配置,使用 .env 默认配置
|
||||
if (empty($providers)) {
|
||||
$envConfig = config('services.ai');
|
||||
if (!empty($envConfig['api_key'])) {
|
||||
$providers = [
|
||||
'default' => [
|
||||
'name' => '默认 (环境变量)',
|
||||
'endpoint' => $envConfig['endpoint'],
|
||||
'api_key' => $envConfig['api_key'],
|
||||
'model' => $envConfig['model'],
|
||||
'temperature' => $envConfig['temperature'],
|
||||
'timeout' => $envConfig['timeout'],
|
||||
'max_tokens' => $envConfig['max_tokens'],
|
||||
'enabled' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $providers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前激活的 AI 提供商
|
||||
*/
|
||||
public function getActiveProvider(): ?array
|
||||
{
|
||||
if ($this->currentProvider !== null) {
|
||||
return $this->currentProvider;
|
||||
}
|
||||
|
||||
$activeKey = $this->configService->get('log_analysis.active_ai_provider');
|
||||
$providers = $this->getProviders();
|
||||
|
||||
if ($activeKey && isset($providers[$activeKey]) && ($providers[$activeKey]['enabled'] ?? true)) {
|
||||
$this->currentProvider = $providers[$activeKey];
|
||||
$this->currentProvider['key'] = $activeKey;
|
||||
return $this->currentProvider;
|
||||
}
|
||||
|
||||
// 返回第一个启用的提供商
|
||||
foreach ($providers as $key => $provider) {
|
||||
if ($provider['enabled'] ?? true) {
|
||||
$this->currentProvider = $provider;
|
||||
$this->currentProvider['key'] = $key;
|
||||
return $this->currentProvider;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前使用的提供商
|
||||
*/
|
||||
public function setActiveProvider(string $providerKey): void
|
||||
{
|
||||
$providers = $this->getProviders();
|
||||
if (!isset($providers[$providerKey])) {
|
||||
throw new RuntimeException("AI 提供商不存在: {$providerKey}");
|
||||
}
|
||||
|
||||
$this->configService->set('log_analysis.active_ai_provider', $providerKey);
|
||||
$this->currentProvider = $providers[$providerKey];
|
||||
$this->currentProvider['key'] = $providerKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已配置
|
||||
*/
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->getActiveProvider() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送聊天请求 (OpenAI 兼容接口)
|
||||
*
|
||||
* @param array $messages 消息数组
|
||||
* @param string|null $systemPrompt 系统提示词
|
||||
* @return string AI 响应内容
|
||||
*/
|
||||
public function chat(array $messages, ?string $systemPrompt = null): string
|
||||
{
|
||||
$provider = $this->getActiveProvider();
|
||||
if (!$provider) {
|
||||
throw new RuntimeException('未配置 AI 服务,请在设置页面配置 AI 提供商');
|
||||
}
|
||||
|
||||
// 速率限制
|
||||
$this->waitForRateLimit($provider['key'] ?? 'default');
|
||||
|
||||
$allMessages = [];
|
||||
if ($systemPrompt) {
|
||||
$allMessages[] = ['role' => 'system', 'content' => $systemPrompt];
|
||||
}
|
||||
$allMessages = array_merge($allMessages, $messages);
|
||||
|
||||
$endpoint = rtrim($provider['endpoint'], '/') . '/chat/completions';
|
||||
|
||||
$response = Http::timeout($provider['timeout'] ?? 120)
|
||||
->withHeaders([
|
||||
'Authorization' => 'Bearer ' . $provider['api_key'],
|
||||
'Content-Type' => 'application/json',
|
||||
])
|
||||
->post($endpoint, [
|
||||
'model' => $provider['model'],
|
||||
'messages' => $allMessages,
|
||||
'temperature' => $provider['temperature'] ?? 0.3,
|
||||
'max_tokens' => $provider['max_tokens'] ?? 4096,
|
||||
]);
|
||||
|
||||
if (!$response->successful()) {
|
||||
$error = $response->json('error.message') ?? $response->body();
|
||||
throw new RuntimeException("AI 请求失败: {$error}");
|
||||
}
|
||||
|
||||
return $response->json('choices.0.message.content', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析日志并返回结构化结果
|
||||
*
|
||||
* @param string $logsContent 日志内容
|
||||
* @param string|null $codeContext 相关代码上下文
|
||||
* @return array 分析结果
|
||||
*/
|
||||
public function analyzeLogs(string $logsContent, ?string $codeContext = null): array
|
||||
{
|
||||
$systemPrompt = $this->buildAnalysisSystemPrompt();
|
||||
$userContent = $this->buildAnalysisUserPrompt($logsContent, $codeContext);
|
||||
|
||||
$response = $this->chat([
|
||||
['role' => 'user', 'content' => $userContent],
|
||||
], $systemPrompt);
|
||||
|
||||
return $this->parseAnalysisResponse($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试连接
|
||||
*/
|
||||
public function testConnection(): array
|
||||
{
|
||||
$provider = $this->getActiveProvider();
|
||||
if (!$provider) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => '未配置 AI 服务',
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->chat([
|
||||
['role' => 'user', 'content' => 'Hello, please respond with "OK" only.'],
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => '连接成功',
|
||||
'provider' => $provider['name'] ?? $provider['key'],
|
||||
'model' => $provider['model'],
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function buildAnalysisSystemPrompt(): string
|
||||
{
|
||||
return <<<'PROMPT'
|
||||
你是一个专业的日志分析专家。你的任务是分析提供的日志内容,识别异常和问题,并给出分类和建议。
|
||||
|
||||
请按照以下 JSON 格式返回分析结果:
|
||||
{
|
||||
"core_anomalies": [
|
||||
{
|
||||
"type": "error|warning|performance|security",
|
||||
"classification": "database|network|application|configuration|resource|other",
|
||||
"count": 数量,
|
||||
"sample": "示例日志内容",
|
||||
"possible_cause": "可能的原因",
|
||||
"suggestion": "建议的解决方案"
|
||||
}
|
||||
],
|
||||
"summary": "整体分析摘要",
|
||||
"impact": "high|medium|low",
|
||||
"trends": "趋势分析(可选)"
|
||||
}
|
||||
|
||||
分类说明:
|
||||
- type: error(错误), warning(警告), performance(性能问题), security(安全问题)
|
||||
- classification: database(数据库), network(网络), application(应用逻辑), configuration(配置), resource(资源), other(其他)
|
||||
- impact: high(高影响), medium(中等影响), low(低影响)
|
||||
|
||||
请确保返回有效的 JSON 格式。
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
private function buildAnalysisUserPrompt(string $logsContent, ?string $codeContext): string
|
||||
{
|
||||
$prompt = "请分析以下日志内容:\n\n```\n{$logsContent}\n```";
|
||||
|
||||
if ($codeContext) {
|
||||
$prompt .= "\n\n相关代码上下文:\n\n```\n{$codeContext}\n```";
|
||||
}
|
||||
|
||||
return $prompt;
|
||||
}
|
||||
|
||||
private function parseAnalysisResponse(string $response): array
|
||||
{
|
||||
// 尝试提取 JSON
|
||||
if (preg_match('/\{[\s\S]*}/', $response, $matches)) {
|
||||
$json = $matches[0];
|
||||
$decoded = json_decode($json, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果无法解析 JSON,返回原始响应
|
||||
return [
|
||||
'core_anomalies' => [],
|
||||
'summary' => $response,
|
||||
'impact' => 'unknown',
|
||||
'raw_response' => $response,
|
||||
];
|
||||
}
|
||||
|
||||
private function waitForRateLimit(string $providerKey): void
|
||||
{
|
||||
$key = "ai_rate_limit:{$providerKey}";
|
||||
|
||||
// 每分钟最多 60 次请求
|
||||
$maxAttempts = 60;
|
||||
$decaySeconds = 60;
|
||||
|
||||
if (RateLimiter::tooManyAttempts($key, $maxAttempts)) {
|
||||
$seconds = RateLimiter::availableIn($key);
|
||||
sleep($seconds);
|
||||
}
|
||||
|
||||
RateLimiter::hit($key, $decaySeconds);
|
||||
}
|
||||
}
|
||||
292
app/Clients/SlsClient.php
Normal file
292
app/Clients/SlsClient.php
Normal file
@@ -0,0 +1,292 @@
|
||||
<?php
|
||||
|
||||
namespace App\Clients;
|
||||
|
||||
use Aliyun_Log_Client;
|
||||
use Aliyun_Log_Models_GetLogsRequest;
|
||||
use Aliyun_Log_Models_GetHistogramsRequest;
|
||||
use Aliyun_Log_Exception;
|
||||
use RuntimeException;
|
||||
|
||||
class SlsClient
|
||||
{
|
||||
private ?Aliyun_Log_Client $client = null;
|
||||
private string $project;
|
||||
private array $logstores;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$config = config('services.sls');
|
||||
|
||||
if (empty($config['endpoint']) || empty($config['access_key_id']) || empty($config['access_key_secret'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->project = $config['project'] ?? '';
|
||||
|
||||
// 解析 logstore 配置,支持逗号分隔的多个 logstore
|
||||
$logstoreConfig = $config['logstore'] ?? '';
|
||||
if (!empty($logstoreConfig)) {
|
||||
// 如果包含逗号,说明是多个 logstore
|
||||
if (str_contains($logstoreConfig, ',')) {
|
||||
$this->logstores = array_map('trim', explode(',', $logstoreConfig));
|
||||
} else {
|
||||
$this->logstores = [$logstoreConfig];
|
||||
}
|
||||
} else {
|
||||
$this->logstores = [];
|
||||
}
|
||||
|
||||
$this->client = new Aliyun_Log_Client(
|
||||
$config['endpoint'],
|
||||
$config['access_key_id'],
|
||||
$config['access_key_secret'],
|
||||
$config['security_token'] ?: ''
|
||||
);
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
// 只要有 client、project,并且至少有一个 logstore 就算配置完成
|
||||
return $this->client !== null
|
||||
&& !empty($this->project)
|
||||
&& !empty($this->logstores);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置的所有 logstore
|
||||
*/
|
||||
public function getLogstores(): array
|
||||
{
|
||||
return $this->logstores;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认的 logstore(第一个)
|
||||
*/
|
||||
private function getDefaultLogstore(): string
|
||||
{
|
||||
if (empty($this->logstores)) {
|
||||
throw new RuntimeException('没有配置可用的 logstore');
|
||||
}
|
||||
|
||||
return $this->logstores[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询日志
|
||||
*
|
||||
* @param int $from 开始时间戳
|
||||
* @param int $to 结束时间戳
|
||||
* @param string|null $query SLS 查询语句
|
||||
* @param int $offset 偏移量
|
||||
* @param int $limit 返回数量
|
||||
* @param string|null $logstore 可选的 logstore,不传则使用默认
|
||||
* @return array{logs: array, count: int, complete: bool}
|
||||
*/
|
||||
public function getLogs(
|
||||
int $from,
|
||||
int $to,
|
||||
?string $query = null,
|
||||
int $offset = 0,
|
||||
int $limit = 100,
|
||||
?string $logstore = null
|
||||
): array {
|
||||
$this->ensureConfigured();
|
||||
|
||||
$request = new Aliyun_Log_Models_GetLogsRequest(
|
||||
$this->project,
|
||||
$logstore ?? $this->getDefaultLogstore(),
|
||||
$from,
|
||||
$to,
|
||||
'',
|
||||
$query ?? '*',
|
||||
$limit,
|
||||
$offset,
|
||||
false
|
||||
);
|
||||
|
||||
try {
|
||||
$response = $this->client->getLogs($request);
|
||||
|
||||
$logs = [];
|
||||
foreach ($response->getLogs() as $log) {
|
||||
$logs[] = $log->getContents();
|
||||
}
|
||||
|
||||
return [
|
||||
'logs' => $logs,
|
||||
'count' => $response->getCount(),
|
||||
'complete' => $response->isCompleted(),
|
||||
];
|
||||
} catch (Aliyun_Log_Exception $e) {
|
||||
throw new RuntimeException(
|
||||
"SLS 查询失败: [{$e->getErrorCode()}] {$e->getErrorMessage()}",
|
||||
0,
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取日志分布直方图
|
||||
*
|
||||
* @param int $from 开始时间戳
|
||||
* @param int $to 结束时间戳
|
||||
* @param string|null $query SLS 查询语句
|
||||
* @param string|null $logstore 可选的 logstore
|
||||
* @return array{histograms: array, count: int, complete: bool}
|
||||
*/
|
||||
public function getHistograms(
|
||||
int $from,
|
||||
int $to,
|
||||
?string $query = null,
|
||||
?string $logstore = null
|
||||
): array {
|
||||
$this->ensureConfigured();
|
||||
|
||||
$request = new Aliyun_Log_Models_GetHistogramsRequest(
|
||||
$this->project,
|
||||
$logstore ?? $this->getDefaultLogstore(),
|
||||
$from,
|
||||
$to,
|
||||
'',
|
||||
$query ?? '*'
|
||||
);
|
||||
|
||||
try {
|
||||
$response = $this->client->getHistograms($request);
|
||||
|
||||
$histograms = [];
|
||||
foreach ($response->getHistograms() as $histogram) {
|
||||
$histograms[] = [
|
||||
'from' => $histogram->getFrom(),
|
||||
'to' => $histogram->getTo(),
|
||||
'count' => $histogram->getCount(),
|
||||
'complete' => $histogram->isCompleted(),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'histograms' => $histograms,
|
||||
'count' => $response->getTotalCount(),
|
||||
'complete' => $response->isCompleted(),
|
||||
];
|
||||
} catch (Aliyun_Log_Exception $e) {
|
||||
throw new RuntimeException(
|
||||
"SLS 直方图查询失败: [{$e->getErrorCode()}] {$e->getErrorMessage()}",
|
||||
0,
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页获取所有日志
|
||||
*
|
||||
* @param int $from 开始时间戳
|
||||
* @param int $to 结束时间戳
|
||||
* @param string|null $query SLS 查询语句
|
||||
* @param int $maxLogs 最大返回日志数
|
||||
* @param string|null $logstore 可选的 logstore
|
||||
* @return array
|
||||
*/
|
||||
public function getAllLogs(
|
||||
int $from,
|
||||
int $to,
|
||||
?string $query = null,
|
||||
int $maxLogs = 1000,
|
||||
?string $logstore = null
|
||||
): array {
|
||||
$allLogs = [];
|
||||
$offset = 0;
|
||||
$batchSize = 100;
|
||||
|
||||
while (count($allLogs) < $maxLogs) {
|
||||
$result = $this->getLogs($from, $to, $query, $offset, $batchSize, $logstore);
|
||||
|
||||
$allLogs = array_merge($allLogs, $result['logs']);
|
||||
|
||||
if ($result['complete'] || count($result['logs']) < $batchSize) {
|
||||
break;
|
||||
}
|
||||
|
||||
$offset += $batchSize;
|
||||
}
|
||||
|
||||
return array_slice($allLogs, 0, $maxLogs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试连接
|
||||
*/
|
||||
public function testConnection(): bool
|
||||
{
|
||||
if (!$this->isConfigured()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$now = time();
|
||||
$this->getLogs($now - 60, $now, '*', 0, 1);
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从多个 logstore 获取日志
|
||||
*
|
||||
* @param int $from 开始时间戳
|
||||
* @param int $to 结束时间戳
|
||||
* @param string|null $query SLS 查询语句
|
||||
* @param int $maxLogs 最大返回日志数
|
||||
* @param array|null $logstores 要查询的 logstore 列表,不传则使用配置的所有 logstore
|
||||
* @return array 按 logstore 分组的日志数据 ['logstore_name' => ['logs' => [...], 'count' => 100]]
|
||||
*/
|
||||
public function getAllLogsFromMultipleStores(
|
||||
int $from,
|
||||
int $to,
|
||||
?string $query = null,
|
||||
int $maxLogs = 1000,
|
||||
?array $logstores = null
|
||||
): array {
|
||||
$this->ensureConfigured();
|
||||
|
||||
$targetLogstores = $logstores ?? $this->getLogstores();
|
||||
|
||||
if (empty($targetLogstores)) {
|
||||
throw new RuntimeException('没有配置可用的 logstore');
|
||||
}
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach ($targetLogstores as $logstore) {
|
||||
try {
|
||||
$logs = $this->getAllLogs($from, $to, $query, $maxLogs, $logstore);
|
||||
$results[$logstore] = [
|
||||
'logs' => $logs,
|
||||
'count' => count($logs),
|
||||
'success' => true,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
$results[$logstore] = [
|
||||
'logs' => [],
|
||||
'count' => 0,
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
private function ensureConfigured(): void
|
||||
{
|
||||
if (!$this->isConfigured()) {
|
||||
throw new RuntimeException('SLS 客户端未配置,请检查 .env 中的 SLS_* 配置项');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,11 +37,31 @@ class GitMonitorCheckCommand extends Command
|
||||
));
|
||||
|
||||
if (!empty($result['issues']['develop_merges'])) {
|
||||
$this->warn(sprintf(' - 检测到 %d 个 develop merge', count($result['issues']['develop_merges'])));
|
||||
$this->warn(sprintf(' - 检测到 %d 个 develop merge:', count($result['issues']['develop_merges'])));
|
||||
foreach ($result['issues']['develop_merges'] as $commit) {
|
||||
$this->warn(sprintf(
|
||||
' • %s %s (%s)',
|
||||
substr($commit['hash'], 0, 8),
|
||||
$commit['subject'],
|
||||
$commit['author']
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($result['issues']['missing_functions'])) {
|
||||
$this->warn(sprintf(' - 检测到 %d 个疑似缺失函数的提交', count($result['issues']['missing_functions'])));
|
||||
$this->warn(sprintf(' - 检测到 %d 个疑似缺失函数的提交:', count($result['issues']['missing_functions'])));
|
||||
foreach ($result['issues']['missing_functions'] as $issue) {
|
||||
$this->warn(sprintf(
|
||||
' • %s %s (%s)',
|
||||
substr($issue['commit']['hash'], 0, 8),
|
||||
$issue['commit']['subject'],
|
||||
$issue['commit']['author']
|
||||
));
|
||||
foreach ($issue['details'] as $detail) {
|
||||
$functions = implode(', ', array_slice($detail['functions'], 0, 5));
|
||||
$this->warn(sprintf(' %s => %s', $detail['file'], $functions));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
188
app/Console/Commands/LogAnalysisCommand.php
Normal file
188
app/Console/Commands/LogAnalysisCommand.php
Normal file
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\AnalysisMode;
|
||||
use App\Services\LogAnalysisService;
|
||||
use App\Services\SlsService;
|
||||
use App\Services\AiService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class LogAnalysisCommand extends Command
|
||||
{
|
||||
protected $signature = 'log-analysis:run
|
||||
{--from= : 开始时间 (Y-m-d H:i:s 或相对时间如 -1h, -30m, -1d)}
|
||||
{--to= : 结束时间 (Y-m-d H:i:s 或相对时间,默认 now)}
|
||||
{--query= : SLS 查询语句}
|
||||
{--mode=logs : 分析模式 (logs|logs+code)}
|
||||
{--output= : 输出 JSON 报告的文件路径}
|
||||
{--push : 推送结果到钉钉}
|
||||
{--no-save : 不保存报告到数据库}';
|
||||
|
||||
protected $description = '分析 SLS 日志,支持 AI 智能分析和钉钉推送';
|
||||
|
||||
public function handle(
|
||||
LogAnalysisService $analysisService,
|
||||
SlsService $slsService,
|
||||
AiService $aiService
|
||||
): int {
|
||||
// 检查配置
|
||||
if (!$slsService->isConfigured()) {
|
||||
$this->error('SLS 服务未配置,请检查 .env 中的 SLS_* 配置项');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (!$aiService->isConfigured()) {
|
||||
$this->error('AI 服务未配置,请在页面上配置 AI 提供商或设置 .env 中的 AI_* 配置项');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// 解析时间参数
|
||||
$from = $this->parseTime($this->option('from') ?? '-1h');
|
||||
$to = $this->parseTime($this->option('to') ?? 'now');
|
||||
|
||||
if ($from >= $to) {
|
||||
$this->error('开始时间必须早于结束时间');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// 解析分析模式
|
||||
$modeOption = $this->option('mode');
|
||||
$mode = $modeOption === 'logs+code'
|
||||
? AnalysisMode::LogsWithCode
|
||||
: AnalysisMode::Logs;
|
||||
|
||||
$query = $this->option('query');
|
||||
|
||||
$this->info("开始分析日志...");
|
||||
$this->line(" 时间范围: {$from->format('Y-m-d H:i:s')} ~ {$to->format('Y-m-d H:i:s')}");
|
||||
$this->line(" 查询语句: " . ($query ?: '*'));
|
||||
$this->line(" 分析模式: {$mode->label()}");
|
||||
$this->newLine();
|
||||
|
||||
try {
|
||||
$result = $analysisService->analyze(
|
||||
$from,
|
||||
$to,
|
||||
$query,
|
||||
$mode,
|
||||
!$this->option('no-save')
|
||||
);
|
||||
|
||||
// 输出到文件
|
||||
if ($outputPath = $this->option('output')) {
|
||||
$json = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
file_put_contents($outputPath, $json);
|
||||
$this->info("报告已保存到: {$outputPath}");
|
||||
}
|
||||
|
||||
// 推送到钉钉
|
||||
if ($this->option('push')) {
|
||||
$this->line("正在推送到钉钉...");
|
||||
$pushed = $analysisService->pushToNotification($result);
|
||||
if ($pushed) {
|
||||
$this->info("已推送到钉钉");
|
||||
} else {
|
||||
$this->warn("钉钉推送失败");
|
||||
}
|
||||
}
|
||||
|
||||
// 显示摘要
|
||||
$this->displaySummary($result);
|
||||
|
||||
return Command::SUCCESS;
|
||||
} catch (\Exception $e) {
|
||||
$this->error("分析失败: {$e->getMessage()}");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析时间参数
|
||||
*/
|
||||
private function parseTime(string $input): Carbon
|
||||
{
|
||||
// 相对时间格式: -1h, -30m, -1d, -2w
|
||||
if (preg_match('/^-(\d+)([hmsdw])$/', $input, $matches)) {
|
||||
$value = (int) $matches[1];
|
||||
$unit = $matches[2];
|
||||
|
||||
return match ($unit) {
|
||||
'h' => Carbon::now()->subHours($value),
|
||||
'm' => Carbon::now()->subMinutes($value),
|
||||
's' => Carbon::now()->subSeconds($value),
|
||||
'd' => Carbon::now()->subDays($value),
|
||||
'w' => Carbon::now()->subWeeks($value),
|
||||
default => Carbon::now(),
|
||||
};
|
||||
}
|
||||
|
||||
// 特殊值
|
||||
if ($input === 'now') {
|
||||
return Carbon::now();
|
||||
}
|
||||
|
||||
// 尝试解析为日期时间
|
||||
return Carbon::parse($input);
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示分析摘要
|
||||
*/
|
||||
private function displaySummary(array $result): void
|
||||
{
|
||||
$this->newLine();
|
||||
$this->info('=== 分析摘要 ===');
|
||||
$this->line("总日志数: {$result['metadata']['total_logs']}");
|
||||
$this->line("分析应用数: {$result['metadata']['apps_analyzed']}");
|
||||
$this->line("执行时间: {$result['metadata']['execution_time_ms']}ms");
|
||||
$this->newLine();
|
||||
|
||||
if (empty($result['results'])) {
|
||||
$this->warn('未找到匹配的日志');
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($result['results'] as $appName => $appResult) {
|
||||
$this->line("【{$appName}】");
|
||||
|
||||
if (isset($appResult['error'])) {
|
||||
$this->error(" 分析失败: {$appResult['error']}");
|
||||
continue;
|
||||
}
|
||||
|
||||
$impact = $appResult['impact'] ?? 'unknown';
|
||||
$impactColor = match ($impact) {
|
||||
'high' => 'red',
|
||||
'medium' => 'yellow',
|
||||
'low' => 'green',
|
||||
default => 'white',
|
||||
};
|
||||
|
||||
$this->line(" 日志数: {$appResult['log_count']}");
|
||||
$this->line(" 代码上下文: " . ($appResult['has_code_context'] ? '是' : '否'));
|
||||
$this->line(" 影响级别: <fg={$impactColor}>{$impact}</>");
|
||||
$this->line(" 摘要: " . ($appResult['summary'] ?? 'N/A'));
|
||||
|
||||
$anomalies = $appResult['core_anomalies'] ?? [];
|
||||
if (!empty($anomalies)) {
|
||||
$this->line(" 异常数: " . count($anomalies));
|
||||
|
||||
$table = [];
|
||||
foreach (array_slice($anomalies, 0, 5) as $anomaly) {
|
||||
$table[] = [
|
||||
$anomaly['type'] ?? 'N/A',
|
||||
$anomaly['classification'] ?? 'N/A',
|
||||
$anomaly['count'] ?? 1,
|
||||
mb_substr($anomaly['possible_cause'] ?? 'N/A', 0, 40),
|
||||
];
|
||||
}
|
||||
|
||||
$this->table(['类型', '分类', '数量', '可能原因'], $table);
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
17
app/Enums/AnalysisMode.php
Normal file
17
app/Enums/AnalysisMode.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum AnalysisMode: string
|
||||
{
|
||||
case Logs = 'logs';
|
||||
case LogsWithCode = 'logs+code';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Logs => '仅日志分析',
|
||||
self::LogsWithCode => '日志 + 代码分析',
|
||||
};
|
||||
}
|
||||
}
|
||||
315
app/Http/Controllers/LogAnalysisController.php
Normal file
315
app/Http/Controllers/LogAnalysisController.php
Normal file
@@ -0,0 +1,315 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\AnalysisMode;
|
||||
use App\Services\AiService;
|
||||
use App\Services\ConfigService;
|
||||
use App\Services\LogAnalysisService;
|
||||
use App\Services\SlsService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class LogAnalysisController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LogAnalysisService $analysisService,
|
||||
private readonly SlsService $slsService,
|
||||
private readonly AiService $aiService,
|
||||
private readonly ConfigService $configService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 查询日志(预览,不进行 AI 分析)
|
||||
*/
|
||||
public function queryLogs(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'from' => 'required|date',
|
||||
'to' => 'required|date|after:from',
|
||||
'query' => 'nullable|string|max:1000',
|
||||
'limit' => 'nullable|integer|min:1|max:1000',
|
||||
'logstores' => 'nullable|array',
|
||||
'logstores.*' => 'string',
|
||||
]);
|
||||
|
||||
if (!$this->slsService->isConfigured()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'SLS 服务未配置,请检查 .env 中的 SLS_* 配置项',
|
||||
], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
// 如果指定了多个 logstore,使用多 logstore 查询
|
||||
if (!empty($validated['logstores']) && count($validated['logstores']) > 1) {
|
||||
$result = $this->analysisService->queryLogsFromMultipleStores(
|
||||
Carbon::parse($validated['from']),
|
||||
Carbon::parse($validated['to']),
|
||||
$validated['query'] ?? null,
|
||||
$validated['limit'] ?? 100,
|
||||
$validated['logstores']
|
||||
);
|
||||
} else {
|
||||
// 单个 logstore 查询
|
||||
$result = $this->analysisService->queryLogs(
|
||||
Carbon::parse($validated['from']),
|
||||
Carbon::parse($validated['to']),
|
||||
$validated['query'] ?? null,
|
||||
$validated['limit'] ?? 100
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$errorMessage = $e->getMessage();
|
||||
|
||||
// 优化常见错误提示
|
||||
if (str_contains($errorMessage, 'key') && str_contains($errorMessage, 'is not config as key value config')) {
|
||||
$errorMessage = 'SLS 查询语法错误:使用 "字段:值" 语法需要该字段配置为键值索引。建议使用全文搜索(如 "ERROR")或 SQL 语法(如 "* | where level = \"ERROR\"")';
|
||||
} elseif (str_contains($errorMessage, 'ParameterInvalid')) {
|
||||
$errorMessage = 'SLS 查询参数无效:' . $errorMessage . '。请检查查询语法是否正确';
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $errorMessage,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用的 logstore 列表
|
||||
*/
|
||||
public function getLogstores(): JsonResponse
|
||||
{
|
||||
if (!$this->slsService->isConfigured()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'SLS 服务未配置',
|
||||
], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$logstores = $this->slsService->getLogstores();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'logstores' => $logstores,
|
||||
],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行异步分析(创建后台任务)
|
||||
*/
|
||||
public function analyze(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'from' => 'required|date',
|
||||
'to' => 'required|date|after:from',
|
||||
'query' => 'nullable|string|max:1000',
|
||||
'mode' => 'nullable|in:logs,logs+code',
|
||||
]);
|
||||
|
||||
if (!$this->slsService->isConfigured()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'SLS 服务未配置',
|
||||
], 400);
|
||||
}
|
||||
|
||||
if (!$this->aiService->isConfigured()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'AI 服务未配置,请在设置页面配置 AI 提供商',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$mode = ($validated['mode'] ?? 'logs') === 'logs+code'
|
||||
? AnalysisMode::LogsWithCode
|
||||
: AnalysisMode::Logs;
|
||||
|
||||
try {
|
||||
// 创建异步分析任务
|
||||
$report = $this->analysisService->analyzeAsync(
|
||||
Carbon::parse($validated['from']),
|
||||
Carbon::parse($validated['to']),
|
||||
$validated['query'] ?? null,
|
||||
$mode
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '分析任务已创建,请在历史报告中查看结果',
|
||||
'data' => [
|
||||
'report_id' => $report->id,
|
||||
'status' => $report->status,
|
||||
],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取历史报告列表
|
||||
*/
|
||||
public function listReports(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'limit' => 'nullable|integer|min:1|max:100',
|
||||
'offset' => 'nullable|integer|min:0',
|
||||
]);
|
||||
|
||||
$result = $this->analysisService->getReports(
|
||||
$validated['limit'] ?? 20,
|
||||
$validated['offset'] ?? 0
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个报告详情
|
||||
*/
|
||||
public function getReport(int $id): JsonResponse
|
||||
{
|
||||
$report = $this->analysisService->getReport($id);
|
||||
|
||||
if (!$report) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '报告不存在',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $report,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置
|
||||
*/
|
||||
public function getConfig(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'sls_configured' => $this->slsService->isConfigured(),
|
||||
'ai_configured' => $this->aiService->isConfigured(),
|
||||
'ai_providers' => $this->aiService->getProviders(),
|
||||
'active_ai_provider' => $this->aiService->getActiveProvider(),
|
||||
'app_env_map' => $this->configService->get('log_analysis.app_env_map', []),
|
||||
'settings' => $this->configService->get('log_analysis.settings', [
|
||||
'max_logs_per_batch' => 1000,
|
||||
'max_logs_per_app' => 500,
|
||||
'default_time_window_minutes' => 60,
|
||||
'schedule_enabled' => false,
|
||||
]),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新配置(需要管理员权限)
|
||||
*/
|
||||
public function updateConfig(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'app_env_map' => 'nullable|array',
|
||||
'settings' => 'nullable|array',
|
||||
'ai_providers' => 'nullable|array',
|
||||
'active_ai_provider' => 'nullable|string',
|
||||
]);
|
||||
|
||||
try {
|
||||
if (isset($validated['app_env_map'])) {
|
||||
$this->configService->set(
|
||||
'log_analysis.app_env_map',
|
||||
$validated['app_env_map'],
|
||||
'Log analysis app to project/env mapping'
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($validated['settings'])) {
|
||||
$this->configService->set(
|
||||
'log_analysis.settings',
|
||||
$validated['settings'],
|
||||
'Log analysis settings'
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($validated['ai_providers'])) {
|
||||
$this->aiService->saveProviders($validated['ai_providers']);
|
||||
}
|
||||
|
||||
if (isset($validated['active_ai_provider'])) {
|
||||
$this->aiService->setActiveProvider($validated['active_ai_provider']);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '配置已更新',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 SLS 连接
|
||||
*/
|
||||
public function testSlsConnection(): JsonResponse
|
||||
{
|
||||
if (!$this->slsService->isConfigured()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'SLS 服务未配置',
|
||||
]);
|
||||
}
|
||||
|
||||
$connected = $this->slsService->testConnection();
|
||||
|
||||
return response()->json([
|
||||
'success' => $connected,
|
||||
'message' => $connected ? 'SLS 连接成功' : 'SLS 连接失败',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 AI 连接
|
||||
*/
|
||||
public function testAiConnection(): JsonResponse
|
||||
{
|
||||
$result = $this->aiService->testConnection();
|
||||
|
||||
return response()->json([
|
||||
'success' => $result['success'],
|
||||
'message' => $result['message'],
|
||||
'data' => $result,
|
||||
]);
|
||||
}
|
||||
}
|
||||
224
app/Jobs/LogAnalysisJob.php
Normal file
224
app/Jobs/LogAnalysisJob.php
Normal file
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\AnalysisMode;
|
||||
use App\Models\LogAnalysisReport;
|
||||
use App\Services\AiService;
|
||||
use App\Services\CodeContextService;
|
||||
use App\Services\ConfigService;
|
||||
use App\Services\DingTalkService;
|
||||
use App\Services\SlsService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class LogAnalysisJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $timeout = 600; // 10 分钟超时
|
||||
public int $tries = 1; // 只尝试一次
|
||||
|
||||
public function __construct(
|
||||
private readonly int $reportId,
|
||||
private readonly Carbon $from,
|
||||
private readonly Carbon $to,
|
||||
private readonly ?string $query,
|
||||
private readonly AnalysisMode $mode,
|
||||
private readonly bool $pushNotification = false
|
||||
) {}
|
||||
|
||||
public function handle(
|
||||
SlsService $slsService,
|
||||
AiService $aiService,
|
||||
CodeContextService $codeContextService,
|
||||
ConfigService $configService,
|
||||
DingTalkService $dingTalkService
|
||||
): void {
|
||||
$report = LogAnalysisReport::find($this->reportId);
|
||||
if (!$report) {
|
||||
Log::error("LogAnalysisJob: Report not found", ['report_id' => $this->reportId]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$startTime = microtime(true);
|
||||
|
||||
// 1. 获取日志
|
||||
$logs = $slsService->fetchLogs($this->from, $this->to, $this->query);
|
||||
|
||||
if ($logs->isEmpty()) {
|
||||
$report->update([
|
||||
'status' => 'completed',
|
||||
'total_logs' => 0,
|
||||
'results' => [],
|
||||
'metadata' => [
|
||||
'total_logs' => 0,
|
||||
'apps_analyzed' => 0,
|
||||
'execution_time_ms' => 0,
|
||||
'analyzed_at' => Carbon::now()->format('Y-m-d H:i:s'),
|
||||
'message' => '未找到匹配的日志',
|
||||
],
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 按 app_name 分组
|
||||
$grouped = $slsService->groupByAppName($logs);
|
||||
|
||||
// 3. 分析每个分组
|
||||
$results = [];
|
||||
$settings = $configService->get('log_analysis.settings', []);
|
||||
$maxLogsPerApp = $settings['max_logs_per_app'] ?? 500;
|
||||
|
||||
foreach ($grouped as $appName => $appLogs) {
|
||||
$appLogsCollection = collect($appLogs);
|
||||
|
||||
// 限制每个 app 的日志数量
|
||||
if ($appLogsCollection->count() > $maxLogsPerApp) {
|
||||
$appLogsCollection = $appLogsCollection->take($maxLogsPerApp);
|
||||
}
|
||||
|
||||
// 获取代码上下文(如果需要)
|
||||
$codeContext = null;
|
||||
if ($this->mode === AnalysisMode::LogsWithCode) {
|
||||
$repoPath = $codeContextService->getRepoPath($appName);
|
||||
if ($repoPath) {
|
||||
$codeContext = $codeContextService->extractRelevantCode($repoPath, $appLogsCollection);
|
||||
}
|
||||
}
|
||||
|
||||
// 准备日志内容
|
||||
$logsContent = $this->formatLogsForAnalysis($appLogsCollection);
|
||||
|
||||
// AI 分析
|
||||
try {
|
||||
$results[$appName] = $aiService->analyzeLogs($logsContent, $codeContext);
|
||||
$results[$appName]['log_count'] = $appLogsCollection->count();
|
||||
$results[$appName]['has_code_context'] = $codeContext !== null;
|
||||
} catch (\Exception $e) {
|
||||
$results[$appName] = [
|
||||
'error' => $e->getMessage(),
|
||||
'log_count' => $appLogsCollection->count(),
|
||||
'has_code_context' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$executionTime = (microtime(true) - $startTime) * 1000;
|
||||
|
||||
// 4. 更新报告
|
||||
$report->update([
|
||||
'status' => 'completed',
|
||||
'total_logs' => $logs->count(),
|
||||
'results' => $results,
|
||||
'metadata' => [
|
||||
'total_logs' => $logs->count(),
|
||||
'apps_analyzed' => count($results),
|
||||
'execution_time_ms' => round($executionTime),
|
||||
'analyzed_at' => Carbon::now()->format('Y-m-d H:i:s'),
|
||||
],
|
||||
]);
|
||||
|
||||
// 5. 推送通知(如果需要)
|
||||
if ($this->pushNotification) {
|
||||
$this->pushToNotification($report, $dingTalkService);
|
||||
}
|
||||
|
||||
Log::info("LogAnalysisJob: Completed", [
|
||||
'report_id' => $this->reportId,
|
||||
'total_logs' => $logs->count(),
|
||||
'execution_time_ms' => round($executionTime),
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error("LogAnalysisJob: Failed", [
|
||||
'report_id' => $this->reportId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$report->update([
|
||||
'status' => 'failed',
|
||||
'error_message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function formatLogsForAnalysis(Collection $logs): string
|
||||
{
|
||||
$formatted = [];
|
||||
|
||||
foreach ($logs as $log) {
|
||||
$line = sprintf(
|
||||
"[%s] [%s] %s",
|
||||
$log['time'] ?? 'N/A',
|
||||
$log['level'] ?? 'N/A',
|
||||
$log['message'] ?? json_encode($log['_raw'] ?? $log)
|
||||
);
|
||||
|
||||
if (!empty($log['trace'])) {
|
||||
$line .= "\n" . $log['trace'];
|
||||
}
|
||||
|
||||
$formatted[] = $line;
|
||||
}
|
||||
|
||||
return implode("\n\n", $formatted);
|
||||
}
|
||||
|
||||
private function pushToNotification(LogAnalysisReport $report, DingTalkService $dingTalkService): void
|
||||
{
|
||||
$lines = [];
|
||||
$lines[] = "📊 SLS 日志分析报告";
|
||||
$lines[] = "时间范围: {$report->from_time->format('Y-m-d H:i:s')} ~ {$report->to_time->format('Y-m-d H:i:s')}";
|
||||
$lines[] = "总日志数: {$report->total_logs}";
|
||||
$lines[] = "";
|
||||
|
||||
foreach ($report->results as $appName => $appResult) {
|
||||
$lines[] = "【{$appName}】";
|
||||
|
||||
if (isset($appResult['error'])) {
|
||||
$lines[] = " 分析失败: {$appResult['error']}";
|
||||
continue;
|
||||
}
|
||||
|
||||
$impact = $appResult['impact'] ?? 'unknown';
|
||||
$impactEmoji = match ($impact) {
|
||||
'high' => '🔴',
|
||||
'medium' => '🟡',
|
||||
'low' => '🟢',
|
||||
default => '⚪',
|
||||
};
|
||||
|
||||
$lines[] = " 影响级别: {$impactEmoji} {$impact}";
|
||||
$lines[] = " 摘要: " . ($appResult['summary'] ?? 'N/A');
|
||||
|
||||
$anomalies = $appResult['core_anomalies'] ?? [];
|
||||
if (!empty($anomalies)) {
|
||||
$lines[] = " 异常数: " . count($anomalies);
|
||||
foreach (array_slice($anomalies, 0, 3) as $anomaly) {
|
||||
$lines[] = " - [{$anomaly['classification']}] {$anomaly['possible_cause']}";
|
||||
}
|
||||
}
|
||||
|
||||
$lines[] = "";
|
||||
}
|
||||
|
||||
$message = implode("\n", $lines);
|
||||
|
||||
try {
|
||||
$dingTalkService->sendText($message);
|
||||
} catch (\Exception $e) {
|
||||
Log::warning("LogAnalysisJob: Failed to push notification", [
|
||||
'report_id' => $this->reportId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
25
app/Models/LogAnalysisReport.php
Normal file
25
app/Models/LogAnalysisReport.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class LogAnalysisReport extends BaseModel
|
||||
{
|
||||
protected $fillable = [
|
||||
'from_time',
|
||||
'to_time',
|
||||
'query',
|
||||
'mode',
|
||||
'total_logs',
|
||||
'results',
|
||||
'metadata',
|
||||
'status',
|
||||
'error_message',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'from_time' => 'datetime',
|
||||
'to_time' => 'datetime',
|
||||
'results' => 'array',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
}
|
||||
@@ -2,6 +2,19 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Clients\AgentClient;
|
||||
use App\Clients\AiClient;
|
||||
use App\Clients\MonoClient;
|
||||
use App\Clients\SlsClient;
|
||||
use App\Services\AiService;
|
||||
use App\Services\CodeContextService;
|
||||
use App\Services\ConfigService;
|
||||
use App\Services\DingTalkService;
|
||||
use App\Services\EnvService;
|
||||
use App\Services\GitMonitorService;
|
||||
use App\Services\JiraService;
|
||||
use App\Services\LogAnalysisService;
|
||||
use App\Services\SlsService;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@@ -11,11 +24,22 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
// 注册应用服务
|
||||
$this->app->singleton(\App\Services\JiraService::class);
|
||||
$this->app->singleton(\App\Services\ConfigService::class);
|
||||
$this->app->singleton(\App\Services\DingTalkService::class);
|
||||
$this->app->singleton(\App\Services\GitMonitorService::class);
|
||||
// 注册 Clients
|
||||
$this->app->singleton(AgentClient::class);
|
||||
$this->app->singleton(MonoClient::class);
|
||||
$this->app->singleton(SlsClient::class);
|
||||
$this->app->singleton(AiClient::class, fn ($app) => new AiClient($app->make(ConfigService::class)));
|
||||
|
||||
// 注册 Services
|
||||
$this->app->singleton(ConfigService::class);
|
||||
$this->app->singleton(JiraService::class);
|
||||
$this->app->singleton(DingTalkService::class);
|
||||
$this->app->singleton(EnvService::class);
|
||||
$this->app->singleton(GitMonitorService::class);
|
||||
$this->app->singleton(SlsService::class);
|
||||
$this->app->singleton(AiService::class);
|
||||
$this->app->singleton(CodeContextService::class);
|
||||
$this->app->singleton(LogAnalysisService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Clients\AgentClient;
|
||||
use App\Clients\MonoClient;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class ClientServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(AgentClient::class, function () {
|
||||
return new AgentClient();
|
||||
});
|
||||
|
||||
$this->app->singleton(MonoClient::class, function () {
|
||||
return new MonoClient();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use App\Console\Commands\EnvCommand;
|
||||
|
||||
class EnvServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* 注册服务
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
// 注册环境管理服务
|
||||
$this->app->singleton(\App\Services\EnvService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动服务
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
// 注册Artisan命令
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->commands([
|
||||
EnvCommand::class,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Console\Commands\GitMonitorCacheCommand;
|
||||
use App\Console\Commands\GitMonitorCheckCommand;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class GitMonitorServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->commands([
|
||||
GitMonitorCheckCommand::class,
|
||||
GitMonitorCacheCommand::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
126
app/Services/AiService.php
Normal file
126
app/Services/AiService.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Clients\AiClient;
|
||||
|
||||
class AiService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AiClient $client,
|
||||
private readonly ConfigService $configService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 检查 AI 服务是否已配置
|
||||
*/
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->client->isConfigured();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有 AI 提供商配置
|
||||
*/
|
||||
public function getProviders(): array
|
||||
{
|
||||
return $this->client->getProviders();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前激活的提供商
|
||||
*/
|
||||
public function getActiveProvider(): ?array
|
||||
{
|
||||
return $this->client->getActiveProvider();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置激活的提供商
|
||||
*/
|
||||
public function setActiveProvider(string $providerKey): void
|
||||
{
|
||||
$this->client->setActiveProvider($providerKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 AI 提供商配置
|
||||
*
|
||||
* @param array $providers 提供商配置数组
|
||||
*/
|
||||
public function saveProviders(array $providers): void
|
||||
{
|
||||
// 验证配置格式
|
||||
foreach ($providers as $key => $provider) {
|
||||
if (empty($provider['endpoint']) || empty($provider['api_key']) || empty($provider['model'])) {
|
||||
throw new \InvalidArgumentException("提供商 {$key} 配置不完整");
|
||||
}
|
||||
}
|
||||
|
||||
$this->configService->set(
|
||||
'log_analysis.ai_providers',
|
||||
$providers,
|
||||
'AI 服务提供商配置'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加或更新单个提供商
|
||||
*/
|
||||
public function saveProvider(string $key, array $config): void
|
||||
{
|
||||
$providers = $this->getProviders();
|
||||
$providers[$key] = array_merge($providers[$key] ?? [], $config);
|
||||
$this->saveProviders($providers);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除提供商
|
||||
*/
|
||||
public function deleteProvider(string $key): void
|
||||
{
|
||||
$providers = $this->getProviders();
|
||||
unset($providers[$key]);
|
||||
$this->saveProviders($providers);
|
||||
|
||||
// 如果删除的是当前激活的,清除激活状态
|
||||
$activeKey = $this->configService->get('log_analysis.active_ai_provider');
|
||||
if ($activeKey === $key) {
|
||||
$this->configService->set('log_analysis.active_ai_provider', null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析日志
|
||||
*
|
||||
* @param string $logsContent 日志内容
|
||||
* @param string|null $codeContext 代码上下文
|
||||
* @return array 分析结果
|
||||
*/
|
||||
public function analyzeLogs(string $logsContent, ?string $codeContext = null): array
|
||||
{
|
||||
return $this->client->analyzeLogs($logsContent, $codeContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义提示词分析
|
||||
*
|
||||
* @param string $content 要分析的内容
|
||||
* @param string $prompt 自定义提示词
|
||||
* @return string AI 响应
|
||||
*/
|
||||
public function analyze(string $content, string $prompt): string
|
||||
{
|
||||
return $this->client->chat([
|
||||
['role' => 'user', 'content' => $prompt . "\n\n" . $content],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试连接
|
||||
*/
|
||||
public function testConnection(): array
|
||||
{
|
||||
return $this->client->testConnection();
|
||||
}
|
||||
}
|
||||
267
app/Services/CodeContextService.php
Normal file
267
app/Services/CodeContextService.php
Normal file
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class CodeContextService
|
||||
{
|
||||
private int $contextLines = 10;
|
||||
|
||||
public function __construct(
|
||||
private readonly ConfigService $configService,
|
||||
private readonly EnvService $envService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 根据 app_name 获取代码仓库路径
|
||||
*
|
||||
* @param string $appName
|
||||
* @return string|null
|
||||
*/
|
||||
public function getRepoPath(string $appName): ?string
|
||||
{
|
||||
$appEnvMap = $this->configService->get('log_analysis.app_env_map', []);
|
||||
|
||||
if (!isset($appEnvMap[$appName])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mapping = $appEnvMap[$appName];
|
||||
$project = $mapping['project'] ?? null;
|
||||
$env = $mapping['env'] ?? null;
|
||||
|
||||
if (!$project || !$env) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$envContent = $this->envService->getEnvContent($project, $env);
|
||||
$repoPath = $this->parseEnvValue($envContent, 'LOG_ANALYSIS_CODE_REPO_PATH');
|
||||
|
||||
if ($repoPath && is_dir($repoPath)) {
|
||||
return $repoPath;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 忽略错误,返回 null
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从日志中提取相关代码片段
|
||||
*
|
||||
* @param string $repoPath 代码仓库路径
|
||||
* @param Collection $logs 日志集合
|
||||
* @return string|null 代码上下文
|
||||
*/
|
||||
public function extractRelevantCode(string $repoPath, Collection $logs): ?string
|
||||
{
|
||||
$codeSnippets = [];
|
||||
$processedFiles = [];
|
||||
|
||||
foreach ($logs as $log) {
|
||||
$file = $log['file'] ?? null;
|
||||
$line = $log['line'] ?? null;
|
||||
|
||||
if (!$file || !$line) {
|
||||
// 尝试从 trace 中提取
|
||||
$extracted = $this->extractFileLineFromTrace($log['trace'] ?? '');
|
||||
if ($extracted) {
|
||||
$file = $extracted['file'];
|
||||
$line = $extracted['line'];
|
||||
}
|
||||
}
|
||||
|
||||
if (!$file || !$line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 构建完整路径
|
||||
$fullPath = $this->resolveFilePath($repoPath, $file);
|
||||
if (!$fullPath || !File::exists($fullPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 避免重复处理同一文件的同一位置
|
||||
$key = "{$fullPath}:{$line}";
|
||||
if (isset($processedFiles[$key])) {
|
||||
continue;
|
||||
}
|
||||
$processedFiles[$key] = true;
|
||||
|
||||
// 提取代码片段
|
||||
$snippet = $this->extractCodeSnippet($fullPath, (int) $line);
|
||||
if ($snippet) {
|
||||
$codeSnippets[] = $snippet;
|
||||
}
|
||||
|
||||
// 限制代码片段数量
|
||||
if (count($codeSnippets) >= 5) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($codeSnippets)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return implode("\n\n---\n\n", $codeSnippets);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取指定文件指定行附近的代码
|
||||
*
|
||||
* @param string $filePath 文件路径
|
||||
* @param int $line 行号
|
||||
* @return string|null
|
||||
*/
|
||||
public function extractCodeSnippet(string $filePath, int $line): ?string
|
||||
{
|
||||
if (!File::exists($filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = File::get($filePath);
|
||||
$lines = explode("\n", $content);
|
||||
$totalLines = count($lines);
|
||||
|
||||
$startLine = max(1, $line - $this->contextLines);
|
||||
$endLine = min($totalLines, $line + $this->contextLines);
|
||||
|
||||
$snippet = [];
|
||||
$snippet[] = "// File: " . basename($filePath);
|
||||
$snippet[] = "// Lines: {$startLine}-{$endLine} (target: {$line})";
|
||||
$snippet[] = "";
|
||||
|
||||
for ($i = $startLine - 1; $i < $endLine; $i++) {
|
||||
$lineNum = $i + 1;
|
||||
$marker = ($lineNum === $line) ? '>>>' : ' ';
|
||||
$snippet[] = sprintf("%s %4d | %s", $marker, $lineNum, $lines[$i] ?? '');
|
||||
}
|
||||
|
||||
return implode("\n", $snippet);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从堆栈跟踪中提取文件和行号
|
||||
*
|
||||
* @param string $trace
|
||||
* @return array|null ['file' => string, 'line' => int]
|
||||
*/
|
||||
private function extractFileLineFromTrace(string $trace): ?array
|
||||
{
|
||||
// 匹配常见的堆栈跟踪格式
|
||||
// PHP: at /path/to/file.php:123
|
||||
// PHP: #0 /path/to/file.php(123): function()
|
||||
// Java: at com.example.Class.method(File.java:123)
|
||||
|
||||
$patterns = [
|
||||
'/at\s+([^\s:]+):(\d+)/',
|
||||
'/#\d+\s+([^\s(]+)\((\d+)\)/',
|
||||
'/([^\s:]+\.php):(\d+)/',
|
||||
'/\(([^:]+):(\d+)\)/',
|
||||
];
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
if (preg_match($pattern, $trace, $matches)) {
|
||||
return [
|
||||
'file' => $matches[1],
|
||||
'line' => (int) $matches[2],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析文件路径
|
||||
*
|
||||
* @param string $repoPath 仓库根路径
|
||||
* @param string $file 文件路径(可能是相对路径或绝对路径)
|
||||
* @return string|null
|
||||
*/
|
||||
private function resolveFilePath(string $repoPath, string $file): ?string
|
||||
{
|
||||
// 如果是绝对路径且存在
|
||||
if (str_starts_with($file, '/') && File::exists($file)) {
|
||||
return $file;
|
||||
}
|
||||
|
||||
// 尝试作为相对路径
|
||||
$fullPath = rtrim($repoPath, '/') . '/' . ltrim($file, '/');
|
||||
if (File::exists($fullPath)) {
|
||||
return $fullPath;
|
||||
}
|
||||
|
||||
// 尝试在仓库中搜索文件名
|
||||
$filename = basename($file);
|
||||
$found = $this->findFileInRepo($repoPath, $filename);
|
||||
|
||||
return $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在仓库中搜索文件
|
||||
*
|
||||
* @param string $repoPath
|
||||
* @param string $filename
|
||||
* @return string|null
|
||||
*/
|
||||
private function findFileInRepo(string $repoPath, string $filename): ?string
|
||||
{
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($repoPath, \RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->isFile() && $file->getFilename() === $filename) {
|
||||
return $file->getPathname();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 .env 内容中解析指定键的值
|
||||
*
|
||||
* @param string $envContent
|
||||
* @param string $key
|
||||
* @return string|null
|
||||
*/
|
||||
private function parseEnvValue(string $envContent, string $key): ?string
|
||||
{
|
||||
$lines = explode("\n", $envContent);
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
|
||||
// 跳过注释和空行
|
||||
if (empty($line) || str_starts_with($line, '#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_starts_with($line, "{$key}=")) {
|
||||
$value = substr($line, strlen($key) + 1);
|
||||
// 移除引号
|
||||
$value = trim($value, '"\'');
|
||||
return $value ?: null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置上下文行数
|
||||
*/
|
||||
public function setContextLines(int $lines): void
|
||||
{
|
||||
$this->contextLines = $lines;
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,11 @@ class DingTalkService
|
||||
public function sendText(string $message, array $atMobiles = [], bool $atAll = false): void
|
||||
{
|
||||
if (empty($this->webhook)) {
|
||||
Log::warning('DingTalk webhook is not configured, skip sending alert.');
|
||||
Log::warning('DingTalk webhook is not configured, skip sending alert. Alert content logged below.', [
|
||||
'message' => $message,
|
||||
'atMobiles' => $atMobiles,
|
||||
'atAll' => $atAll,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -66,12 +66,17 @@ class GitMonitorService
|
||||
}
|
||||
|
||||
try {
|
||||
$version = $this->jiraService->getUpcomingReleaseVersion($projectKey);
|
||||
// 先从 master 分支获取当前版本号
|
||||
$currentVersion = $this->getMasterVersion($repoKey, $repoConfig);
|
||||
|
||||
// 根据当前版本号获取下一个版本
|
||||
$version = $this->jiraService->getUpcomingReleaseVersion($projectKey, $currentVersion);
|
||||
if ($version) {
|
||||
$payload['repositories'][$repoKey] = [
|
||||
'version' => $version['version'],
|
||||
'release_date' => $version['release_date'],
|
||||
'branch' => 'release/' . $version['version'],
|
||||
'current_version' => $currentVersion,
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
@@ -181,10 +186,16 @@ class GitMonitorService
|
||||
];
|
||||
|
||||
foreach ($commits as $commit) {
|
||||
if ($this->isDevelopMerge($path, $commit)) {
|
||||
$isMerge = $this->isMergeCommit($path, $commit);
|
||||
$isConflictResolution = $this->isConflictResolution($path, $commit);
|
||||
|
||||
// 只检测直接从 develop 合并的情况
|
||||
if ($isMerge && $this->isDevelopMerge($path, $commit)) {
|
||||
$issues['develop_merges'][] = $this->getCommitMetadata($path, $commit);
|
||||
}
|
||||
|
||||
// 只在 merge 提交或冲突解决提交中检测缺失函数
|
||||
if ($isMerge || $isConflictResolution) {
|
||||
$missingFunctions = $this->detectMissingFunctions($path, $commit);
|
||||
if (!empty($missingFunctions)) {
|
||||
$issues['missing_functions'][] = [
|
||||
@@ -193,6 +204,7 @@ class GitMonitorService
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->updateLastChecked($repoKey, $head);
|
||||
|
||||
@@ -239,35 +251,53 @@ class GitMonitorService
|
||||
return array_values(array_filter(array_map('trim', explode("\n", $output))));
|
||||
}
|
||||
|
||||
private function isDevelopMerge(string $repoPath, string $commit): bool
|
||||
/**
|
||||
* 判断是否为 merge 提交(有多个父提交)
|
||||
*/
|
||||
private function isMergeCommit(string $repoPath, string $commit): bool
|
||||
{
|
||||
$parents = trim($this->runGit($repoPath, ['git', 'show', '-s', '--pretty=%P', $commit]));
|
||||
$parentShas = array_values(array_filter(explode(' ', $parents)));
|
||||
|
||||
if (count($parentShas) < 2) {
|
||||
return count($parentShas) >= 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为冲突解决提交
|
||||
* 通过检查 commit message 是否包含冲突相关关键词
|
||||
*/
|
||||
private function isConflictResolution(string $repoPath, string $commit): bool
|
||||
{
|
||||
$message = strtolower($this->runGit($repoPath, ['git', 'show', '-s', '--pretty=%s', $commit]));
|
||||
|
||||
$conflictKeywords = ['conflict', 'resolve', '冲突', '解决冲突'];
|
||||
foreach ($conflictKeywords as $keyword) {
|
||||
if (str_contains($message, $keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为直接从 develop 分支合并到 release 的提交
|
||||
* 只检测 commit message 明确包含 "merge.*develop" 或 "develop.*into" 的情况
|
||||
*/
|
||||
private function isDevelopMerge(string $repoPath, string $commit): bool
|
||||
{
|
||||
$message = strtolower($this->runGit($repoPath, ['git', 'show', '-s', '--pretty=%s', $commit]));
|
||||
if (str_contains($message, self::DEVELOP_BRANCH)) {
|
||||
|
||||
// 检测 "Merge branch 'develop'" 或 "merge develop into" 等模式
|
||||
// 排除 feature/xxx、hotfix/xxx 等分支的合并
|
||||
if (preg_match("/merge\s+(branch\s+)?['\"]?develop['\"]?(\s+into)?/i", $message)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$target = 'origin/' . self::DEVELOP_BRANCH;
|
||||
|
||||
foreach ($parentShas as $parent) {
|
||||
$branches = $this->runGit($repoPath, ['git', 'branch', '-r', '--contains', $parent]);
|
||||
foreach (preg_split('/\R/', $branches) as $branchLine) {
|
||||
$branchLine = trim(str_replace('*', '', $branchLine));
|
||||
if (empty($branchLine)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($branchLine === $target || str_ends_with($branchLine, '/' . self::DEVELOP_BRANCH)) {
|
||||
// 检测 "develop into release" 模式
|
||||
if (preg_match("/develop\s+into\s+['\"]?release/i", $message)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -331,11 +361,15 @@ class GitMonitorService
|
||||
private function extractFunctionName(string $line): ?string
|
||||
{
|
||||
$trimmed = trim($line);
|
||||
if (str_starts_with($trimmed, '//') || str_starts_with($trimmed, '#') || str_starts_with($trimmed, '*')) {
|
||||
|
||||
// 跳过注释行
|
||||
if (str_starts_with($trimmed, '//') || str_starts_with($trimmed, '#') || str_starts_with($trimmed, '*') || str_starts_with($trimmed, '/*')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/function\s+([A-Za-z0-9_]+)\s*\(/', $trimmed, $matches)) {
|
||||
// 只匹配真正的函数定义,必须以 function 关键字开头,或者以访问修饰符开头
|
||||
// 匹配: function foo(, public function foo(, private static function foo( 等
|
||||
if (preg_match('/^(?:(?:public|protected|private)\s+)?(?:static\s+)?function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/', $trimmed, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
@@ -495,4 +529,29 @@ class GitMonitorService
|
||||
$directory = $repoConfig['directory'] ?? $repoKey;
|
||||
return $this->projectsPath . '/' . ltrim($directory, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 master 分支读取 version.txt 获取当前版本号
|
||||
*/
|
||||
public function getMasterVersion(string $repoKey, array $repoConfig): ?string
|
||||
{
|
||||
$path = $this->resolveProjectPath($repoKey, $repoConfig);
|
||||
|
||||
if (!is_dir($path) || !is_dir($path . DIRECTORY_SEPARATOR . '.git')) {
|
||||
Log::warning('Invalid git repository path', ['repository' => $repoKey, 'path' => $path]);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->runGit($path, ['git', 'fetch', 'origin', 'master']);
|
||||
$version = $this->runGit($path, ['git', 'show', 'origin/master:version.txt']);
|
||||
return trim($version) ?: null;
|
||||
} catch (ProcessFailedException $e) {
|
||||
Log::warning('Failed to read version.txt from master branch', [
|
||||
'repository' => $repoKey,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -758,9 +758,13 @@ class JiraService
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近的 release 版本
|
||||
* 获取下一个 release 版本
|
||||
* 根据当前版本号,在 Jira 版本列表中找到下一个版本
|
||||
*
|
||||
* @param string $projectKey Jira 项目 key
|
||||
* @param string|null $currentVersion 当前版本号(来自 master 分支的 version.txt)
|
||||
*/
|
||||
public function getUpcomingReleaseVersion(string $projectKey): ?array
|
||||
public function getUpcomingReleaseVersion(string $projectKey, ?string $currentVersion = null): ?array
|
||||
{
|
||||
try {
|
||||
$versions = $this->projectService->getVersions($projectKey);
|
||||
@@ -772,22 +776,47 @@ class JiraService
|
||||
return null;
|
||||
}
|
||||
|
||||
$now = Carbon::now()->startOfDay();
|
||||
// 按版本名称排序(假设版本号格式一致,如 1.0.0, 1.0.1, 1.1.0)
|
||||
$sortedVersions = collect($versions)
|
||||
->filter(fn($version) => !empty($version->name))
|
||||
->sortBy(fn($version) => $version->name, SORT_NATURAL)
|
||||
->values();
|
||||
|
||||
$candidate = collect($versions)
|
||||
->filter(function ($version) use ($now) {
|
||||
if (($version->released ?? false) || empty($version->releaseDate)) {
|
||||
return false;
|
||||
if ($sortedVersions->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::parse($version->releaseDate)->greaterThanOrEqualTo($now);
|
||||
} catch (\Throwable) {
|
||||
// 如果没有提供当前版本,返回第一个未发布的版本
|
||||
if (empty($currentVersion)) {
|
||||
$candidate = $sortedVersions
|
||||
->filter(fn($version) => !($version->released ?? false))
|
||||
->first();
|
||||
|
||||
if (!$candidate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'version' => $candidate->name,
|
||||
'release_date' => !empty($candidate->releaseDate)
|
||||
? Carbon::parse($candidate->releaseDate)->toDateString()
|
||||
: null,
|
||||
];
|
||||
}
|
||||
|
||||
// 找到当前版本在列表中的位置,返回下一个版本
|
||||
$currentIndex = $sortedVersions->search(
|
||||
fn($version) => $version->name === $currentVersion
|
||||
);
|
||||
|
||||
// 如果找不到当前版本,尝试找到第一个大于当前版本的未发布版本
|
||||
if ($currentIndex === false) {
|
||||
$candidate = $sortedVersions
|
||||
->filter(function ($version) use ($currentVersion) {
|
||||
if ($version->released ?? false) {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
->sortBy(function ($version) {
|
||||
return Carbon::parse($version->releaseDate);
|
||||
return version_compare($version->name, $currentVersion, '>');
|
||||
})
|
||||
->first();
|
||||
|
||||
@@ -797,7 +826,27 @@ class JiraService
|
||||
|
||||
return [
|
||||
'version' => $candidate->name,
|
||||
'release_date' => Carbon::parse($candidate->releaseDate)->toDateString(),
|
||||
'release_date' => !empty($candidate->releaseDate)
|
||||
? Carbon::parse($candidate->releaseDate)->toDateString()
|
||||
: null,
|
||||
];
|
||||
}
|
||||
|
||||
// 从当前版本的下一个开始,找到第一个未发布的版本
|
||||
$candidate = $sortedVersions
|
||||
->slice($currentIndex + 1)
|
||||
->filter(fn($version) => !($version->released ?? false))
|
||||
->first();
|
||||
|
||||
if (!$candidate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'version' => $candidate->name,
|
||||
'release_date' => !empty($candidate->releaseDate)
|
||||
? Carbon::parse($candidate->releaseDate)->toDateString()
|
||||
: null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
437
app/Services/LogAnalysisService.php
Normal file
437
app/Services/LogAnalysisService.php
Normal file
@@ -0,0 +1,437 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\AnalysisMode;
|
||||
use App\Jobs\LogAnalysisJob;
|
||||
use App\Models\LogAnalysisReport;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class LogAnalysisService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SlsService $slsService,
|
||||
private readonly AiService $aiService,
|
||||
private readonly CodeContextService $codeContextService,
|
||||
private readonly ConfigService $configService,
|
||||
private readonly DingTalkService $dingTalkService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 异步执行日志分析(创建后台任务)
|
||||
*
|
||||
* @param Carbon $from 开始时间
|
||||
* @param Carbon $to 结束时间
|
||||
* @param string|null $query SLS 查询语句
|
||||
* @param AnalysisMode $mode 分析模式
|
||||
* @param bool $pushNotification 是否推送通知
|
||||
* @return LogAnalysisReport 创建的报告(状态为 pending)
|
||||
*/
|
||||
public function analyzeAsync(
|
||||
Carbon $from,
|
||||
Carbon $to,
|
||||
?string $query = null,
|
||||
AnalysisMode $mode = AnalysisMode::Logs,
|
||||
bool $pushNotification = false
|
||||
): LogAnalysisReport {
|
||||
// 如果没有指定查询条件,默认只获取 ERROR 和 WARNING 级别的日志
|
||||
$effectiveQuery = $query;
|
||||
if (empty($query)) {
|
||||
$effectiveQuery = 'ERROR or WARNING';
|
||||
}
|
||||
|
||||
// 创建 pending 状态的报告
|
||||
$report = LogAnalysisReport::create([
|
||||
'from_time' => $from,
|
||||
'to_time' => $to,
|
||||
'query' => $effectiveQuery,
|
||||
'mode' => $mode->value,
|
||||
'total_logs' => 0,
|
||||
'results' => [],
|
||||
'metadata' => [],
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
// 分发后台任务
|
||||
LogAnalysisJob::dispatch(
|
||||
$report->id,
|
||||
$from,
|
||||
$to,
|
||||
$effectiveQuery,
|
||||
$mode,
|
||||
$pushNotification
|
||||
);
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行日志分析
|
||||
*
|
||||
* @param Carbon $from 开始时间
|
||||
* @param Carbon $to 结束时间
|
||||
* @param string|null $query SLS 查询语句
|
||||
* @param AnalysisMode $mode 分析模式
|
||||
* @param bool $saveReport 是否保存报告
|
||||
* @return array 分析结果
|
||||
*/
|
||||
public function analyze(
|
||||
Carbon $from,
|
||||
Carbon $to,
|
||||
?string $query = null,
|
||||
AnalysisMode $mode = AnalysisMode::Logs,
|
||||
bool $saveReport = true
|
||||
): array {
|
||||
$startTime = microtime(true);
|
||||
|
||||
// 1. 获取日志
|
||||
$logs = $this->slsService->fetchLogs($from, $to, $query);
|
||||
|
||||
if ($logs->isEmpty()) {
|
||||
return $this->buildEmptyResult($from, $to, $query, $mode);
|
||||
}
|
||||
|
||||
// 2. 按 app_name 分组
|
||||
$grouped = $this->slsService->groupByAppName($logs);
|
||||
|
||||
// 3. 分析每个分组
|
||||
$results = [];
|
||||
$settings = $this->configService->get('log_analysis.settings', []);
|
||||
$maxLogsPerApp = $settings['max_logs_per_app'] ?? 500;
|
||||
|
||||
foreach ($grouped as $appName => $appLogs) {
|
||||
$appLogsCollection = collect($appLogs);
|
||||
|
||||
// 限制每个 app 的日志数量
|
||||
if ($appLogsCollection->count() > $maxLogsPerApp) {
|
||||
$appLogsCollection = $appLogsCollection->take($maxLogsPerApp);
|
||||
}
|
||||
|
||||
// 获取代码上下文(如果需要)
|
||||
$codeContext = null;
|
||||
if ($mode === AnalysisMode::LogsWithCode) {
|
||||
$repoPath = $this->codeContextService->getRepoPath($appName);
|
||||
if ($repoPath) {
|
||||
$codeContext = $this->codeContextService->extractRelevantCode($repoPath, $appLogsCollection);
|
||||
}
|
||||
}
|
||||
|
||||
// 准备日志内容
|
||||
$logsContent = $this->formatLogsForAnalysis($appLogsCollection);
|
||||
|
||||
// AI 分析
|
||||
try {
|
||||
$results[$appName] = $this->aiService->analyzeLogs($logsContent, $codeContext);
|
||||
$results[$appName]['log_count'] = $appLogsCollection->count();
|
||||
$results[$appName]['has_code_context'] = $codeContext !== null;
|
||||
} catch (\Exception $e) {
|
||||
$results[$appName] = [
|
||||
'error' => $e->getMessage(),
|
||||
'log_count' => $appLogsCollection->count(),
|
||||
'has_code_context' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$executionTime = (microtime(true) - $startTime) * 1000;
|
||||
|
||||
// 4. 构建结果
|
||||
$result = [
|
||||
'request' => [
|
||||
'from' => $from->format('Y-m-d H:i:s'),
|
||||
'to' => $to->format('Y-m-d H:i:s'),
|
||||
'query' => $query,
|
||||
'mode' => $mode->value,
|
||||
],
|
||||
'results' => $results,
|
||||
'metadata' => [
|
||||
'total_logs' => $logs->count(),
|
||||
'apps_analyzed' => count($results),
|
||||
'execution_time_ms' => round($executionTime),
|
||||
'analyzed_at' => Carbon::now()->format('Y-m-d H:i:s'),
|
||||
],
|
||||
];
|
||||
|
||||
// 5. 保存报告
|
||||
if ($saveReport) {
|
||||
$this->saveReport($result, $from, $to, $query, $mode, $logs->count());
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅查询日志(不进行 AI 分析)
|
||||
*
|
||||
* @param Carbon $from
|
||||
* @param Carbon $to
|
||||
* @param string|null $query
|
||||
* @param int|null $limit
|
||||
* @return array
|
||||
*/
|
||||
public function queryLogs(
|
||||
Carbon $from,
|
||||
Carbon $to,
|
||||
?string $query = null,
|
||||
?int $limit = null
|
||||
): array {
|
||||
$logs = $this->slsService->fetchLogs($from, $to, $query, $limit);
|
||||
$grouped = $this->slsService->groupByAppName($logs);
|
||||
$statistics = $this->slsService->getStatistics($logs);
|
||||
|
||||
return [
|
||||
'total' => $logs->count(),
|
||||
'statistics' => $statistics,
|
||||
'grouped' => array_map(fn($g) => count($g), $grouped),
|
||||
'logs' => $logs->take(100)->values()->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 从多个 logstore 查询日志(不进行 AI 分析)
|
||||
*
|
||||
* @param Carbon $from
|
||||
* @param Carbon $to
|
||||
* @param string|null $query
|
||||
* @param int|null $limit
|
||||
* @param array|null $logstores
|
||||
* @return array
|
||||
*/
|
||||
public function queryLogsFromMultipleStores(
|
||||
Carbon $from,
|
||||
Carbon $to,
|
||||
?string $query = null,
|
||||
?int $limit = null,
|
||||
?array $logstores = null
|
||||
): array {
|
||||
$logstoreData = $this->slsService->fetchLogsFromMultipleStores($from, $to, $query, $limit, $logstores);
|
||||
$statistics = $this->slsService->getMultiStoreStatistics($logstoreData);
|
||||
|
||||
// 合并所有 logstore 的日志用于预览
|
||||
$allLogs = collect();
|
||||
$groupedByLogstore = [];
|
||||
|
||||
foreach ($logstoreData as $logstore => $data) {
|
||||
if ($data['success']) {
|
||||
$allLogs = $allLogs->merge($data['logs']);
|
||||
$groupedByLogstore[$logstore] = [
|
||||
'count' => $data['count'],
|
||||
'success' => true,
|
||||
];
|
||||
} else {
|
||||
$groupedByLogstore[$logstore] = [
|
||||
'count' => 0,
|
||||
'success' => false,
|
||||
'error' => $data['error'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => $statistics['total'],
|
||||
'statistics' => $statistics,
|
||||
'grouped_by_logstore' => $groupedByLogstore,
|
||||
'logs' => $allLogs->take(100)->values()->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取历史报告列表
|
||||
*
|
||||
* @param int $limit
|
||||
* @param int $offset
|
||||
* @return array
|
||||
*/
|
||||
public function getReports(int $limit = 20, int $offset = 0): array
|
||||
{
|
||||
$query = LogAnalysisReport::query()
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
$total = $query->count();
|
||||
$reports = $query->skip($offset)->take($limit)->get();
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'reports' => $reports->map(function ($report) {
|
||||
return [
|
||||
'id' => $report->id,
|
||||
'from_time' => $report->from_time->format('Y-m-d H:i:s'),
|
||||
'to_time' => $report->to_time->format('Y-m-d H:i:s'),
|
||||
'query' => $report->query,
|
||||
'mode' => $report->mode,
|
||||
'total_logs' => $report->total_logs,
|
||||
'status' => $report->status,
|
||||
'created_at' => $report->created_at->format('Y-m-d H:i:s'),
|
||||
];
|
||||
})->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个报告详情
|
||||
*
|
||||
* @param int $id
|
||||
* @return array|null
|
||||
*/
|
||||
public function getReport(int $id): ?array
|
||||
{
|
||||
$report = LogAnalysisReport::find($id);
|
||||
|
||||
if (!$report) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $report->id,
|
||||
'request' => [
|
||||
'from' => $report->from_time->format('Y-m-d H:i:s'),
|
||||
'to' => $report->to_time->format('Y-m-d H:i:s'),
|
||||
'query' => $report->query,
|
||||
'mode' => $report->mode,
|
||||
],
|
||||
'results' => $report->results,
|
||||
'metadata' => $report->metadata,
|
||||
'status' => $report->status,
|
||||
'error_message' => $report->error_message,
|
||||
'created_at' => $report->created_at->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送分析结果到钉钉
|
||||
*
|
||||
* @param array $result 分析结果
|
||||
* @return bool
|
||||
*/
|
||||
public function pushToNotification(array $result): bool
|
||||
{
|
||||
$message = $this->formatNotificationMessage($result);
|
||||
|
||||
try {
|
||||
$this->dingTalkService->sendText($message);
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日志用于 AI 分析
|
||||
*/
|
||||
private function formatLogsForAnalysis(Collection $logs): string
|
||||
{
|
||||
$formatted = [];
|
||||
|
||||
foreach ($logs as $log) {
|
||||
$line = sprintf(
|
||||
"[%s] [%s] %s",
|
||||
$log['time'] ?? 'N/A',
|
||||
$log['level'] ?? 'N/A',
|
||||
$log['message'] ?? json_encode($log['_raw'] ?? $log)
|
||||
);
|
||||
|
||||
if (!empty($log['trace'])) {
|
||||
$line .= "\n" . $log['trace'];
|
||||
}
|
||||
|
||||
$formatted[] = $line;
|
||||
}
|
||||
|
||||
return implode("\n\n", $formatted);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化通知消息
|
||||
*/
|
||||
private function formatNotificationMessage(array $result): string
|
||||
{
|
||||
$lines = [];
|
||||
$lines[] = "📊 SLS 日志分析报告";
|
||||
$lines[] = "时间范围: {$result['request']['from']} ~ {$result['request']['to']}";
|
||||
$lines[] = "总日志数: {$result['metadata']['total_logs']}";
|
||||
$lines[] = "";
|
||||
|
||||
foreach ($result['results'] as $appName => $appResult) {
|
||||
$lines[] = "【{$appName}】";
|
||||
|
||||
if (isset($appResult['error'])) {
|
||||
$lines[] = " 分析失败: {$appResult['error']}";
|
||||
continue;
|
||||
}
|
||||
|
||||
$impact = $appResult['impact'] ?? 'unknown';
|
||||
$impactEmoji = match ($impact) {
|
||||
'high' => '🔴',
|
||||
'medium' => '🟡',
|
||||
'low' => '🟢',
|
||||
default => '⚪',
|
||||
};
|
||||
|
||||
$lines[] = " 影响级别: {$impactEmoji} {$impact}";
|
||||
$lines[] = " 摘要: " . ($appResult['summary'] ?? 'N/A');
|
||||
|
||||
$anomalies = $appResult['core_anomalies'] ?? [];
|
||||
if (!empty($anomalies)) {
|
||||
$lines[] = " 异常数: " . count($anomalies);
|
||||
foreach (array_slice($anomalies, 0, 3) as $anomaly) {
|
||||
$lines[] = " - [{$anomaly['classification']}] {$anomaly['possible_cause']}";
|
||||
}
|
||||
}
|
||||
|
||||
$lines[] = "";
|
||||
}
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存分析报告
|
||||
*/
|
||||
private function saveReport(
|
||||
array $result,
|
||||
Carbon $from,
|
||||
Carbon $to,
|
||||
?string $query,
|
||||
AnalysisMode $mode,
|
||||
int $totalLogs
|
||||
): LogAnalysisReport {
|
||||
return LogAnalysisReport::create([
|
||||
'from_time' => $from,
|
||||
'to_time' => $to,
|
||||
'query' => $query,
|
||||
'mode' => $mode->value,
|
||||
'total_logs' => $totalLogs,
|
||||
'results' => $result['results'],
|
||||
'metadata' => $result['metadata'],
|
||||
'status' => 'completed',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建空结果
|
||||
*/
|
||||
private function buildEmptyResult(
|
||||
Carbon $from,
|
||||
Carbon $to,
|
||||
?string $query,
|
||||
AnalysisMode $mode
|
||||
): array {
|
||||
return [
|
||||
'request' => [
|
||||
'from' => $from->format('Y-m-d H:i:s'),
|
||||
'to' => $to->format('Y-m-d H:i:s'),
|
||||
'query' => $query,
|
||||
'mode' => $mode->value,
|
||||
],
|
||||
'results' => [],
|
||||
'metadata' => [
|
||||
'total_logs' => 0,
|
||||
'apps_analyzed' => 0,
|
||||
'execution_time_ms' => 0,
|
||||
'analyzed_at' => Carbon::now()->format('Y-m-d H:i:s'),
|
||||
'message' => '未找到匹配的日志',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
288
app/Services/SlsService.php
Normal file
288
app/Services/SlsService.php
Normal file
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Clients\SlsClient;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class SlsService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SlsClient $client,
|
||||
private readonly ConfigService $configService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 检查 SLS 是否已配置
|
||||
*/
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->client->isConfigured();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取日志
|
||||
*
|
||||
* @param Carbon $from 开始时间
|
||||
* @param Carbon $to 结束时间
|
||||
* @param string|null $query SLS 查询语句
|
||||
* @param int|null $limit 最大返回数量
|
||||
* @return Collection
|
||||
*/
|
||||
public function fetchLogs(
|
||||
Carbon $from,
|
||||
Carbon $to,
|
||||
?string $query = null,
|
||||
?int $limit = null
|
||||
): Collection {
|
||||
$settings = $this->configService->get('log_analysis.settings', []);
|
||||
$maxLogs = $limit ?? ($settings['max_logs_per_batch'] ?? 1000);
|
||||
|
||||
$logs = $this->client->getAllLogs(
|
||||
$from->timestamp,
|
||||
$to->timestamp,
|
||||
$query,
|
||||
$maxLogs
|
||||
);
|
||||
|
||||
return collect($logs)->map(function ($log) {
|
||||
return $this->normalizeLog($log);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 从多个 logstore 获取日志
|
||||
*
|
||||
* @param Carbon $from 开始时间
|
||||
* @param Carbon $to 结束时间
|
||||
* @param string|null $query SLS 查询语句
|
||||
* @param int|null $limit 最大返回数量
|
||||
* @param array|null $logstores 要查询的 logstore 列表
|
||||
* @return array 按 logstore 分组的日志数据
|
||||
*/
|
||||
public function fetchLogsFromMultipleStores(
|
||||
Carbon $from,
|
||||
Carbon $to,
|
||||
?string $query = null,
|
||||
?int $limit = null,
|
||||
?array $logstores = null
|
||||
): array {
|
||||
$settings = $this->configService->get('log_analysis.settings', []);
|
||||
$maxLogs = $limit ?? ($settings['max_logs_per_batch'] ?? 1000);
|
||||
|
||||
$results = $this->client->getAllLogsFromMultipleStores(
|
||||
$from->timestamp,
|
||||
$to->timestamp,
|
||||
$query,
|
||||
$maxLogs,
|
||||
$logstores
|
||||
);
|
||||
|
||||
// 标准化每个 logstore 的日志
|
||||
$normalized = [];
|
||||
foreach ($results as $logstore => $data) {
|
||||
$normalized[$logstore] = [
|
||||
'logs' => collect($data['logs'])->map(function ($log) use ($logstore) {
|
||||
$normalizedLog = $this->normalizeLog($log);
|
||||
$normalizedLog['_logstore'] = $logstore;
|
||||
return $normalizedLog;
|
||||
}),
|
||||
'count' => $data['count'],
|
||||
'success' => $data['success'],
|
||||
'error' => $data['error'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置的所有 logstore
|
||||
*/
|
||||
public function getLogstores(): array
|
||||
{
|
||||
return $this->client->getLogstores();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 app_name 分组日志
|
||||
*
|
||||
* @param Collection $logs
|
||||
* @return array<string, Collection>
|
||||
*/
|
||||
public function groupByAppName(Collection $logs): array
|
||||
{
|
||||
$grouped = $logs->groupBy(function ($log) {
|
||||
return $log['app_name'] ?? $log['__source__'] ?? 'unknown';
|
||||
});
|
||||
|
||||
return $grouped->map(function ($group) {
|
||||
return $group->values();
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按日志级别过滤
|
||||
*
|
||||
* @param Collection $logs
|
||||
* @param array $levels 要保留的级别 ['ERROR', 'WARN', 'INFO']
|
||||
* @return Collection
|
||||
*/
|
||||
public function filterByLevel(Collection $logs, array $levels): Collection
|
||||
{
|
||||
$levels = array_map('strtoupper', $levels);
|
||||
|
||||
return $logs->filter(function ($log) use ($levels) {
|
||||
$level = strtoupper($log['level'] ?? $log['__level__'] ?? '');
|
||||
return in_array($level, $levels);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取日志分布直方图
|
||||
*
|
||||
* @param Carbon $from
|
||||
* @param Carbon $to
|
||||
* @param string|null $query
|
||||
* @return array
|
||||
*/
|
||||
public function getHistogram(Carbon $from, Carbon $to, ?string $query = null): array
|
||||
{
|
||||
return $this->client->getHistograms(
|
||||
$from->timestamp,
|
||||
$to->timestamp,
|
||||
$query
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取日志统计信息
|
||||
*
|
||||
* @param Collection $logs
|
||||
* @return array
|
||||
*/
|
||||
public function getStatistics(Collection $logs): array
|
||||
{
|
||||
$byLevel = $logs->groupBy(function ($log) {
|
||||
return strtoupper($log['level'] ?? $log['__level__'] ?? 'UNKNOWN');
|
||||
})->map(fn ($group) => $group->count());
|
||||
|
||||
$byApp = $logs->groupBy(function ($log) {
|
||||
return $log['app_name'] ?? $log['__source__'] ?? 'unknown';
|
||||
})->map(fn ($group) => $group->count());
|
||||
|
||||
return [
|
||||
'total' => $logs->count(),
|
||||
'by_level' => $byLevel->toArray(),
|
||||
'by_app' => $byApp->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取多 logstore 的统计信息
|
||||
*
|
||||
* @param array $logstoreData 按 logstore 分组的日志数据
|
||||
* @return array
|
||||
*/
|
||||
public function getMultiStoreStatistics(array $logstoreData): array
|
||||
{
|
||||
$totalCount = 0;
|
||||
$byLogstore = [];
|
||||
$byLevel = [];
|
||||
$byApp = [];
|
||||
|
||||
foreach ($logstoreData as $logstore => $data) {
|
||||
if (!$data['success']) {
|
||||
$byLogstore[$logstore] = [
|
||||
'count' => 0,
|
||||
'success' => false,
|
||||
'error' => $data['error'],
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
$logs = $data['logs'];
|
||||
$count = $logs->count();
|
||||
$totalCount += $count;
|
||||
|
||||
$byLogstore[$logstore] = [
|
||||
'count' => $count,
|
||||
'success' => true,
|
||||
];
|
||||
|
||||
// 按级别统计
|
||||
$logs->groupBy(function ($log) {
|
||||
return strtoupper($log['level'] ?? $log['__level__'] ?? 'UNKNOWN');
|
||||
})->each(function ($group, $level) use (&$byLevel) {
|
||||
$byLevel[$level] = ($byLevel[$level] ?? 0) + $group->count();
|
||||
});
|
||||
|
||||
// 按应用统计
|
||||
$logs->groupBy(function ($log) {
|
||||
return $log['app_name'] ?? $log['__source__'] ?? 'unknown';
|
||||
})->each(function ($group, $app) use (&$byApp) {
|
||||
$byApp[$app] = ($byApp[$app] ?? 0) + $group->count();
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => $totalCount,
|
||||
'by_logstore' => $byLogstore,
|
||||
'by_level' => $byLevel,
|
||||
'by_app' => $byApp,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试连接
|
||||
*/
|
||||
public function testConnection(): bool
|
||||
{
|
||||
return $this->client->testConnection();
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化日志格式
|
||||
*/
|
||||
private function normalizeLog(array $log): array
|
||||
{
|
||||
// 如果日志内容是 JSON 字符串(某些情况下 SLS 会将整条日志作为一个字段返回)
|
||||
// 尝试解析 content 或 message 字段
|
||||
if (isset($log['content']) && is_string($log['content']) && str_starts_with(trim($log['content']), '{')) {
|
||||
$decoded = json_decode($log['content'], true);
|
||||
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
|
||||
$log = array_merge($log, $decoded);
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试提取常见字段,优先使用业务字段,其次使用 SLS 系统字段
|
||||
$normalized = [
|
||||
'time' => $log['date'] ?? $log['time'] ?? $log['timestamp'] ?? $log['__time__'] ?? null,
|
||||
'level' => $log['level'] ?? $log['__level__'] ?? $log['severity'] ?? null,
|
||||
'message' => $log['message'] ?? $log['msg'] ?? $log['content'] ?? null,
|
||||
'app_name' => $log['app_name'] ?? $log['application'] ?? $log['service'] ?? $log['__source__'] ?? null,
|
||||
'trace' => $log['stack_trace'] ?? $log['trace'] ?? $log['exception'] ?? null,
|
||||
'file' => $log['file'] ?? $log['filename'] ?? null,
|
||||
'line' => $log['line'] ?? $log['lineno'] ?? null,
|
||||
];
|
||||
|
||||
// 格式化时间
|
||||
if (is_numeric($normalized['time'])) {
|
||||
// Unix 时间戳
|
||||
$normalized['time'] = Carbon::createFromTimestamp($normalized['time'])->format('Y-m-d H:i:s');
|
||||
} elseif (is_string($normalized['time']) && !empty($normalized['time'])) {
|
||||
// ISO 8601 格式或其他字符串格式
|
||||
try {
|
||||
$normalized['time'] = Carbon::parse($normalized['time'])->format('Y-m-d H:i:s');
|
||||
} catch (\Exception $e) {
|
||||
// 如果解析失败,保持原样
|
||||
}
|
||||
}
|
||||
|
||||
// 保留原始数据
|
||||
$normalized['_raw'] = $log;
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
@@ -31,14 +30,4 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
})
|
||||
->withSchedule(function (Schedule $schedule): void {
|
||||
$schedule->command('git-monitor:check')
|
||||
->everyTenMinutes()
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
$schedule->command('git-monitor:cache')
|
||||
->dailyAt('02:00')
|
||||
->withoutOverlapping();
|
||||
})
|
||||
->create();
|
||||
|
||||
@@ -2,7 +2,4 @@
|
||||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\ClientServiceProvider::class,
|
||||
App\Providers\EnvServiceProvider::class,
|
||||
App\Providers\GitMonitorServiceProvider::class,
|
||||
];
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"$schema": "https://getcomposer.org/schema.json",
|
||||
"name": "laravel/laravel",
|
||||
"type": "project",
|
||||
"description": "The skeleton application for the Laravel framework.",
|
||||
@@ -7,10 +6,11 @@
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"ext-pdo": "*",
|
||||
"alibabacloud/aliyun-log-php-sdk": "^0.6.8",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"lesstif/php-jira-rest-client": "5.10.0",
|
||||
"ext-pdo": "*"
|
||||
"lesstif/php-jira-rest-client": "5.10.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
|
||||
60
composer.lock
generated
60
composer.lock
generated
@@ -4,8 +4,63 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "5955fb8cae2c82cdb7352006103c8c0c",
|
||||
"content-hash": "c0be44d46402c6a66259be9824335576",
|
||||
"packages": [
|
||||
{
|
||||
"name": "alibabacloud/aliyun-log-php-sdk",
|
||||
"version": "0.6.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/alibabacloud-sdk-php/aliyun-log-php-sdk.git",
|
||||
"reference": "a4ca1baf62570bf5ce84ad74ed3778414e3f5827"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/alibabacloud-sdk-php/aliyun-log-php-sdk/zipball/a4ca1baf62570bf5ce84ad74ed3778414e3f5827",
|
||||
"reference": "a4ca1baf62570bf5ce84ad74ed3778414e3f5827",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.1.7"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^11.2"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"Log_Autoload.php"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Qi Zhou"
|
||||
},
|
||||
{
|
||||
"name": "TingTao Sun"
|
||||
},
|
||||
{
|
||||
"name": "Leiyun Ma"
|
||||
},
|
||||
{
|
||||
"name": "Xin Zhang"
|
||||
}
|
||||
],
|
||||
"description": "The Php SDK of Alibaba log service",
|
||||
"keywords": [
|
||||
"SLS",
|
||||
"alibaba",
|
||||
"sdk"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/alibabacloud-sdk-php/aliyun-log-php-sdk/tree/0.6.8"
|
||||
},
|
||||
"time": "2025-03-03T07:49:29+00:00"
|
||||
},
|
||||
{
|
||||
"name": "brick/math",
|
||||
"version": "0.13.1",
|
||||
@@ -8216,7 +8271,8 @@
|
||||
"prefer-stable": true,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
"php": "^8.2"
|
||||
"php": "^8.2",
|
||||
"ext-pdo": "*"
|
||||
},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.6.0"
|
||||
|
||||
@@ -50,4 +50,23 @@ return [
|
||||
'secret' => env('DINGTALK_SECRET'),
|
||||
],
|
||||
|
||||
'sls' => [
|
||||
'endpoint' => env('SLS_ENDPOINT'),
|
||||
'project' => env('SLS_PROJECT'),
|
||||
'logstore' => env('SLS_LOGSTORE'),
|
||||
'access_key_id' => env('SLS_ACCESS_KEY_ID'),
|
||||
'access_key_secret' => env('SLS_ACCESS_KEY_SECRET'),
|
||||
'security_token' => env('SLS_SECURITY_TOKEN', ''),
|
||||
'query_timeout' => (int) env('SLS_QUERY_TIMEOUT', 60),
|
||||
],
|
||||
|
||||
'ai' => [
|
||||
'endpoint' => env('AI_ENDPOINT', 'https://api.openai.com/v1'),
|
||||
'api_key' => env('AI_API_KEY'),
|
||||
'model' => env('AI_MODEL', 'gpt-4o'),
|
||||
'temperature' => (float) env('AI_TEMPERATURE', 0.3),
|
||||
'timeout' => (int) env('AI_TIMEOUT', 120),
|
||||
'max_tokens' => (int) env('AI_MAX_TOKENS', 4096),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('log_analysis_reports', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->timestamp('from_time');
|
||||
$table->timestamp('to_time');
|
||||
$table->string('query', 1000)->nullable();
|
||||
$table->string('mode', 20);
|
||||
$table->integer('total_logs');
|
||||
$table->json('results');
|
||||
$table->json('metadata')->nullable();
|
||||
$table->string('status', 20)->default('completed');
|
||||
$table->text('error_message')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['from_time', 'to_time']);
|
||||
$table->index('status');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('log_analysis_reports');
|
||||
}
|
||||
};
|
||||
@@ -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') {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
|
||||
955
resources/js/components/log-analysis/LogAnalysis.vue
Normal file
955
resources/js/components/log-analysis/LogAnalysis.vue
Normal 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>
|
||||
@@ -3,6 +3,7 @@
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\EnvController;
|
||||
use App\Http\Controllers\JiraController;
|
||||
use App\Http\Controllers\LogAnalysisController;
|
||||
use App\Http\Controllers\MessageSyncController;
|
||||
use App\Http\Controllers\MessageDispatchController;
|
||||
use App\Http\Controllers\SqlGeneratorController;
|
||||
@@ -76,3 +77,30 @@ Route::prefix('admin')->middleware('admin.ip')->group(function () {
|
||||
Route::put('/ip-user-mappings/{mapping}', [IpUserMappingController::class, 'update']);
|
||||
Route::delete('/ip-user-mappings/{mapping}', [IpUserMappingController::class, 'destroy']);
|
||||
});
|
||||
|
||||
// 日志分析 API 路由
|
||||
Route::prefix('log-analysis')->group(function () {
|
||||
// 日志查询(预览)
|
||||
Route::post('/query', [LogAnalysisController::class, 'queryLogs']);
|
||||
|
||||
// AI 分析
|
||||
Route::post('/analyze', [LogAnalysisController::class, 'analyze']);
|
||||
|
||||
// 历史报告
|
||||
Route::get('/reports', [LogAnalysisController::class, 'listReports']);
|
||||
Route::get('/reports/{id}', [LogAnalysisController::class, 'getReport']);
|
||||
|
||||
// 配置(读取不需要管理员权限)
|
||||
Route::get('/config', [LogAnalysisController::class, 'getConfig']);
|
||||
|
||||
// 获取 logstore 列表
|
||||
Route::get('/logstores', [LogAnalysisController::class, 'getLogstores']);
|
||||
|
||||
// 连接测试
|
||||
Route::get('/test-sls', [LogAnalysisController::class, 'testSlsConnection']);
|
||||
Route::get('/test-ai', [LogAnalysisController::class, 'testAiConnection']);
|
||||
|
||||
// 配置更新(需要管理员权限)
|
||||
Route::put('/config', [LogAnalysisController::class, 'updateConfig'])
|
||||
->middleware('admin.ip');
|
||||
});
|
||||
|
||||
@@ -1,8 +1,76 @@
|
||||
<?php
|
||||
|
||||
use App\Services\ConfigService;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Scheduled Tasks
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 定时任务配置
|
||||
| 所有定时任务统一在此文件管理
|
||||
|
|
||||
*/
|
||||
|
||||
// Git Monitor - 每 10 分钟检查 release 分支
|
||||
Schedule::command('git-monitor:check')
|
||||
->everyTenMinutes()
|
||||
->withoutOverlapping()
|
||||
->runInBackground()
|
||||
->name('git-monitor-check');
|
||||
|
||||
// Git Monitor - 每天凌晨 2 点刷新 release 缓存
|
||||
Schedule::command('git-monitor:cache')
|
||||
->dailyAt('02:00')
|
||||
->withoutOverlapping()
|
||||
->name('git-monitor-cache');
|
||||
|
||||
// SLS 日志分析定时任务 - 每天凌晨 2 点执行
|
||||
// 分析过去 24 小时的 ERROR 和 WARNING 日志并推送到钉钉
|
||||
// 可通过数据库配置 log_analysis.settings.daily_schedule_enabled 控制是否启用
|
||||
/*
|
||||
Schedule::command('log-analysis:run --from="-24h" --to="now" --query="ERROR or WARNING" --push')
|
||||
->dailyAt('02:00')
|
||||
->withoutOverlapping()
|
||||
->runInBackground()
|
||||
->when(function () {
|
||||
try {
|
||||
$settings = app(ConfigService::class)->get('log_analysis.settings', []);
|
||||
return $settings['daily_schedule_enabled'] ?? false;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
->name('daily-log-analysis')
|
||||
->onFailure(function () {
|
||||
Log::error('每日日志分析定时任务执行失败');
|
||||
});
|
||||
*/
|
||||
|
||||
// SLS 日志分析定时任务 - 每 4 小时执行一次
|
||||
// 分析过去 6 小时的 ERROR 和 WARNING 日志并推送到钉钉
|
||||
// 可通过数据库配置 log_analysis.settings.schedule_enabled 控制是否启用
|
||||
Schedule::command('log-analysis:run --from="-6h" --to="now" --query="ERROR or WARNING" --push')
|
||||
->everyFourHours()
|
||||
->withoutOverlapping()
|
||||
->runInBackground()
|
||||
->when(function () {
|
||||
try {
|
||||
$settings = app(ConfigService::class)->get('log_analysis.settings', []);
|
||||
return $settings['schedule_enabled'] ?? false;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
->name('frequent-log-analysis')
|
||||
->onFailure(function () {
|
||||
Log::error('SLS 日志分析定时任务执行失败');
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ Route::get('/worklog', [AdminController::class, 'index'])->name('admin.worklog')
|
||||
Route::get('/message-sync', [AdminController::class, 'index'])->name('admin.message-sync');
|
||||
Route::get('/event-consumer-sync', [AdminController::class, 'index'])->name('admin.event-consumer-sync');
|
||||
Route::get('/message-dispatch', [AdminController::class, 'index'])->name('admin.message-dispatch');
|
||||
Route::get('/log-analysis', [AdminController::class, 'index'])->name('admin.log-analysis');
|
||||
Route::get('/settings', [AdminController::class, 'index'])->name('admin.settings');
|
||||
Route::get('/logs', [AdminController::class, 'index'])->name('admin.logs');
|
||||
Route::get('/ip-mappings', [AdminController::class, 'index'])->name('admin.ip-mappings')->middleware('admin.ip');
|
||||
|
||||
Reference in New Issue
Block a user