Compare commits

..

3 Commits

Author SHA1 Message Date
ddd0f531fd #feature: update log format 2026-01-19 15:42:44 +08:00
0646c8612b #feature: update log format 2026-01-19 14:21:15 +08:00
da3b05b7c0 #feature: add Jenkins deploy monitor & log clean task 2026-01-19 11:46:38 +08:00
25 changed files with 1023 additions and 152 deletions

View File

@@ -126,3 +126,12 @@ AI_TEMPERATURE=0.3
AI_TIMEOUT=120 AI_TIMEOUT=120
AI_MAX_TOKENS=4096 AI_MAX_TOKENS=4096
DINGTALK_WEBHOOK=
DINGTALK_SECRET=
# Jenkins Configuration
JENKINS_HOST=http://jenkins.example.com
JENKINS_USERNAME=
JENKINS_API_TOKEN=
JENKINS_TIMEOUT=30

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Clients;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class JenkinsClient
{
private ?string $host;
private ?string $username;
private ?string $apiToken;
private int $timeout;
public function __construct()
{
$config = config('jenkins', []);
$this->host = rtrim($config['host'] ?? '', '/');
$this->username = $config['username'] ?? null;
$this->apiToken = $config['api_token'] ?? null;
$this->timeout = $config['timeout'] ?? 30;
}
public function isConfigured(): bool
{
return !empty($this->host) && !empty($this->username) && !empty($this->apiToken);
}
public function getJobInfo(string $jobName): ?array
{
return $this->request("/job/{$jobName}/api/json");
}
public function getBuildInfo(string $jobName, int $buildNumber): ?array
{
return $this->request("/job/{$jobName}/{$buildNumber}/api/json");
}
public function getLastBuild(string $jobName): ?array
{
return $this->request("/job/{$jobName}/lastBuild/api/json");
}
public function getBuilds(string $jobName, int $limit = 10): array
{
$jobInfo = $this->getJobInfo($jobName);
if (!$jobInfo || empty($jobInfo['builds'])) {
return [];
}
$builds = array_slice($jobInfo['builds'], 0, $limit);
$result = [];
foreach ($builds as $build) {
$buildInfo = $this->getBuildInfo($jobName, $build['number']);
if ($buildInfo) {
$result[] = $buildInfo;
}
}
return $result;
}
private function request(string $path): ?array
{
if (!$this->isConfigured()) {
Log::warning('Jenkins client is not configured');
return null;
}
$url = $this->host . $path;
try {
$response = Http::timeout($this->timeout)
->withBasicAuth($this->username, $this->apiToken)
->get($url);
if ($response->successful()) {
return $response->json();
}
Log::warning('Jenkins API request failed', [
'url' => $url,
'status' => $response->status(),
]);
return null;
} catch (\Throwable $e) {
Log::error('Jenkins API request error', [
'url' => $url,
'error' => $e->getMessage(),
]);
return null;
}
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Console\Commands;
use App\Services\GitMonitorService; use App\Services\GitMonitorService;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class GitMonitorCacheCommand extends Command class GitMonitorCacheCommand extends Command
{ {
@@ -16,11 +17,11 @@ class GitMonitorCacheCommand extends Command
$cache = $monitor->refreshReleaseCache(true); $cache = $monitor->refreshReleaseCache(true);
if (empty($cache)) { if (empty($cache)) {
$this->warn('未获取到任何 release 版本信息,请检查配置。'); Log::channel('git-monitor')->warning('未获取到任何 release 版本信息,请检查配置。');
return; return;
} }
$this->info(sprintf( Log::channel('git-monitor')->info(sprintf(
'已缓存 %d 个仓库的 release 分支信息。', '已缓存 %d 个仓库的 release 分支信息。',
count($cache['repositories'] ?? []) count($cache['repositories'] ?? [])
)); ));

View File

@@ -4,6 +4,7 @@ namespace App\Console\Commands;
use App\Services\GitMonitorService; use App\Services\GitMonitorService;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class GitMonitorCheckCommand extends Command class GitMonitorCheckCommand extends Command
{ {
@@ -24,11 +25,11 @@ class GitMonitorCheckCommand extends Command
foreach ($results as $repo => $result) { foreach ($results as $repo => $result) {
if (isset($result['error'])) { if (isset($result['error'])) {
$this->error(sprintf('[%s] %s', $repo, $result['error'])); Log::channel('git-monitor')->error(sprintf('[%s] %s', $repo, $result['error']));
continue; continue;
} }
$this->line(sprintf( Log::channel('git-monitor')->info(sprintf(
'[%s] 分支 %s 已对齐 %s扫描 %d 个提交。', '[%s] 分支 %s 已对齐 %s扫描 %d 个提交。',
$repo, $repo,
$result['branch'], $result['branch'],
@@ -37,9 +38,9 @@ class GitMonitorCheckCommand extends Command
)); ));
if (!empty($result['issues']['develop_merges'])) { if (!empty($result['issues']['develop_merges'])) {
$this->warn(sprintf(' - 检测到 %d 个 develop merge:', count($result['issues']['develop_merges']))); Log::channel('git-monitor')->warning(sprintf(' - 检测到 %d 个 develop merge:', count($result['issues']['develop_merges'])));
foreach ($result['issues']['develop_merges'] as $commit) { foreach ($result['issues']['develop_merges'] as $commit) {
$this->warn(sprintf( Log::channel('git-monitor')->warning(sprintf(
' • %s %s (%s)', ' • %s %s (%s)',
substr($commit['hash'], 0, 8), substr($commit['hash'], 0, 8),
$commit['subject'], $commit['subject'],
@@ -49,9 +50,9 @@ class GitMonitorCheckCommand extends Command
} }
if (!empty($result['issues']['missing_functions'])) { if (!empty($result['issues']['missing_functions'])) {
$this->warn(sprintf(' - 检测到 %d 个疑似缺失函数的提交:', count($result['issues']['missing_functions']))); Log::channel('git-monitor')->warning(sprintf(' - 检测到 %d 个疑似缺失函数的提交:', count($result['issues']['missing_functions'])));
foreach ($result['issues']['missing_functions'] as $issue) { foreach ($result['issues']['missing_functions'] as $issue) {
$this->warn(sprintf( Log::channel('git-monitor')->warning(sprintf(
' • %s %s (%s)', ' • %s %s (%s)',
substr($issue['commit']['hash'], 0, 8), substr($issue['commit']['hash'], 0, 8),
$issue['commit']['subject'], $issue['commit']['subject'],
@@ -59,7 +60,7 @@ class GitMonitorCheckCommand extends Command
)); ));
foreach ($issue['details'] as $detail) { foreach ($issue['details'] as $detail) {
$functions = implode(', ', array_slice($detail['functions'], 0, 5)); $functions = implode(', ', array_slice($detail['functions'], 0, 5));
$this->warn(sprintf(' %s => %s', $detail['file'], $functions)); Log::channel('git-monitor')->warning(sprintf(' %s => %s', $detail['file'], $functions));
} }
} }
} }

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Console\Commands;
use App\Services\JenkinsMonitorService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class JenkinsMonitorCommand extends Command
{
protected $signature = 'jenkins:monitor';
protected $description = '轮询 Jenkins 检查新构建并发送钉钉通知';
public function handle(JenkinsMonitorService $service): void
{
Log::channel('jenkins-monitor')->info('开始检查 Jenkins 构建...');
$results = $service->checkAllProjects();
if (isset($results['skipped'])) {
Log::channel('jenkins-monitor')->warning('跳过检查: ' . ($results['reason'] ?? 'unknown'));
return;
}
foreach ($results as $slug => $result) {
if (isset($result['skipped'])) {
Log::channel('jenkins-monitor')->info(sprintf('[%s] 跳过: %s', $slug, $result['reason'] ?? 'unknown'));
continue;
}
$newBuilds = $result['new_builds'] ?? [];
if (empty($newBuilds)) {
Log::channel('jenkins-monitor')->info(sprintf('[%s] 无新构建', $slug));
} else {
Log::channel('jenkins-monitor')->info(sprintf('[%s] 发现 %d 个新构建: #%s', $slug, count($newBuilds), implode(', #', $newBuilds)));
}
}
Log::channel('jenkins-monitor')->info('检查完成');
}
}

View File

@@ -8,6 +8,7 @@ use App\Services\SlsService;
use App\Services\AiService; use App\Services\AiService;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class LogAnalysisCommand extends Command class LogAnalysisCommand extends Command
{ {
@@ -29,12 +30,12 @@ class LogAnalysisCommand extends Command
): int { ): int {
// 检查配置 // 检查配置
if (!$slsService->isConfigured()) { if (!$slsService->isConfigured()) {
$this->error('SLS 服务未配置,请检查 .env 中的 SLS_* 配置项'); Log::channel('log-analysis')->error('SLS 服务未配置,请检查 .env 中的 SLS_* 配置项');
return Command::FAILURE; return Command::FAILURE;
} }
if (!$aiService->isConfigured()) { if (!$aiService->isConfigured()) {
$this->error('AI 服务未配置,请在页面上配置 AI 提供商或设置 .env 中的 AI_* 配置项'); Log::channel('log-analysis')->error('AI 服务未配置,请在页面上配置 AI 提供商或设置 .env 中的 AI_* 配置项');
return Command::FAILURE; return Command::FAILURE;
} }
@@ -43,7 +44,7 @@ class LogAnalysisCommand extends Command
$to = $this->parseTime($this->option('to') ?? 'now'); $to = $this->parseTime($this->option('to') ?? 'now');
if ($from >= $to) { if ($from >= $to) {
$this->error('开始时间必须早于结束时间'); Log::channel('log-analysis')->error('开始时间必须早于结束时间');
return Command::FAILURE; return Command::FAILURE;
} }
@@ -55,11 +56,10 @@ class LogAnalysisCommand extends Command
$query = $this->option('query'); $query = $this->option('query');
$this->info("开始分析日志..."); Log::channel('log-analysis')->info("开始分析日志...");
$this->line(" 时间范围: {$from->format('Y-m-d H:i:s')} ~ {$to->format('Y-m-d H:i:s')}"); Log::channel('log-analysis')->info(" 时间范围: {$from->format('Y-m-d H:i:s')} ~ {$to->format('Y-m-d H:i:s')}");
$this->line(" 查询语句: " . ($query ?: '*')); Log::channel('log-analysis')->info(" 查询语句: " . ($query ?: '*'));
$this->line(" 分析模式: {$mode->label()}"); Log::channel('log-analysis')->info(" 分析模式: {$mode->label()}");
$this->newLine();
try { try {
$result = $analysisService->analyze( $result = $analysisService->analyze(
@@ -74,17 +74,17 @@ class LogAnalysisCommand extends Command
if ($outputPath = $this->option('output')) { if ($outputPath = $this->option('output')) {
$json = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); $json = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
file_put_contents($outputPath, $json); file_put_contents($outputPath, $json);
$this->info("报告已保存到: {$outputPath}"); Log::channel('log-analysis')->info("报告已保存到: {$outputPath}");
} }
// 推送到钉钉 // 推送到钉钉
if ($this->option('push')) { if ($this->option('push')) {
$this->line("正在推送到钉钉..."); Log::channel('log-analysis')->info("正在推送到钉钉...");
$pushed = $analysisService->pushToNotification($result); $pushed = $analysisService->pushToNotification($result);
if ($pushed) { if ($pushed) {
$this->info("已推送到钉钉"); Log::channel('log-analysis')->info("已推送到钉钉");
} else { } else {
$this->warn("钉钉推送失败"); Log::channel('log-analysis')->warning("钉钉推送失败");
} }
} }
@@ -93,7 +93,7 @@ class LogAnalysisCommand extends Command
return Command::SUCCESS; return Command::SUCCESS;
} catch (\Exception $e) { } catch (\Exception $e) {
$this->error("分析失败: {$e->getMessage()}"); Log::channel('log-analysis')->error("分析失败: {$e->getMessage()}");
return Command::FAILURE; return Command::FAILURE;
} }
} }
@@ -132,57 +132,45 @@ class LogAnalysisCommand extends Command
*/ */
private function displaySummary(array $result): void private function displaySummary(array $result): void
{ {
$this->newLine(); Log::channel('log-analysis')->info('=== 分析摘要 ===');
$this->info('=== 分析摘要 ==='); Log::channel('log-analysis')->info("总日志数: {$result['metadata']['total_logs']}");
$this->line("总日志数: {$result['metadata']['total_logs']}"); Log::channel('log-analysis')->info("分析应用数: {$result['metadata']['apps_analyzed']}");
$this->line("分析应用数: {$result['metadata']['apps_analyzed']}"); Log::channel('log-analysis')->info("执行时间: {$result['metadata']['execution_time_ms']}ms");
$this->line("执行时间: {$result['metadata']['execution_time_ms']}ms");
$this->newLine();
if (empty($result['results'])) { if (empty($result['results'])) {
$this->warn('未找到匹配的日志'); Log::channel('log-analysis')->warning('未找到匹配的日志');
return; return;
} }
foreach ($result['results'] as $appName => $appResult) { foreach ($result['results'] as $appName => $appResult) {
$this->line("{$appName}"); Log::channel('log-analysis')->info("{$appName}");
if (isset($appResult['error'])) { if (isset($appResult['error'])) {
$this->error(" 分析失败: {$appResult['error']}"); Log::channel('log-analysis')->error(" 分析失败: {$appResult['error']}");
continue; continue;
} }
$impact = $appResult['impact'] ?? 'unknown'; $impact = $appResult['impact'] ?? 'unknown';
$impactColor = match ($impact) {
'high' => 'red',
'medium' => 'yellow',
'low' => 'green',
default => 'white',
};
$this->line(" 日志数: {$appResult['log_count']}"); Log::channel('log-analysis')->info(" 日志数: {$appResult['log_count']}");
$this->line(" 代码上下文: " . ($appResult['has_code_context'] ? '是' : '否')); Log::channel('log-analysis')->info(" 代码上下文: " . ($appResult['has_code_context'] ? '是' : '否'));
$this->line(" 影响级别: <fg={$impactColor}>{$impact}</>"); Log::channel('log-analysis')->info(" 影响级别: {$impact}");
$this->line(" 摘要: " . ($appResult['summary'] ?? 'N/A')); Log::channel('log-analysis')->info(" 摘要: " . ($appResult['summary'] ?? 'N/A'));
$anomalies = $appResult['core_anomalies'] ?? []; $anomalies = $appResult['core_anomalies'] ?? [];
if (!empty($anomalies)) { if (!empty($anomalies)) {
$this->line(" 异常数: " . count($anomalies)); Log::channel('log-analysis')->info(" 异常数: " . count($anomalies));
$table = [];
foreach (array_slice($anomalies, 0, 5) as $anomaly) { foreach (array_slice($anomalies, 0, 5) as $anomaly) {
$table[] = [ Log::channel('log-analysis')->info(sprintf(
" - [%s] %s (数量: %d) - %s",
$anomaly['type'] ?? 'N/A', $anomaly['type'] ?? 'N/A',
$anomaly['classification'] ?? 'N/A', $anomaly['classification'] ?? 'N/A',
$anomaly['count'] ?? 1, $anomaly['count'] ?? 1,
mb_substr($anomaly['possible_cause'] ?? 'N/A', 0, 40), mb_substr($anomaly['possible_cause'] ?? 'N/A', 0, 40)
]; ));
} }
$this->table(['类型', '分类', '数量', '可能原因'], $table);
} }
$this->newLine();
} }
} }
} }

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Console\Commands;
use App\Services\ScheduledTaskService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class ScheduledTaskRefreshCommand extends Command
{
protected $signature = 'scheduled-task:refresh';
protected $description = '刷新定时任务列表,同步 console.php 中的任务配置到数据库';
public function handle(ScheduledTaskService $taskService): int
{
try {
Log::channel('scheduled-tasks')->info('开始刷新定时任务列表...');
$tasks = $taskService->getAllTasks();
Log::channel('scheduled-tasks')->info(sprintf('成功刷新 %d 个定时任务', count($tasks)));
// 显示任务列表
foreach ($tasks as $task) {
Log::channel('scheduled-tasks')->info(sprintf(
' - %s: %s (%s) [%s]',
$task['name'],
$task['description'],
$task['frequency'],
$task['enabled'] ? '已启用' : '已禁用'
));
}
return Command::SUCCESS;
} catch (\Exception $e) {
Log::channel('scheduled-tasks')->error("刷新失败: {$e->getMessage()}");
return Command::FAILURE;
}
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\JenkinsDeployment;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class JenkinsDeploymentController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = JenkinsDeployment::with('project:id,slug,name')
->orderByDesc('created_at');
if ($request->filled('project_id')) {
$query->where('project_id', $request->input('project_id'));
}
if ($request->filled('job_name')) {
$query->where('job_name', $request->input('job_name'));
}
if ($request->filled('status')) {
$query->where('status', $request->input('status'));
}
$perPage = min((int) $request->input('per_page', 20), 100);
$deployments = $query->paginate($perPage);
return response()->json($deployments);
}
public function show(int $id): JsonResponse
{
$deployment = JenkinsDeployment::with('project:id,slug,name')->findOrFail($id);
return response()->json($deployment);
}
}

View File

@@ -82,6 +82,8 @@ class ProjectController extends Controller
'log_app_names' => ['nullable', 'array'], 'log_app_names' => ['nullable', 'array'],
'log_app_names.*' => ['string', 'max:100'], 'log_app_names.*' => ['string', 'max:100'],
'log_env' => ['nullable', 'string', 'max:50'], 'log_env' => ['nullable', 'string', 'max:50'],
'jenkins_job_name' => ['nullable', 'string', 'max:255'],
'jenkins_notify_enabled' => ['nullable', 'boolean'],
]); ]);
$project = $this->projectService->create($data); $project = $this->projectService->create($data);
@@ -126,6 +128,8 @@ class ProjectController extends Controller
'log_app_names' => ['nullable', 'array'], 'log_app_names' => ['nullable', 'array'],
'log_app_names.*' => ['string', 'max:100'], 'log_app_names.*' => ['string', 'max:100'],
'log_env' => ['nullable', 'string', 'max:50'], 'log_env' => ['nullable', 'string', 'max:50'],
'jenkins_job_name' => ['nullable', 'string', 'max:255'],
'jenkins_notify_enabled' => ['nullable', 'boolean'],
]); ]);
$project = $this->projectService->update($project, $data); $project = $this->projectService->update($project, $data);

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class JenkinsDeployment extends BaseModel
{
protected $fillable = [
'project_id',
'build_number',
'job_name',
'status',
'branch',
'commit_sha',
'triggered_by',
'duration',
'build_url',
'raw_data',
'build_params',
'notified',
];
protected $casts = [
'raw_data' => 'array',
'build_params' => 'array',
'notified' => 'boolean',
];
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
public function getFormattedDuration(): string
{
if (!$this->duration) {
return '-';
}
$seconds = (int) ($this->duration / 1000);
$minutes = (int) ($seconds / 60);
$seconds = $seconds % 60;
return sprintf('%02d:%02d', $minutes, $seconds);
}
public function getStatusEmoji(): string
{
return match ($this->status) {
'SUCCESS' => '✅',
'FAILURE' => '❌',
'ABORTED' => '⏹️',
'UNSTABLE' => '⚠️',
default => '❓',
};
}
public function getStatusLabel(): string
{
return match ($this->status) {
'SUCCESS' => '成功',
'FAILURE' => '失败',
'ABORTED' => '已中止',
'UNSTABLE' => '不稳定',
default => '未知',
};
}
}

View File

@@ -20,12 +20,16 @@ class Project extends BaseModel
'git_version_cached_at', 'git_version_cached_at',
'log_app_names', 'log_app_names',
'log_env', 'log_env',
'jenkins_job_name',
'jenkins_notify_enabled',
'jenkins_last_notified_build',
]; ];
protected $casts = [ protected $casts = [
'git_monitor_enabled' => 'boolean', 'git_monitor_enabled' => 'boolean',
'auto_create_release_branch' => 'boolean', 'auto_create_release_branch' => 'boolean',
'is_important' => 'boolean', 'is_important' => 'boolean',
'jenkins_notify_enabled' => 'boolean',
'log_app_names' => 'array', 'log_app_names' => 'array',
'git_version_cached_at' => 'datetime', 'git_version_cached_at' => 'datetime',
]; ];
@@ -86,4 +90,15 @@ class Project extends BaseModel
->whereJsonContains('log_app_names', $appName) ->whereJsonContains('log_app_names', $appName)
->first(); ->first();
} }
/**
* 获取所有启用 Jenkins 通知的项目
*/
public static function getJenkinsNotifyEnabled(): \Illuminate\Database\Eloquent\Collection
{
return static::query()
->where('jenkins_notify_enabled', true)
->whereNotNull('jenkins_job_name')
->get();
}
} }

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ScheduledTask extends Model
{
protected $fillable = [
'name',
'command',
'description',
'frequency',
'cron',
'enabled',
];
protected $casts = [
'enabled' => 'boolean',
];
}

View File

@@ -11,8 +11,7 @@ class CodeContextService
private int $contextLines = 10; private int $contextLines = 10;
public function __construct( public function __construct(
private readonly ConfigService $configService, private readonly ConfigService $configService
private readonly EnvService $envService
) {} ) {}
/** /**
@@ -23,47 +22,14 @@ class CodeContextService
*/ */
public function getRepoPath(string $appName): ?string public function getRepoPath(string $appName): ?string
{ {
// 优先从 Project 模型查找 // 从 Project 模型查找,直接使用项目路径
$project = Project::findByAppName($appName); $project = Project::findByAppName($appName);
if ($project) { if ($project) {
$env = $project->log_env ?? 'production'; $projectsPath = $this->configService->get('workspace.projects_path', '');
try { if ($projectsPath && $project->isPathValid($projectsPath)) {
$envContent = $this->envService->getEnvContent($project->slug, $env); return $project->getFullPath($projectsPath);
$repoPath = $this->parseEnvValue($envContent, 'LOG_ANALYSIS_CODE_REPO_PATH');
if ($repoPath && is_dir($repoPath)) {
return $repoPath;
} }
} catch (\Exception $e) {
// 忽略错误,继续尝试旧配置
}
}
// 回退到旧的配置方式(兼容迁移前的情况)
$appEnvMap = $this->configService->get('log_analysis.app_env_map', []);
if (!isset($appEnvMap[$appName])) {
return null;
}
$mapping = $appEnvMap[$appName];
$projectSlug = $mapping['project'] ?? null;
$env = $mapping['env'] ?? null;
if (!$projectSlug || !$env) {
return null;
}
try {
$envContent = $this->envService->getEnvContent($projectSlug, $env);
$repoPath = $this->parseEnvValue($envContent, 'LOG_ANALYSIS_CODE_REPO_PATH');
if ($repoPath && is_dir($repoPath)) {
return $repoPath;
}
} catch (\Exception $e) {
// 忽略错误,返回 null
} }
return null; return null;
@@ -246,36 +212,6 @@ class CodeContextService
return null; 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;
}
/** /**
* 设置上下文行数 * 设置上下文行数
*/ */

View File

@@ -0,0 +1,294 @@
<?php
namespace App\Services;
use App\Clients\JenkinsClient;
use App\Models\JenkinsDeployment;
use App\Models\Project;
use Illuminate\Support\Facades\Log;
class JenkinsMonitorService
{
public function __construct(
private readonly JenkinsClient $jenkinsClient,
private readonly DingTalkService $dingTalkService,
private readonly ConfigService $configService
) {}
public function checkAllProjects(): array
{
if (!$this->jenkinsClient->isConfigured()) {
Log::warning('Jenkins client is not configured, skipping monitor');
return ['skipped' => true, 'reason' => 'Jenkins not configured'];
}
$projects = Project::getJenkinsNotifyEnabled();
$results = [];
foreach ($projects as $project) {
$results[$project->slug] = $this->checkProject($project);
}
return $results;
}
public function checkProject(Project $project): array
{
if (empty($project->jenkins_job_name)) {
return ['skipped' => true, 'reason' => 'No Jenkins job configured'];
}
$jobName = $project->jenkins_job_name;
$lastNotifiedBuild = $project->jenkins_last_notified_build ?? 0;
$allowedTriggers = $this->getAllowedTriggers();
$builds = $this->jenkinsClient->getBuilds($jobName, 5);
$newBuilds = [];
foreach ($builds as $build) {
// 只处理已完成的构建
if ($build['building'] ?? true) {
continue;
}
$buildNumber = $build['number'];
// 跳过已通知的构建
if ($buildNumber <= $lastNotifiedBuild) {
continue;
}
// 检查是否已存在记录
$exists = JenkinsDeployment::where('job_name', $jobName)
->where('build_number', $buildNumber)
->exists();
if ($exists) {
continue;
}
// 解析构建信息
$triggeredBy = $this->extractTriggeredBy($build);
$branch = $this->extractBranch($build);
$commitSha = $this->extractCommitSha($build);
$buildParams = $this->extractBuildParams($build);
// 过滤触发者(只通知指定用户触发的构建)
// 如果没有配置允许的触发者列表,则跳过所有通知
if (empty($allowedTriggers)) {
Log::info('Skipping build - no allowed triggers configured', [
'job' => $jobName,
'build' => $buildNumber,
'triggered_by' => $triggeredBy,
]);
continue;
}
// 检查触发者是否在允许列表中
if (!$this->isAllowedTrigger($triggeredBy, $allowedTriggers)) {
Log::info('Skipping build due to trigger filter', [
'job' => $jobName,
'build' => $buildNumber,
'triggered_by' => $triggeredBy,
]);
continue;
}
// 保存发布记录
$deployment = JenkinsDeployment::create([
'project_id' => $project->id,
'build_number' => $buildNumber,
'job_name' => $jobName,
'status' => $build['result'] ?? 'UNKNOWN',
'branch' => $branch,
'commit_sha' => $commitSha,
'triggered_by' => $triggeredBy,
'duration' => $build['duration'] ?? null,
'build_url' => $build['url'] ?? null,
'raw_data' => $build,
'build_params' => $buildParams,
'notified' => false,
]);
// 发送通知
$this->sendNotification($project, $deployment);
$deployment->update(['notified' => true]);
$newBuilds[] = $buildNumber;
}
// 更新最后通知的构建号
if (!empty($newBuilds)) {
$project->update([
'jenkins_last_notified_build' => max($newBuilds),
]);
}
return [
'job' => $jobName,
'new_builds' => $newBuilds,
];
}
private function sendNotification(Project $project, JenkinsDeployment $deployment): void
{
$lines = [];
$lines[] = sprintf(
"%s 【Jenkins 发布通知】",
$deployment->getStatusEmoji()
);
$lines[] = sprintf("项目: %s", $project->name);
$lines[] = sprintf("状态: %s", $deployment->getStatusLabel());
$lines[] = sprintf("构建号: #%d", $deployment->build_number);
$lines[] = sprintf("触发者: %s", $deployment->triggered_by ?? '-');
$lines[] = sprintf("耗时: %s", $deployment->getFormattedDuration());
// 添加构建参数
if (!empty($deployment->build_params)) {
$lines[] = "\n构建参数:";
foreach ($deployment->build_params as $key => $value) {
// 格式化参数值
if (is_bool($value)) {
$value = $value ? 'true' : 'false';
} elseif (is_array($value)) {
$value = json_encode($value, JSON_UNESCAPED_UNICODE);
} elseif ($value === null || $value === '') {
continue; // 跳过空值
}
$lines[] = sprintf(" %s: %s", $key, $value);
}
}
$lines[] = sprintf("\n详情: %s", $deployment->build_url ?? '-');
$message = implode("\n", $lines);
$this->dingTalkService->sendText($message);
}
private function extractTriggeredBy(array $build): ?string
{
$actions = $build['actions'] ?? [];
foreach ($actions as $action) {
// UserIdCause - 用户手动触发
if (isset($action['causes'])) {
foreach ($action['causes'] as $cause) {
if (isset($cause['userId'])) {
return $cause['userId'];
}
if (isset($cause['userName'])) {
return $cause['userName'];
}
}
}
}
return null;
}
private function extractBranch(array $build): ?string
{
$actions = $build['actions'] ?? [];
// 优先从参数中获取 branchName
foreach ($actions as $action) {
if (isset($action['parameters'])) {
foreach ($action['parameters'] as $param) {
if (in_array($param['name'] ?? '', ['branchName', 'BRANCH', 'branch', 'GIT_BRANCH', 'BRANCH_NAME'])) {
$value = $param['value'] ?? null;
if (!empty($value)) {
return $value;
}
}
}
}
}
// 如果参数中没有,再从 Git 分支信息中获取
foreach ($actions as $action) {
if (isset($action['lastBuiltRevision']['branch'])) {
foreach ($action['lastBuiltRevision']['branch'] as $branch) {
$name = $branch['name'] ?? '';
// 移除 origin/ 和 refs/remotes/origin/ 前缀
return preg_replace('/^(refs\/remotes\/origin\/|origin\/)/', '', $name);
}
}
}
return null;
}
private function extractCommitSha(array $build): ?string
{
$actions = $build['actions'] ?? [];
foreach ($actions as $action) {
if (isset($action['lastBuiltRevision']['SHA1'])) {
return substr($action['lastBuiltRevision']['SHA1'], 0, 8);
}
}
return null;
}
private function getAllowedTriggers(): array
{
$config = $this->configService->get('jenkins_allowed_triggers', []);
// 如果配置为空,返回空数组
if (empty($config)) {
return [];
}
// 如果配置是数组,直接返回(过滤空值)
if (is_array($config)) {
return array_filter(array_map('trim', $config));
}
// 如果配置是字符串(兼容旧格式),按逗号分隔
if (is_string($config)) {
return array_filter(array_map('trim', explode(',', $config)));
}
return [];
}
private function isAllowedTrigger(?string $triggeredBy, array $allowedTriggers): bool
{
if (empty($triggeredBy)) {
return false;
}
foreach ($allowedTriggers as $allowed) {
if (strcasecmp($triggeredBy, $allowed) === 0) {
return true;
}
}
return false;
}
private function extractBuildParams(array $build): array
{
$actions = $build['actions'] ?? [];
foreach ($actions as $action) {
// 查找 ParametersAction
if (isset($action['_class']) && $action['_class'] === 'hudson.model.ParametersAction') {
if (isset($action['parameters']) && is_array($action['parameters'])) {
$params = [];
foreach ($action['parameters'] as $param) {
$name = $param['name'] ?? null;
$value = $param['value'] ?? null;
if ($name !== null) {
$params[$name] = $value;
}
}
return $params;
}
}
}
return [];
}
}

View File

@@ -170,7 +170,7 @@ class JiraService
$endOfWeek = $now->copy()->subWeek()->endOfWeek(); $endOfWeek = $now->copy()->subWeek()->endOfWeek();
$workLogs = $this->getWorkLogs($username, $startOfWeek, $endOfWeek); $workLogs = $this->getWorkLogs($username, $startOfWeek, $endOfWeek);
$organizedTasks = $this->organizeTasksForReport($workLogs); $organizedTasks = $this->organizeTasksForReport($workLogs, $username);
$nextWeekTasks = $this->getNextWeekTasks($username); $nextWeekTasks = $this->getNextWeekTasks($username);
@@ -307,8 +307,11 @@ class JiraService
'created', 'created',
'fixVersions', 'fixVersions',
'labels', 'labels',
'assignee', // 经办人
'customfield_10004', // Sprint字段 'customfield_10004', // Sprint字段
'customfield_10900', // Bug发现阶段 'customfield_10900', // Bug发现阶段
'customfield_11000', // 开发人
'customfield_11301', // 实际修复人
'customfield_12700', // Bug错误类型 'customfield_12700', // Bug错误类型
'customfield_10115', // Bug修复描述 'customfield_10115', // Bug修复描述
'customfield_14305', // 需求类型 'customfield_14305', // 需求类型
@@ -369,6 +372,11 @@ class JiraService
// 提取需求类型 // 提取需求类型
$requirementType = $this->extractRequirementType($issue); $requirementType = $this->extractRequirementType($issue);
// 提取经办人、开发人、实际修复人
$assignee = $this->extractAssignee($issue);
$developer = $this->extractDeveloper($issue);
$actualFixer = $this->extractActualFixer($issue);
$workLogs->push([ $workLogs->push([
'id' => $worklog->id ?? '', 'id' => $worklog->id ?? '',
'project' => $issue->fields->project->name ?? '', 'project' => $issue->fields->project->name ?? '',
@@ -385,6 +393,9 @@ class JiraService
'bug_type' => $bugType, 'bug_type' => $bugType,
'bug_description' => $bugDescription, 'bug_description' => $bugDescription,
'requirement_type' => $requirementType, 'requirement_type' => $requirementType,
'assignee' => $assignee,
'developer' => $developer,
'actual_fixer' => $actualFixer,
'date' => $worklogDate->format('Y-m-d'), 'date' => $worklogDate->format('Y-m-d'),
'time' => $worklogDate->format('H:i'), 'time' => $worklogDate->format('H:i'),
'hours' => round(($worklog->timeSpentSeconds ?? 0) / 3600, 2), 'hours' => round(($worklog->timeSpentSeconds ?? 0) / 3600, 2),
@@ -593,6 +604,60 @@ class JiraService
return null; return null;
} }
/**
* 提取经办人
*/
private function extractAssignee($issue): ?string
{
// 从assignee字段获取经办人
if (isset($issue->fields->assignee)) {
$assignee = $issue->fields->assignee;
// 处理对象类型
if (is_object($assignee)) {
return $assignee->name ?? $assignee->key ?? null;
} elseif (is_string($assignee)) {
return $assignee;
}
}
return null;
}
/**
* 提取开发人
*/
private function extractDeveloper($issue): ?string
{
// 从customfield_11000获取开发人
if (isset($issue->fields->customFields['customfield_11000'])) {
$developer = $issue->fields->customFields['customfield_11000'];
if (is_string($developer) && !empty($developer)) {
return $developer;
}
}
return null;
}
/**
* 提取实际修复人
*/
private function extractActualFixer($issue): ?string
{
// 从customfield_11301获取实际修复人
if (isset($issue->fields->customFields['customfield_11301'])) {
$fixer = $issue->fields->customFields['customfield_11301'];
if (is_string($fixer) && !empty($fixer)) {
return $fixer;
}
}
return null;
}
/** /**
* 清理摘要中的图片链接 * 清理摘要中的图片链接
*/ */
@@ -610,29 +675,27 @@ class JiraService
*/ */
private function isTaskCompleted(string $status): bool private function isTaskCompleted(string $status): bool
{ {
// 只有已完成或已取消才打勾 // 定义"进行中"的状态列表
$completedStatuses = [ // 如果状态不在这个列表中,则认为任务已完成
'已完成', $inProgressStatuses = [
'完成', '需求已确认',
'Done', '开发中',
'Closed', '需求调研中',
'Resolved', '需求已调研',
]; '需求已评审',
$cancelledStatuses = [ '需求已排期',
'已取消', '待提测',
'取消', '需求设计中',
'Cancelled',
'Canceled',
]; ];
return in_array($status, $completedStatuses, true) // 如果状态不在"进行中"列表中,则标记为已完成
|| in_array($status, $cancelledStatuses, true); return !in_array($status, $inProgressStatuses, true);
} }
/** /**
* 组织任务数据用于周报生成 * 组织任务数据用于周报生成
*/ */
private function organizeTasksForReport(Collection $workLogs): Collection private function organizeTasksForReport(Collection $workLogs, string $username): Collection
{ {
$organized = collect([ $organized = collect([
'sprints' => collect(), 'sprints' => collect(),
@@ -671,6 +734,21 @@ class JiraService
$isSubtask = in_array($issueType, ['Sub-task', 'sub-task', '子任务']); $isSubtask = in_array($issueType, ['Sub-task', 'sub-task', '子任务']);
if ($isBug && $workLog['bug_stage']) { if ($isBug && $workLog['bug_stage']) {
// Bug过滤逻辑必须经办人、实际修复人或开发人是当前用户
$assignee = $workLog['assignee'] ?? null;
$developer = $workLog['developer'] ?? null;
$actualFixer = $workLog['actual_fixer'] ?? null;
// 检查是否有任一字段匹配当前用户
$isUserRelated = ($assignee === $username)
|| ($developer === $username)
|| ($actualFixer === $username);
// 如果不是当前用户相关的Bug跳过
if (!$isUserRelated) {
continue;
}
// Bug按发现阶段分类 // Bug按发现阶段分类
$stage = $workLog['bug_stage']; $stage = $workLog['bug_stage'];
if (!$organized['bugs']->has($stage)) { if (!$organized['bugs']->has($stage)) {

View File

@@ -44,7 +44,7 @@ class ScheduledTaskService
$tasks[] = [ $tasks[] = [
'name' => $name, 'name' => $name,
'command' => $this->getEventCommand($event), 'command' => $this->getEventCommand($event),
'description' => $event->description ?: $name, 'description' => $this->getTaskDescription($name),
'frequency' => $this->getFrequencyLabel($event->expression), 'frequency' => $this->getFrequencyLabel($event->expression),
'cron' => $event->expression, 'cron' => $event->expression,
'enabled' => $enabledTasks[$name] ?? false, 'enabled' => $enabledTasks[$name] ?? false,
@@ -92,9 +92,15 @@ class ScheduledTaskService
private function getEventName($event): string private function getEventName($event): string
{ {
if (property_exists($event, 'mutexName') && $event->mutexName) { // Laravel Schedule 事件的 description 属性存储任务名称
return $event->mutexName; // 我们在 routes/console.php 中通过 ->description() 设置
// 1. 优先使用 description (我们设置的任务标识符)
if (property_exists($event, 'description') && $event->description) {
return $event->description;
} }
// 2. 最后使用命令作为名称
return $this->getEventCommand($event); return $this->getEventCommand($event);
} }
@@ -125,9 +131,27 @@ class ScheduledTaskService
'0 */12 * * *' => '每 12 小时', '0 */12 * * *' => '每 12 小时',
'0 0 * * *' => '每天凌晨 0:00', '0 0 * * *' => '每天凌晨 0:00',
'0 2 * * *' => '每天凌晨 2:00', '0 2 * * *' => '每天凌晨 2:00',
'0 3 * * *' => '每天凌晨 3:00',
'0 0 * * 0' => '每周日凌晨', '0 0 * * 0' => '每周日凌晨',
'0 0 1 * *' => '每月 1 日凌晨', '0 0 1 * *' => '每月 1 日凌晨',
]; ];
return $map[$expression] ?? $expression; return $map[$expression] ?? $expression;
} }
/**
* 获取任务的友好描述文本
*/
private function getTaskDescription(string $name): string
{
$descriptions = [
'git-monitor-check' => 'Git 监控 - 检查 release 分支变化',
'git-monitor-cache' => 'Git 监控 - 刷新 release 缓存',
'daily-log-analysis' => 'SLS 日志分析 - 每日分析过去 24 小时日志',
'frequent-log-analysis' => 'SLS 日志分析 - 定期分析过去 6 小时日志',
'jenkins-monitor' => 'Jenkins 发布监控 - 检查新构建并发送通知',
'scheduled-task-refresh' => '定时任务管理 - 刷新定时任务列表',
'logs-cleanup' => '日志清理 - 自动删除 7 天前的定时任务日志',
];
return $descriptions[$name] ?? $name;
}
} }

8
config/jenkins.php Normal file
View File

@@ -0,0 +1,8 @@
<?php
return [
'host' => env('JENKINS_HOST'),
'username' => env('JENKINS_USERNAME'),
'api_token' => env('JENKINS_API_TOKEN'),
'timeout' => (int) env('JENKINS_TIMEOUT', 30),
];

View File

@@ -127,6 +127,38 @@ return [
'path' => storage_path('logs/laravel.log'), 'path' => storage_path('logs/laravel.log'),
], ],
'jenkins-monitor' => [
'driver' => 'daily',
'path' => storage_path('logs/scheduled-tasks/jenkins-monitor.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => 7,
'replace_placeholders' => true,
],
'git-monitor' => [
'driver' => 'daily',
'path' => storage_path('logs/scheduled-tasks/git-monitor.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => 7,
'replace_placeholders' => true,
],
'log-analysis' => [
'driver' => 'daily',
'path' => storage_path('logs/scheduled-tasks/log-analysis.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => 7,
'replace_placeholders' => true,
],
'scheduled-tasks' => [
'driver' => 'daily',
'path' => storage_path('logs/scheduled-tasks/scheduled-tasks.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => 7,
'replace_placeholders' => true,
],
], ],
]; ];

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('projects', function (Blueprint $table) {
$table->string('jenkins_job_name', 255)->nullable()->comment('Jenkins Job 名称');
$table->boolean('jenkins_notify_enabled')->default(false)->comment('是否启用 Jenkins 通知');
$table->integer('jenkins_last_notified_build')->nullable()->comment('最后通知的构建号');
});
}
public function down(): void
{
Schema::table('projects', function (Blueprint $table) {
$table->dropColumn(['jenkins_job_name', 'jenkins_notify_enabled', 'jenkins_last_notified_build']);
});
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('jenkins_deployments', function (Blueprint $table) {
$table->id();
$table->foreignId('project_id')->nullable()->constrained()->onDelete('cascade');
$table->integer('build_number');
$table->string('job_name', 255);
$table->string('status', 20)->comment('SUCCESS, FAILURE, ABORTED, UNSTABLE');
$table->string('branch', 255)->nullable();
$table->string('commit_sha', 64)->nullable();
$table->string('triggered_by', 100)->nullable();
$table->integer('duration')->nullable()->comment('构建耗时(毫秒)');
$table->string('build_url', 500)->nullable();
$table->json('raw_data')->nullable();
$table->boolean('notified')->default(false);
$table->timestamps();
$table->unique(['job_name', 'build_number']);
$table->index(['project_id', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('jenkins_deployments');
}
};

View File

@@ -0,0 +1,33 @@
<?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('scheduled_tasks', function (Blueprint $table) {
$table->id();
$table->string('name')->unique()->comment('任务唯一标识符');
$table->string('command')->comment('任务命令');
$table->string('description')->nullable()->comment('任务描述');
$table->string('frequency')->comment('执行频率描述');
$table->string('cron')->comment('Cron 表达式');
$table->boolean('enabled')->default(false)->comment('是否启用');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('scheduled_tasks');
}
};

View File

@@ -0,0 +1,28 @@
<?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::table('jenkins_deployments', function (Blueprint $table) {
$table->json('build_params')->nullable()->after('raw_data')->comment('构建参数');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('jenkins_deployments', function (Blueprint $table) {
$table->dropColumn('build_params');
});
}
};

View File

@@ -181,6 +181,22 @@
<input v-model="form.is_important" type="checkbox" id="is_important" class="rounded border-gray-300 text-yellow-500 focus:ring-yellow-500" /> <input v-model="form.is_important" type="checkbox" id="is_important" class="rounded border-gray-300 text-yellow-500 focus:ring-yellow-500" />
<label for="is_important" class="text-sm text-gray-700">标记为重要项目</label> <label for="is_important" class="text-sm text-gray-700">标记为重要项目</label>
</div> </div>
<!-- Jenkins 配置 -->
<div class="border-t border-gray-200 pt-4 mt-4">
<h5 class="text-sm font-medium text-gray-700 mb-3">Jenkins 发布通知</h5>
<div class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Jenkins Job 名称</label>
<input v-model="form.jenkins_job_name" type="text" class="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:ring-1 focus:ring-blue-500" placeholder="如: portal-be-deploy" />
</div>
<div class="flex items-center gap-2">
<input v-model="form.jenkins_notify_enabled" type="checkbox" id="jenkins_notify" class="rounded border-gray-300 text-orange-500 focus:ring-orange-500" />
<label for="jenkins_notify" class="text-sm text-gray-700">启用 Jenkins 发布通知</label>
</div>
</div>
</div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">日志 App 名称 (逗号分隔)</label> <label class="block text-sm font-medium text-gray-700 mb-1">日志 App 名称 (逗号分隔)</label>
<input v-model="form.log_app_names_text" type="text" class="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:ring-1 focus:ring-blue-500" placeholder="如: portal-api, portal-worker" /> <input v-model="form.log_app_names_text" type="text" class="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:ring-1 focus:ring-blue-500" placeholder="如: portal-api, portal-worker" />
@@ -309,6 +325,10 @@ const ProjectCard = {
<span class="text-gray-500">版本:</span> <span class="text-gray-500">版本:</span>
<span class="font-mono text-gray-700">{{ project.git_current_version }}</span> <span class="font-mono text-gray-700">{{ project.git_current_version }}</span>
</div> </div>
<div v-if="project.jenkins_job_name" class="flex items-center gap-2">
<span class="text-gray-500">Jenkins:</span>
<span class="font-mono bg-orange-50 text-orange-700 px-1.5 py-0.5 rounded text-xs">{{ project.jenkins_job_name }}</span>
</div>
<div v-if="project.log_app_names?.length" class="flex items-center gap-2"> <div v-if="project.log_app_names?.length" class="flex items-center gap-2">
<span class="text-gray-500">App:</span> <span class="text-gray-500">App:</span>
<span class="text-gray-700">{{ project.log_app_names.join(', ') }}</span> <span class="text-gray-700">{{ project.log_app_names.join(', ') }}</span>
@@ -333,6 +353,14 @@ const ProjectCard = {
</span> </span>
<span class="text-xs" :class="project.auto_create_release_branch ? 'text-purple-600' : 'text-gray-500'">自动创建分支</span> <span class="text-xs" :class="project.auto_create_release_branch ? 'text-purple-600' : 'text-gray-500'">自动创建分支</span>
</label> </label>
<label class="inline-flex items-center gap-1.5 cursor-pointer" @click.prevent="$emit('toggle-field', project, 'jenkins_notify_enabled')">
<span class="relative inline-block">
<input type="checkbox" :checked="project.jenkins_notify_enabled" class="sr-only peer" />
<span class="block w-8 h-4 bg-gray-200 rounded-full peer peer-checked:bg-orange-500 transition-colors"></span>
<span class="absolute left-0.5 top-0.5 w-3 h-3 bg-white rounded-full transition-transform peer-checked:translate-x-4"></span>
</span>
<span class="text-xs" :class="project.jenkins_notify_enabled ? 'text-orange-600' : 'text-gray-500'">Jenkins通知</span>
</label>
<label class="inline-flex items-center gap-1.5 cursor-pointer" @click.prevent="$emit('toggle-field', project, 'is_important')"> <label class="inline-flex items-center gap-1.5 cursor-pointer" @click.prevent="$emit('toggle-field', project, 'is_important')">
<span class="relative inline-block"> <span class="relative inline-block">
<input type="checkbox" :checked="project.is_important" class="sr-only peer" /> <input type="checkbox" :checked="project.is_important" class="sr-only peer" />
@@ -418,6 +446,8 @@ export default {
git_monitor_enabled: false, git_monitor_enabled: false,
auto_create_release_branch: false, auto_create_release_branch: false,
is_important: false, is_important: false,
jenkins_job_name: '',
jenkins_notify_enabled: false,
log_app_names_text: '', log_app_names_text: '',
log_env: 'production' log_env: 'production'
}; };
@@ -456,6 +486,8 @@ export default {
git_monitor_enabled: project.git_monitor_enabled || false, git_monitor_enabled: project.git_monitor_enabled || false,
auto_create_release_branch: project.auto_create_release_branch || false, auto_create_release_branch: project.auto_create_release_branch || false,
is_important: project.is_important || false, is_important: project.is_important || false,
jenkins_job_name: project.jenkins_job_name || '',
jenkins_notify_enabled: project.jenkins_notify_enabled || false,
log_app_names_text: (project.log_app_names || []).join(', '), log_app_names_text: (project.log_app_names || []).join(', '),
log_env: project.log_env || 'production' log_env: project.log_env || 'production'
}; };
@@ -483,6 +515,8 @@ export default {
git_monitor_enabled: this.form.git_monitor_enabled, git_monitor_enabled: this.form.git_monitor_enabled,
auto_create_release_branch: this.form.auto_create_release_branch, auto_create_release_branch: this.form.auto_create_release_branch,
is_important: this.form.is_important, is_important: this.form.is_important,
jenkins_job_name: this.form.jenkins_job_name || null,
jenkins_notify_enabled: this.form.jenkins_notify_enabled,
log_app_names: this.form.log_app_names_text ? this.form.log_app_names_text.split(',').map(s => s.trim()).filter(Boolean) : null, log_app_names: this.form.log_app_names_text ? this.form.log_app_names_text.split(',').map(s => s.trim()).filter(Boolean) : null,
log_env: this.form.log_env || null log_env: this.form.log_env || null
}; };

View File

@@ -10,6 +10,7 @@ use App\Http\Controllers\SqlGeneratorController;
use App\Http\Controllers\Admin\AdminMetaController; use App\Http\Controllers\Admin\AdminMetaController;
use App\Http\Controllers\Admin\ConfigController; use App\Http\Controllers\Admin\ConfigController;
use App\Http\Controllers\Admin\IpUserMappingController; use App\Http\Controllers\Admin\IpUserMappingController;
use App\Http\Controllers\Admin\JenkinsDeploymentController;
use App\Http\Controllers\Admin\OperationLogController; use App\Http\Controllers\Admin\OperationLogController;
use App\Http\Controllers\Admin\ProjectController; use App\Http\Controllers\Admin\ProjectController;
use App\Http\Controllers\Admin\ScheduledTaskController; use App\Http\Controllers\Admin\ScheduledTaskController;
@@ -94,6 +95,10 @@ Route::prefix('admin')->middleware('admin.ip')->group(function () {
// 定时任务管理 // 定时任务管理
Route::get('/scheduled-tasks', [ScheduledTaskController::class, 'index']); Route::get('/scheduled-tasks', [ScheduledTaskController::class, 'index']);
Route::post('/scheduled-tasks/{name}/toggle', [ScheduledTaskController::class, 'toggle']); Route::post('/scheduled-tasks/{name}/toggle', [ScheduledTaskController::class, 'toggle']);
// Jenkins 发布历史
Route::get('/jenkins/deployments', [JenkinsDeploymentController::class, 'index']);
Route::get('/jenkins/deployments/{id}', [JenkinsDeploymentController::class, 'show']);
}); });
// 日志分析 API 路由 // 日志分析 API 路由

View File

@@ -25,16 +25,14 @@ Schedule::command('git-monitor:check')
->everyTenMinutes() ->everyTenMinutes()
->withoutOverlapping() ->withoutOverlapping()
->runInBackground() ->runInBackground()
->name('git-monitor-check') ->description('git-monitor-check')
->description('Git 监控 - 检查 release 分支变化')
->when(fn() => \App\Services\ScheduledTaskService::isEnabled('git-monitor-check')); ->when(fn() => \App\Services\ScheduledTaskService::isEnabled('git-monitor-check'));
// Git Monitor - 每天凌晨 2 点刷新 release 缓存 // Git Monitor - 每天凌晨 2 点刷新 release 缓存
Schedule::command('git-monitor:cache') Schedule::command('git-monitor:cache')
->dailyAt('02:00') ->dailyAt('02:00')
->withoutOverlapping() ->withoutOverlapping()
->name('git-monitor-cache') ->description('git-monitor-cache')
->description('Git 监控 - 刷新 release 缓存')
->when(fn() => \App\Services\ScheduledTaskService::isEnabled('git-monitor-cache')); ->when(fn() => \App\Services\ScheduledTaskService::isEnabled('git-monitor-cache'));
// SLS 日志分析 - 每天凌晨 2 点执行 // SLS 日志分析 - 每天凌晨 2 点执行
@@ -42,8 +40,7 @@ Schedule::command('log-analysis:run --from="-24h" --to="now" --query="ERROR or W
->dailyAt('02:00') ->dailyAt('02:00')
->withoutOverlapping() ->withoutOverlapping()
->runInBackground() ->runInBackground()
->name('daily-log-analysis') ->description('daily-log-analysis')
->description('SLS 日志分析 - 每日分析过去 24 小时日志')
->when(fn() => \App\Services\ScheduledTaskService::isEnabled('daily-log-analysis')) ->when(fn() => \App\Services\ScheduledTaskService::isEnabled('daily-log-analysis'))
->onFailure(fn() => Log::error('每日日志分析定时任务执行失败')); ->onFailure(fn() => Log::error('每日日志分析定时任务执行失败'));
@@ -52,7 +49,21 @@ Schedule::command('log-analysis:run --from="-6h" --to="now" --query="ERROR or WA
->everyFourHours() ->everyFourHours()
->withoutOverlapping() ->withoutOverlapping()
->runInBackground() ->runInBackground()
->name('frequent-log-analysis') ->description('frequent-log-analysis')
->description('SLS 日志分析 - 定期分析过去 6 小时日志')
->when(fn() => \App\Services\ScheduledTaskService::isEnabled('frequent-log-analysis')) ->when(fn() => \App\Services\ScheduledTaskService::isEnabled('frequent-log-analysis'))
->onFailure(fn() => Log::error('SLS 日志分析定时任务执行失败')); ->onFailure(fn() => Log::error('SLS 日志分析定时任务执行失败'));
// Jenkins Monitor - 每分钟检查新构建
Schedule::command('jenkins:monitor')
->everyMinute()
->withoutOverlapping()
->runInBackground()
->description('jenkins-monitor')
->when(fn() => \App\Services\ScheduledTaskService::isEnabled('jenkins-monitor'));
// 定时任务刷新 - 每天凌晨 3 点刷新定时任务列表
Schedule::command('scheduled-task:refresh')
->dailyAt('03:00')
->withoutOverlapping()
->description('scheduled-task-refresh')
->when(fn() => \App\Services\ScheduledTaskService::isEnabled('scheduled-task-refresh'));