From da3b05b7c0dd150e9addfa251c26efd78b38b9d9 Mon Sep 17 00:00:00 2001 From: tradewind Date: Mon, 19 Jan 2026 11:46:38 +0800 Subject: [PATCH] #feature: add Jenkins deploy monitor & log clean task --- .env.example | 9 + app/Clients/JenkinsClient.php | 97 ++++++ .../CleanScheduledTaskLogsCommand.php | 72 +++++ .../Commands/JenkinsMonitorCommand.php | 41 +++ .../Commands/ScheduledTaskRefreshCommand.php | 40 +++ .../Admin/JenkinsDeploymentController.php | 41 +++ .../Controllers/Admin/ProjectController.php | 4 + app/Models/JenkinsDeployment.php | 69 ++++ app/Models/Project.php | 15 + app/Models/ScheduledTask.php | 21 ++ app/Services/CodeContextService.php | 74 +---- app/Services/JenkinsMonitorService.php | 294 ++++++++++++++++++ app/Services/ScheduledTaskService.php | 30 +- config/jenkins.php | 8 + config/logging.php | 33 ++ ...0_add_jenkins_fields_to_projects_table.php | 24 ++ ...50001_create_jenkins_deployments_table.php | 35 +++ ...19_102846_create_scheduled_tasks_table.php | 33 ++ ...ld_params_to_jenkins_deployments_table.php | 28 ++ .../js/components/admin/ProjectManagement.vue | 34 ++ routes/api.php | 5 + routes/console.php | 41 ++- 22 files changed, 968 insertions(+), 80 deletions(-) create mode 100644 app/Clients/JenkinsClient.php create mode 100644 app/Console/Commands/CleanScheduledTaskLogsCommand.php create mode 100644 app/Console/Commands/JenkinsMonitorCommand.php create mode 100644 app/Console/Commands/ScheduledTaskRefreshCommand.php create mode 100644 app/Http/Controllers/Admin/JenkinsDeploymentController.php create mode 100644 app/Models/JenkinsDeployment.php create mode 100644 app/Models/ScheduledTask.php create mode 100644 app/Services/JenkinsMonitorService.php create mode 100644 config/jenkins.php create mode 100644 database/migrations/2026_01_16_150000_add_jenkins_fields_to_projects_table.php create mode 100644 database/migrations/2026_01_16_150001_create_jenkins_deployments_table.php create mode 100644 database/migrations/2026_01_19_102846_create_scheduled_tasks_table.php create mode 100644 database/migrations/2026_01_19_112350_add_build_params_to_jenkins_deployments_table.php diff --git a/.env.example b/.env.example index 0b89b50..fca9a92 100644 --- a/.env.example +++ b/.env.example @@ -126,3 +126,12 @@ AI_TEMPERATURE=0.3 AI_TIMEOUT=120 AI_MAX_TOKENS=4096 +DINGTALK_WEBHOOK= +DINGTALK_SECRET= + +# Jenkins Configuration +JENKINS_HOST=http://jenkins.example.com +JENKINS_USERNAME= +JENKINS_API_TOKEN= +JENKINS_TIMEOUT=30 + diff --git a/app/Clients/JenkinsClient.php b/app/Clients/JenkinsClient.php new file mode 100644 index 0000000..5d8a382 --- /dev/null +++ b/app/Clients/JenkinsClient.php @@ -0,0 +1,97 @@ +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; + } + } +} diff --git a/app/Console/Commands/CleanScheduledTaskLogsCommand.php b/app/Console/Commands/CleanScheduledTaskLogsCommand.php new file mode 100644 index 0000000..6e3de07 --- /dev/null +++ b/app/Console/Commands/CleanScheduledTaskLogsCommand.php @@ -0,0 +1,72 @@ +option('days'); + $logPath = storage_path('logs/scheduled-tasks'); + + if (!File::exists($logPath)) { + $this->info('日志目录不存在,无需清理'); + return Command::SUCCESS; + } + + $cutoffDate = Carbon::now()->subDays($days); + $this->info("开始清理 {$days} 天前的定时任务日志..."); + $this->info("截止日期: {$cutoffDate->format('Y-m-d')}"); + + $files = File::files($logPath); + $deletedCount = 0; + $totalSize = 0; + + foreach ($files as $file) { + $filename = $file->getFilename(); + + // 匹配日志文件名格式: task-name-YYYY-MM-DD.log + if (preg_match('/-(\d{4}-\d{2}-\d{2})\.log$/', $filename, $matches)) { + $fileDate = Carbon::parse($matches[1]); + + if ($fileDate->lt($cutoffDate)) { + $fileSize = $file->getSize(); + $totalSize += $fileSize; + + File::delete($file->getPathname()); + $deletedCount++; + + $this->line("已删除: {$filename} (" . $this->formatBytes($fileSize) . ")"); + } + } + } + + if ($deletedCount > 0) { + $this->info("清理完成!共删除 {$deletedCount} 个日志文件,释放空间: " . $this->formatBytes($totalSize)); + } else { + $this->info('没有需要清理的日志文件'); + } + + return Command::SUCCESS; + } + + private function formatBytes(int $bytes): string + { + if ($bytes >= 1073741824) { + return number_format($bytes / 1073741824, 2) . ' GB'; + } elseif ($bytes >= 1048576) { + return number_format($bytes / 1048576, 2) . ' MB'; + } elseif ($bytes >= 1024) { + return number_format($bytes / 1024, 2) . ' KB'; + } + return $bytes . ' B'; + } +} diff --git a/app/Console/Commands/JenkinsMonitorCommand.php b/app/Console/Commands/JenkinsMonitorCommand.php new file mode 100644 index 0000000..033fabf --- /dev/null +++ b/app/Console/Commands/JenkinsMonitorCommand.php @@ -0,0 +1,41 @@ +info('开始检查 Jenkins 构建...'); + + $results = $service->checkAllProjects(); + + if (isset($results['skipped'])) { + $this->warn('跳过检查: ' . ($results['reason'] ?? 'unknown')); + return; + } + + foreach ($results as $slug => $result) { + if (isset($result['skipped'])) { + $this->line(sprintf('[%s] 跳过: %s', $slug, $result['reason'] ?? 'unknown')); + continue; + } + + $newBuilds = $result['new_builds'] ?? []; + if (empty($newBuilds)) { + $this->line(sprintf('[%s] 无新构建', $slug)); + } else { + $this->info(sprintf('[%s] 发现 %d 个新构建: #%s', $slug, count($newBuilds), implode(', #', $newBuilds))); + } + } + + $this->info('检查完成'); + } +} diff --git a/app/Console/Commands/ScheduledTaskRefreshCommand.php b/app/Console/Commands/ScheduledTaskRefreshCommand.php new file mode 100644 index 0000000..ca80840 --- /dev/null +++ b/app/Console/Commands/ScheduledTaskRefreshCommand.php @@ -0,0 +1,40 @@ +info('开始刷新定时任务列表...'); + + $tasks = $taskService->getAllTasks(); + + $this->info(sprintf('成功刷新 %d 个定时任务', count($tasks))); + + // 显示任务列表 + $this->table( + ['任务名称', '描述', '执行频率', '状态'], + array_map(fn($task) => [ + $task['name'], + $task['description'], + $task['frequency'], + $task['enabled'] ? '已启用' : '已禁用', + ], $tasks) + ); + + return Command::SUCCESS; + } catch (\Exception $e) { + $this->error("刷新失败: {$e->getMessage()}"); + return Command::FAILURE; + } + } +} diff --git a/app/Http/Controllers/Admin/JenkinsDeploymentController.php b/app/Http/Controllers/Admin/JenkinsDeploymentController.php new file mode 100644 index 0000000..3c5f2a8 --- /dev/null +++ b/app/Http/Controllers/Admin/JenkinsDeploymentController.php @@ -0,0 +1,41 @@ +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); + } +} diff --git a/app/Http/Controllers/Admin/ProjectController.php b/app/Http/Controllers/Admin/ProjectController.php index 12c6016..2403b6b 100644 --- a/app/Http/Controllers/Admin/ProjectController.php +++ b/app/Http/Controllers/Admin/ProjectController.php @@ -82,6 +82,8 @@ class ProjectController extends Controller 'log_app_names' => ['nullable', 'array'], 'log_app_names.*' => ['string', 'max:100'], 'log_env' => ['nullable', 'string', 'max:50'], + 'jenkins_job_name' => ['nullable', 'string', 'max:255'], + 'jenkins_notify_enabled' => ['nullable', 'boolean'], ]); $project = $this->projectService->create($data); @@ -126,6 +128,8 @@ class ProjectController extends Controller 'log_app_names' => ['nullable', 'array'], 'log_app_names.*' => ['string', 'max:100'], 'log_env' => ['nullable', 'string', 'max:50'], + 'jenkins_job_name' => ['nullable', 'string', 'max:255'], + 'jenkins_notify_enabled' => ['nullable', 'boolean'], ]); $project = $this->projectService->update($project, $data); diff --git a/app/Models/JenkinsDeployment.php b/app/Models/JenkinsDeployment.php new file mode 100644 index 0000000..8597788 --- /dev/null +++ b/app/Models/JenkinsDeployment.php @@ -0,0 +1,69 @@ + '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 => '未知', + }; + } +} diff --git a/app/Models/Project.php b/app/Models/Project.php index 68067aa..defe323 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -20,12 +20,16 @@ class Project extends BaseModel 'git_version_cached_at', 'log_app_names', 'log_env', + 'jenkins_job_name', + 'jenkins_notify_enabled', + 'jenkins_last_notified_build', ]; protected $casts = [ 'git_monitor_enabled' => 'boolean', 'auto_create_release_branch' => 'boolean', 'is_important' => 'boolean', + 'jenkins_notify_enabled' => 'boolean', 'log_app_names' => 'array', 'git_version_cached_at' => 'datetime', ]; @@ -86,4 +90,15 @@ class Project extends BaseModel ->whereJsonContains('log_app_names', $appName) ->first(); } + + /** + * 获取所有启用 Jenkins 通知的项目 + */ + public static function getJenkinsNotifyEnabled(): \Illuminate\Database\Eloquent\Collection + { + return static::query() + ->where('jenkins_notify_enabled', true) + ->whereNotNull('jenkins_job_name') + ->get(); + } } diff --git a/app/Models/ScheduledTask.php b/app/Models/ScheduledTask.php new file mode 100644 index 0000000..aa98e73 --- /dev/null +++ b/app/Models/ScheduledTask.php @@ -0,0 +1,21 @@ + 'boolean', + ]; +} diff --git a/app/Services/CodeContextService.php b/app/Services/CodeContextService.php index 592f7a8..95487f8 100644 --- a/app/Services/CodeContextService.php +++ b/app/Services/CodeContextService.php @@ -11,8 +11,7 @@ class CodeContextService private int $contextLines = 10; public function __construct( - private readonly ConfigService $configService, - private readonly EnvService $envService + private readonly ConfigService $configService ) {} /** @@ -23,49 +22,16 @@ class CodeContextService */ public function getRepoPath(string $appName): ?string { - // 优先从 Project 模型查找 + // 从 Project 模型查找,直接使用项目路径 $project = Project::findByAppName($appName); if ($project) { - $env = $project->log_env ?? 'production'; - try { - $envContent = $this->envService->getEnvContent($project->slug, $env); - $repoPath = $this->parseEnvValue($envContent, 'LOG_ANALYSIS_CODE_REPO_PATH'); - - if ($repoPath && is_dir($repoPath)) { - return $repoPath; - } - } catch (\Exception $e) { - // 忽略错误,继续尝试旧配置 + $projectsPath = $this->configService->get('workspace.projects_path', ''); + if ($projectsPath && $project->isPathValid($projectsPath)) { + return $project->getFullPath($projectsPath); } } - // 回退到旧的配置方式(兼容迁移前的情况) - $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; } @@ -246,36 +212,6 @@ class CodeContextService 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; - } - /** * 设置上下文行数 */ diff --git a/app/Services/JenkinsMonitorService.php b/app/Services/JenkinsMonitorService.php new file mode 100644 index 0000000..2af7ecf --- /dev/null +++ b/app/Services/JenkinsMonitorService.php @@ -0,0 +1,294 @@ +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 []; + } +} diff --git a/app/Services/ScheduledTaskService.php b/app/Services/ScheduledTaskService.php index e02cedf..96d2233 100644 --- a/app/Services/ScheduledTaskService.php +++ b/app/Services/ScheduledTaskService.php @@ -44,7 +44,7 @@ class ScheduledTaskService $tasks[] = [ 'name' => $name, 'command' => $this->getEventCommand($event), - 'description' => $event->description ?: $name, + 'description' => $this->getTaskDescription($name), 'frequency' => $this->getFrequencyLabel($event->expression), 'cron' => $event->expression, 'enabled' => $enabledTasks[$name] ?? false, @@ -92,9 +92,15 @@ class ScheduledTaskService private function getEventName($event): string { - if (property_exists($event, 'mutexName') && $event->mutexName) { - return $event->mutexName; + // Laravel Schedule 事件的 description 属性存储任务名称 + // 我们在 routes/console.php 中通过 ->description() 设置 + + // 1. 优先使用 description (我们设置的任务标识符) + if (property_exists($event, 'description') && $event->description) { + return $event->description; } + + // 2. 最后使用命令作为名称 return $this->getEventCommand($event); } @@ -125,9 +131,27 @@ class ScheduledTaskService '0 */12 * * *' => '每 12 小时', '0 0 * * *' => '每天凌晨 0:00', '0 2 * * *' => '每天凌晨 2:00', + '0 3 * * *' => '每天凌晨 3:00', '0 0 * * 0' => '每周日凌晨', '0 0 1 * *' => '每月 1 日凌晨', ]; 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; + } } diff --git a/config/jenkins.php b/config/jenkins.php new file mode 100644 index 0000000..61e9c44 --- /dev/null +++ b/config/jenkins.php @@ -0,0 +1,8 @@ + env('JENKINS_HOST'), + 'username' => env('JENKINS_USERNAME'), + 'api_token' => env('JENKINS_API_TOKEN'), + 'timeout' => (int) env('JENKINS_TIMEOUT', 30), +]; diff --git a/config/logging.php b/config/logging.php index 9e998a4..f2593a4 100644 --- a/config/logging.php +++ b/config/logging.php @@ -127,6 +127,39 @@ return [ 'path' => storage_path('logs/laravel.log'), ], + // 定时任务日志通道 + 'scheduled-tasks' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/scheduled-tasks/scheduled-tasks.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, + ], + + 'jenkins-monitor' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/scheduled-tasks/jenkins-monitor.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => 7, + 'replace_placeholders' => true, + ], + ], ]; diff --git a/database/migrations/2026_01_16_150000_add_jenkins_fields_to_projects_table.php b/database/migrations/2026_01_16_150000_add_jenkins_fields_to_projects_table.php new file mode 100644 index 0000000..e3b0458 --- /dev/null +++ b/database/migrations/2026_01_16_150000_add_jenkins_fields_to_projects_table.php @@ -0,0 +1,24 @@ +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']); + }); + } +}; diff --git a/database/migrations/2026_01_16_150001_create_jenkins_deployments_table.php b/database/migrations/2026_01_16_150001_create_jenkins_deployments_table.php new file mode 100644 index 0000000..3de17a5 --- /dev/null +++ b/database/migrations/2026_01_16_150001_create_jenkins_deployments_table.php @@ -0,0 +1,35 @@ +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'); + } +}; diff --git a/database/migrations/2026_01_19_102846_create_scheduled_tasks_table.php b/database/migrations/2026_01_19_102846_create_scheduled_tasks_table.php new file mode 100644 index 0000000..1c3283a --- /dev/null +++ b/database/migrations/2026_01_19_102846_create_scheduled_tasks_table.php @@ -0,0 +1,33 @@ +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'); + } +}; diff --git a/database/migrations/2026_01_19_112350_add_build_params_to_jenkins_deployments_table.php b/database/migrations/2026_01_19_112350_add_build_params_to_jenkins_deployments_table.php new file mode 100644 index 0000000..fd8adbe --- /dev/null +++ b/database/migrations/2026_01_19_112350_add_build_params_to_jenkins_deployments_table.php @@ -0,0 +1,28 @@ +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'); + }); + } +}; diff --git a/resources/js/components/admin/ProjectManagement.vue b/resources/js/components/admin/ProjectManagement.vue index 2f2fe90..9ffe64d 100644 --- a/resources/js/components/admin/ProjectManagement.vue +++ b/resources/js/components/admin/ProjectManagement.vue @@ -181,6 +181,22 @@ + + +
+
Jenkins 发布通知
+
+
+ + +
+
+ + +
+
+
+
@@ -309,6 +325,10 @@ const ProjectCard = { 版本: {{ project.git_current_version }}
+
+ Jenkins: + {{ project.jenkins_job_name }} +
App: {{ project.log_app_names.join(', ') }} @@ -333,6 +353,14 @@ const ProjectCard = { 自动创建分支 +