#feature: add Jenkins batch build trigger
Add an admin Jenkins build page that reuses notification-enabled project config, loads Jenkins build parameters, supports persisted selections, and triggers selected jobs in one action.
This commit is contained in:
@@ -2,14 +2,18 @@
|
|||||||
|
|
||||||
namespace App\Clients;
|
namespace App\Clients;
|
||||||
|
|
||||||
|
use Illuminate\Http\Client\PendingRequest;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class JenkinsClient
|
class JenkinsClient
|
||||||
{
|
{
|
||||||
private ?string $host;
|
private ?string $host;
|
||||||
|
|
||||||
private ?string $username;
|
private ?string $username;
|
||||||
|
|
||||||
private ?string $apiToken;
|
private ?string $apiToken;
|
||||||
|
|
||||||
private int $timeout;
|
private int $timeout;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
@@ -28,17 +32,110 @@ class JenkinsClient
|
|||||||
|
|
||||||
public function getJobInfo(string $jobName): ?array
|
public function getJobInfo(string $jobName): ?array
|
||||||
{
|
{
|
||||||
return $this->request("/job/{$jobName}/api/json");
|
return $this->request($this->getJobPath($jobName).'/api/json');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getBuildInfo(string $jobName, int $buildNumber): ?array
|
public function getBuildInfo(string $jobName, int $buildNumber): ?array
|
||||||
{
|
{
|
||||||
return $this->request("/job/{$jobName}/{$buildNumber}/api/json");
|
return $this->request($this->getJobPath($jobName)."/{$buildNumber}/api/json");
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getLastBuild(string $jobName): ?array
|
public function getLastBuild(string $jobName): ?array
|
||||||
{
|
{
|
||||||
return $this->request("/job/{$jobName}/lastBuild/api/json");
|
return $this->request($this->getJobPath($jobName).'/lastBuild/api/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getParameterDefinitions(string $jobName): array
|
||||||
|
{
|
||||||
|
$jobInfo = $this->getJobInfo($jobName);
|
||||||
|
if (! $jobInfo || empty($jobInfo['property'])) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$buildFormParameters = $this->getBuildFormParameters($jobName);
|
||||||
|
|
||||||
|
foreach ($jobInfo['property'] as $property) {
|
||||||
|
if (empty($property['parameterDefinitions']) || ! is_array($property['parameterDefinitions'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_map(function (array $definition) use ($buildFormParameters) {
|
||||||
|
$name = $definition['name'] ?? '';
|
||||||
|
$formParameter = $buildFormParameters[$name] ?? [];
|
||||||
|
$default = $definition['defaultParameterValue']['value'] ?? null;
|
||||||
|
if (($formParameter['multiple'] ?? false) && isset($formParameter['default'])) {
|
||||||
|
$default = $formParameter['default'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => $name,
|
||||||
|
'type' => $definition['type'] ?? $definition['_class'] ?? 'StringParameterDefinition',
|
||||||
|
'description' => $definition['description'] ?? '',
|
||||||
|
'default' => $default,
|
||||||
|
'choices' => $definition['choices'] ?? $formParameter['choices'] ?? [],
|
||||||
|
'multiple' => (bool) ($formParameter['multiple'] ?? false),
|
||||||
|
];
|
||||||
|
}, array_values(array_filter($property['parameterDefinitions'], fn ($definition) => ! empty($definition['name']))));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function triggerBuild(string $jobName, array $parameters = []): array
|
||||||
|
{
|
||||||
|
if (! $this->isConfigured()) {
|
||||||
|
Log::warning('Jenkins client is not configured');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Jenkins not configured',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $this->getJobPath($jobName).(empty($parameters) ? '/build' : '/buildWithParameters');
|
||||||
|
$url = $this->host.$path;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$request = $this->http();
|
||||||
|
$crumb = $this->getCrumb();
|
||||||
|
if ($crumb) {
|
||||||
|
$request = $request->withHeaders([$crumb['field'] => $crumb['crumb']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = empty($parameters)
|
||||||
|
? $request->post($url)
|
||||||
|
: $request->asForm()->post($url, $parameters);
|
||||||
|
|
||||||
|
if ($response->successful() || $response->status() === 201) {
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'queue_url' => $response->header('Location'),
|
||||||
|
'status' => $response->status(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::warning('Jenkins build trigger failed', [
|
||||||
|
'url' => $url,
|
||||||
|
'status' => $response->status(),
|
||||||
|
'body' => $response->body(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Jenkins 返回状态码 '.$response->status(),
|
||||||
|
'status' => $response->status(),
|
||||||
|
];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Jenkins build trigger error', [
|
||||||
|
'url' => $url,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getBuilds(string $jobName, int $limit = 10): array
|
public function getBuilds(string $jobName, int $limit = 10): array
|
||||||
@@ -65,15 +162,14 @@ class JenkinsClient
|
|||||||
{
|
{
|
||||||
if (! $this->isConfigured()) {
|
if (! $this->isConfigured()) {
|
||||||
Log::warning('Jenkins client is not configured');
|
Log::warning('Jenkins client is not configured');
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$url = $this->host.$path;
|
$url = $this->host.$path;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$response = Http::timeout($this->timeout)
|
$response = $this->http()->get($url);
|
||||||
->withBasicAuth($this->username, $this->apiToken)
|
|
||||||
->get($url);
|
|
||||||
|
|
||||||
if ($response->successful()) {
|
if ($response->successful()) {
|
||||||
return $response->json();
|
return $response->json();
|
||||||
@@ -94,4 +190,117 @@ class JenkinsClient
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function requestBody(string $path, bool $allowMethodNotAllowed = false): ?string
|
||||||
|
{
|
||||||
|
if (! $this->isConfigured()) {
|
||||||
|
Log::warning('Jenkins client is not configured');
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = $this->host.$path;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->http()->get($url);
|
||||||
|
|
||||||
|
if ($response->successful() || ($allowMethodNotAllowed && $response->status() === 405)) {
|
||||||
|
return $response->body();
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::warning('Jenkins page request failed', [
|
||||||
|
'url' => $url,
|
||||||
|
'status' => $response->status(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Jenkins page request error', [
|
||||||
|
'url' => $url,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getBuildFormParameters(string $jobName): array
|
||||||
|
{
|
||||||
|
$html = $this->requestBody($this->getJobPath($jobName).'/build?delay=0sec', allowMethodNotAllowed: true);
|
||||||
|
if (! $html) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$parameters = [];
|
||||||
|
foreach (array_slice(preg_split('/<div name="parameter"[^>]*>/s', $html) ?: [], 1) as $parameterHtml) {
|
||||||
|
if (! preg_match('/<input name="name" type="hidden" value="([^"]+)"/s', $parameterHtml, $nameMatch)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! preg_match('/<select([^>]*)>(.*?)<\/select>/s', $parameterHtml, $selectMatch)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = html_entity_decode($nameMatch[1], ENT_QUOTES | ENT_HTML5);
|
||||||
|
$selectAttributes = $selectMatch[1];
|
||||||
|
$optionsHtml = $selectMatch[2];
|
||||||
|
$multiple = str_contains($selectAttributes, 'multiple');
|
||||||
|
|
||||||
|
preg_match_all('/<option([^>]*) value="([^"]*)"[^>]*>(.*?)<\/option>/s', $optionsHtml, $optionMatches, PREG_SET_ORDER);
|
||||||
|
|
||||||
|
$choices = [];
|
||||||
|
$defaults = [];
|
||||||
|
foreach ($optionMatches as $optionMatch) {
|
||||||
|
$attributes = $optionMatch[1];
|
||||||
|
$value = html_entity_decode($optionMatch[2], ENT_QUOTES | ENT_HTML5);
|
||||||
|
$label = trim(strip_tags(html_entity_decode($optionMatch[3], ENT_QUOTES | ENT_HTML5)));
|
||||||
|
$selected = str_contains($attributes, 'selected') || str_contains($label, '√');
|
||||||
|
$label = trim(str_replace('√', '', $label));
|
||||||
|
|
||||||
|
$choices[] = [
|
||||||
|
'value' => $value,
|
||||||
|
'label' => $label ?: $value,
|
||||||
|
'selected' => $selected,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($selected) {
|
||||||
|
$defaults[] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$parameters[$name] = [
|
||||||
|
'choices' => $choices,
|
||||||
|
'default' => $multiple ? $defaults : ($defaults[0] ?? null),
|
||||||
|
'multiple' => $multiple,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $parameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function http(): PendingRequest
|
||||||
|
{
|
||||||
|
return Http::timeout($this->timeout)
|
||||||
|
->withBasicAuth($this->username, $this->apiToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getCrumb(): ?array
|
||||||
|
{
|
||||||
|
$crumb = $this->request('/crumbIssuer/api/json');
|
||||||
|
if (! $crumb || empty($crumb['crumb']) || empty($crumb['crumbRequestField'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'field' => $crumb['crumbRequestField'],
|
||||||
|
'crumb' => $crumb['crumb'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getJobPath(string $jobName): string
|
||||||
|
{
|
||||||
|
$segments = array_filter(explode('/', trim($jobName, '/')), fn ($segment) => $segment !== '');
|
||||||
|
|
||||||
|
return '/job/'.implode('/job/', array_map('rawurlencode', $segments));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Clients\JenkinsClient;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Project;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class JenkinsBuildController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly JenkinsClient $jenkinsClient
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function projects(): JsonResponse
|
||||||
|
{
|
||||||
|
if (! $this->jenkinsClient->isConfigured()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Jenkins 未配置,请先配置 JENKINS_HOST、JENKINS_USERNAME、JENKINS_API_TOKEN',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$projects = Project::getJenkinsNotifyEnabled()
|
||||||
|
->map(function (Project $project) {
|
||||||
|
return [
|
||||||
|
'id' => $project->id,
|
||||||
|
'slug' => $project->slug,
|
||||||
|
'name' => $project->name,
|
||||||
|
'jenkins_job_name' => $project->jenkins_job_name,
|
||||||
|
'parameters' => $this->jenkinsClient->getParameterDefinitions($project->jenkins_job_name),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'projects' => $projects,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function trigger(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
if (! $this->jenkinsClient->isConfigured()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Jenkins 未配置,请先配置 Jenkins 连接信息',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$projectSlugs = Project::getJenkinsNotifyEnabled()->pluck('slug')->all();
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'builds' => ['required', 'array', 'min:1'],
|
||||||
|
'builds.*.project_slug' => ['required', 'string', Rule::in($projectSlugs)],
|
||||||
|
'builds.*.parameters' => ['nullable', 'array'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$projects = Project::getJenkinsNotifyEnabled()->keyBy('slug');
|
||||||
|
$results = [];
|
||||||
|
$successCount = 0;
|
||||||
|
|
||||||
|
foreach ($data['builds'] as $build) {
|
||||||
|
/** @var Project $project */
|
||||||
|
$project = $projects[$build['project_slug']];
|
||||||
|
$parameters = $this->normalizeParameters($build['parameters'] ?? []);
|
||||||
|
$result = $this->jenkinsClient->triggerBuild($project->jenkins_job_name, $parameters);
|
||||||
|
|
||||||
|
if ($result['success'] ?? false) {
|
||||||
|
$successCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$results[] = [
|
||||||
|
'project_slug' => $project->slug,
|
||||||
|
'project_name' => $project->name,
|
||||||
|
'job_name' => $project->jenkins_job_name,
|
||||||
|
'success' => (bool) ($result['success'] ?? false),
|
||||||
|
'message' => $result['message'] ?? null,
|
||||||
|
'queue_url' => $result['queue_url'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => $successCount === count($results),
|
||||||
|
'message' => sprintf('触发完成:成功 %d 个,失败 %d 个', $successCount, count($results) - $successCount),
|
||||||
|
'data' => [
|
||||||
|
'results' => $results,
|
||||||
|
],
|
||||||
|
], $successCount > 0 ? 200 : 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeParameters(array $parameters): array
|
||||||
|
{
|
||||||
|
return collect($parameters)
|
||||||
|
->filter(fn ($value) => $value !== null && $value !== '')
|
||||||
|
->map(function ($value) {
|
||||||
|
if (is_array($value)) {
|
||||||
|
return implode(',', array_filter($value, fn ($item) => $item !== null && $item !== ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_bool($value) ? ($value ? 'true' : 'false') : $value;
|
||||||
|
})
|
||||||
|
->filter(fn ($value) => $value !== '')
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,6 +79,9 @@
|
|||||||
|
|
||||||
<!-- 定时任务管理页面 -->
|
<!-- 定时任务管理页面 -->
|
||||||
<scheduled-tasks v-else-if="currentPage === 'scheduled-tasks'" />
|
<scheduled-tasks v-else-if="currentPage === 'scheduled-tasks'" />
|
||||||
|
|
||||||
|
<!-- Jenkins 一键构建页面 -->
|
||||||
|
<jenkins-builds v-else-if="currentPage === 'jenkins-builds'" />
|
||||||
</admin-layout>
|
</admin-layout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -99,6 +102,7 @@ import OperationLogs from './OperationLogs.vue';
|
|||||||
import IpUserMappings from './IpUserMappings.vue';
|
import IpUserMappings from './IpUserMappings.vue';
|
||||||
import ProjectManagement from './ProjectManagement.vue';
|
import ProjectManagement from './ProjectManagement.vue';
|
||||||
import ScheduledTasks from './ScheduledTasks.vue';
|
import ScheduledTasks from './ScheduledTasks.vue';
|
||||||
|
import JenkinsBuilds from './JenkinsBuilds.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'AdminDashboard',
|
name: 'AdminDashboard',
|
||||||
@@ -118,7 +122,8 @@ export default {
|
|||||||
OperationLogs,
|
OperationLogs,
|
||||||
IpUserMappings,
|
IpUserMappings,
|
||||||
ProjectManagement,
|
ProjectManagement,
|
||||||
ScheduledTasks
|
ScheduledTasks,
|
||||||
|
JenkinsBuilds
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -148,7 +153,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
handleMenuChange(menu) {
|
handleMenuChange(menu) {
|
||||||
if ((menu === 'ip-mappings' || menu === 'projects' || menu === 'scheduled-tasks') && !this.isAdmin) {
|
if ((menu === 'ip-mappings' || menu === 'projects' || menu === 'scheduled-tasks' || menu === 'jenkins-builds') && !this.isAdmin) {
|
||||||
this.redirectToDefault();
|
this.redirectToDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -171,7 +176,8 @@ export default {
|
|||||||
'logs': '操作日志',
|
'logs': '操作日志',
|
||||||
'ip-mappings': 'IP 用户映射',
|
'ip-mappings': 'IP 用户映射',
|
||||||
'projects': '项目配置管理',
|
'projects': '项目配置管理',
|
||||||
'scheduled-tasks': '定时任务管理'
|
'scheduled-tasks': '定时任务管理',
|
||||||
|
'jenkins-builds': 'Jenkins 一键构建'
|
||||||
};
|
};
|
||||||
|
|
||||||
this.pageTitle = titles[menu] || '环境配置管理';
|
this.pageTitle = titles[menu] || '环境配置管理';
|
||||||
@@ -211,9 +217,11 @@ export default {
|
|||||||
page = 'projects';
|
page = 'projects';
|
||||||
} else if (path === '/scheduled-tasks') {
|
} else if (path === '/scheduled-tasks') {
|
||||||
page = 'scheduled-tasks';
|
page = 'scheduled-tasks';
|
||||||
|
} else if (path === '/jenkins-builds') {
|
||||||
|
page = 'jenkins-builds';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((page === 'ip-mappings' || page === 'projects' || page === 'scheduled-tasks') && !this.isAdmin) {
|
if ((page === 'ip-mappings' || page === 'projects' || page === 'scheduled-tasks' || page === 'jenkins-builds') && !this.isAdmin) {
|
||||||
this.redirectToDefault();
|
this.redirectToDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,31 @@
|
|||||||
定时任务
|
定时任务
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
@click.prevent="setActiveMenu('jenkins-builds')"
|
||||||
|
v-if="isAdmin"
|
||||||
|
:class="[
|
||||||
|
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
|
||||||
|
activeMenu === 'jenkins-builds'
|
||||||
|
? '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 === 'jenkins-builds' ? '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="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||||
|
</svg>
|
||||||
|
Jenkins 构建
|
||||||
|
</a>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="#"
|
href="#"
|
||||||
@click.prevent="setActiveMenu('env')"
|
@click.prevent="setActiveMenu('env')"
|
||||||
@@ -447,7 +472,7 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
setActiveMenu(menu) {
|
setActiveMenu(menu) {
|
||||||
if ((menu === 'ip-mappings' || menu === 'projects' || menu === 'scheduled-tasks') && !this.isAdmin) {
|
if ((menu === 'ip-mappings' || menu === 'projects' || menu === 'scheduled-tasks' || menu === 'jenkins-builds') && !this.isAdmin) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,9 +517,11 @@ export default {
|
|||||||
menu = 'projects';
|
menu = 'projects';
|
||||||
} else if (path === '/scheduled-tasks') {
|
} else if (path === '/scheduled-tasks') {
|
||||||
menu = 'scheduled-tasks';
|
menu = 'scheduled-tasks';
|
||||||
|
} else if (path === '/jenkins-builds') {
|
||||||
|
menu = 'jenkins-builds';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((menu === 'ip-mappings' || menu === 'projects' || menu === 'scheduled-tasks') && !this.isAdmin) {
|
if ((menu === 'ip-mappings' || menu === 'projects' || menu === 'scheduled-tasks' || menu === 'jenkins-builds') && !this.isAdmin) {
|
||||||
menu = 'env';
|
menu = 'env';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,482 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between bg-white px-4 py-2 rounded-lg shadow-sm border border-gray-200">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-bold text-gray-800">Jenkins 一键构建</h3>
|
||||||
|
<p class="text-xs text-gray-500">勾选 Jenkins 通知项目,调整参数后批量触发 Build</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
@click="loadProjects"
|
||||||
|
:disabled="loading || triggering"
|
||||||
|
class="px-2.5 py-1.5 bg-gray-100 text-gray-700 text-xs font-medium rounded hover:bg-gray-200 disabled:opacity-50 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4" :class="{'animate-spin': loading}">
|
||||||
|
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0v2.433l-.31-.31a7 7 0 00-11.712 3.138.75.75 0 001.449.39 5.5 5.5 0 019.201-2.466l.312.312h-2.433a.75.75 0 000 1.5h4.185a.75.75 0 00.53-.219z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="triggerBuilds"
|
||||||
|
:disabled="triggering || selectedProjects.length === 0"
|
||||||
|
class="px-3 py-1.5 bg-blue-600 text-white text-xs font-medium rounded hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<svg v-if="!triggering" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
||||||
|
<path d="M6.3 2.84A1.5 1.5 0 004 4.11v11.78a1.5 1.5 0 002.3 1.27l9.34-5.89a1.5 1.5 0 000-2.54L6.3 2.84z" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4 animate-spin">
|
||||||
|
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{{ triggering ? '触发中...' : `触发选中 (${selectedProjects.length})` }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="message" class="text-xs text-green-600 bg-green-50 px-3 py-2 rounded border border-green-100">{{ message }}</div>
|
||||||
|
<div v-if="error" class="text-xs text-red-600 bg-red-50 px-3 py-2 rounded border border-red-100">{{ error }}</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<div class="bg-gray-50 px-4 py-2 border-b border-gray-200 flex justify-between items-center">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h4 class="font-semibold text-gray-700 text-sm">可构建项目</h4>
|
||||||
|
<span class="text-xs text-gray-400">{{ projects.length }} 个项目</span>
|
||||||
|
</div>
|
||||||
|
<label v-if="projects.length > 0" class="flex items-center gap-1.5 text-xs text-gray-600 cursor-pointer">
|
||||||
|
<input type="checkbox" :checked="isAllSelected" @change="toggleAll" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
|
||||||
|
全选
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="p-10 text-center text-sm text-gray-400">加载 Jenkins 项目中...</div>
|
||||||
|
<div v-else-if="projects.length === 0" class="p-10 text-center text-sm text-gray-400">
|
||||||
|
暂无启用 Jenkins 发布通知且配置 Job 名称的项目
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-100 table-fixed">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="w-60 px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">项目 / Job</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Build 参数</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100 bg-white">
|
||||||
|
<tr v-for="project in projects" :key="project.slug" class="hover:bg-gray-50 align-top">
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<label class="flex items-start gap-2 cursor-pointer">
|
||||||
|
<input v-model="project.selected" type="checkbox" class="mt-0.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" @change="savePreferences" />
|
||||||
|
<span class="min-w-0 leading-tight">
|
||||||
|
<span class="flex items-center gap-1.5 min-w-0">
|
||||||
|
<span class="font-mono text-sm font-semibold text-gray-800 truncate">{{ project.slug }}</span>
|
||||||
|
<span class="text-[11px] text-gray-500 truncate" :title="project.name">{{ project.name }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="block font-mono text-[11px] text-gray-400 truncate" :title="project.jenkins_job_name">{{ project.jenkins_job_name }}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<div v-if="project.parameters.length === 0" class="text-xs text-gray-400 leading-7">
|
||||||
|
无参数,直接触发 build
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div class="grid grid-cols-[7rem_9rem_5.5rem_5rem_8rem_8rem] gap-2 items-start">
|
||||||
|
<template v-for="parameterName in primaryParameterOrder" :key="parameterName">
|
||||||
|
<parameter-control
|
||||||
|
v-if="parameterByName(project, parameterName)"
|
||||||
|
:project="project"
|
||||||
|
:parameter="parameterByName(project, parameterName)"
|
||||||
|
:triggering="triggering"
|
||||||
|
class="w-full"
|
||||||
|
@save="savePreferences"
|
||||||
|
@toggle-multi="toggleMultiValue"
|
||||||
|
/>
|
||||||
|
<div v-else class="h-8"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasSecondaryParameters(project)" class="flex flex-wrap gap-2 border-t border-gray-100 pt-2">
|
||||||
|
<div class="grid grid-cols-[7rem_minmax(0,1fr)] gap-2 items-start w-full">
|
||||||
|
<parameter-control
|
||||||
|
v-if="parameterByName(project, 'region')"
|
||||||
|
:project="project"
|
||||||
|
:parameter="parameterByName(project, 'region')"
|
||||||
|
:triggering="triggering"
|
||||||
|
class="w-full"
|
||||||
|
@save="savePreferences"
|
||||||
|
@toggle-multi="toggleMultiValue"
|
||||||
|
/>
|
||||||
|
<div v-else class="min-h-[4.25rem]"></div>
|
||||||
|
|
||||||
|
<parameter-control
|
||||||
|
v-if="parameterByName(project, 'project')"
|
||||||
|
:project="project"
|
||||||
|
:parameter="parameterByName(project, 'project')"
|
||||||
|
:triggering="triggering"
|
||||||
|
class="w-full"
|
||||||
|
@save="savePreferences"
|
||||||
|
@toggle-multi="toggleMultiValue"
|
||||||
|
/>
|
||||||
|
<div v-else class="min-h-[4.25rem]"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="results.length > 0" class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<div class="bg-gray-50 px-3 py-2 border-b border-gray-200">
|
||||||
|
<h4 class="font-semibold text-gray-700 text-sm">触发结果</h4>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">项目</th>
|
||||||
|
<th class="px-3 py-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Job</th>
|
||||||
|
<th class="px-3 py-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
|
||||||
|
<th class="px-3 py-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">队列</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
<tr v-for="result in results" :key="result.project_slug">
|
||||||
|
<td class="px-3 py-2 text-xs text-gray-800">{{ result.project_name }}</td>
|
||||||
|
<td class="px-3 py-2 text-xs font-mono text-gray-600">{{ result.job_name }}</td>
|
||||||
|
<td class="px-3 py-2 text-xs">
|
||||||
|
<span :class="result.success ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'" class="px-2 py-0.5 rounded text-xs">
|
||||||
|
{{ result.success ? '已触发' : (result.message || '失败') }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 text-xs">
|
||||||
|
<a v-if="result.queue_url" :href="result.queue_url" target="_blank" rel="noopener" class="text-blue-600 hover:text-blue-800 break-all">{{ result.queue_url }}</a>
|
||||||
|
<span v-else class="text-gray-400">-</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const ParameterControl = {
|
||||||
|
name: 'ParameterControl',
|
||||||
|
props: {
|
||||||
|
project: { type: Object, required: true },
|
||||||
|
parameter: { type: Object, required: true },
|
||||||
|
triggering: { type: Boolean, default: false }
|
||||||
|
},
|
||||||
|
emits: ['save', 'toggle-multi'],
|
||||||
|
methods: {
|
||||||
|
normalizedChoices(parameter) {
|
||||||
|
return (parameter.choices || []).map((choice) => {
|
||||||
|
if (choice && typeof choice === 'object') {
|
||||||
|
return {
|
||||||
|
value: choice.value,
|
||||||
|
label: choice.label || choice.value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: choice,
|
||||||
|
label: choice
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
isBooleanParameter(parameter) {
|
||||||
|
return String(parameter.type || '').toLowerCase().includes('boolean');
|
||||||
|
},
|
||||||
|
isMultiSelected(project, parameter, value) {
|
||||||
|
return (project.values[parameter.name] || []).includes(value);
|
||||||
|
},
|
||||||
|
formatDefault(value) {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<label
|
||||||
|
class="inline-flex items-center min-h-8 max-w-full rounded border border-gray-200 bg-white overflow-hidden"
|
||||||
|
:class="{ 'min-h-[4.25rem]': parameter.name === 'project' }"
|
||||||
|
:title="parameter.description || parameter.name"
|
||||||
|
>
|
||||||
|
<span class="self-stretch px-2 text-[11px] font-medium text-gray-500 bg-gray-50 border-r border-gray-200 inline-flex items-center whitespace-nowrap">
|
||||||
|
{{ parameter.name }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<select
|
||||||
|
v-if="!parameter.multiple && parameter.choices && parameter.choices.length > 0"
|
||||||
|
v-model="project.values[parameter.name]"
|
||||||
|
:disabled="triggering"
|
||||||
|
class="h-8 min-w-20 max-w-32 px-2 text-xs border-0 focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:text-gray-400"
|
||||||
|
@change="$emit('save')"
|
||||||
|
>
|
||||||
|
<option v-for="choice in normalizedChoices(parameter)" :key="choice.value" :value="choice.value">{{ choice.label }}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<span v-else-if="parameter.multiple" class="px-2 py-1 grid grid-rows-2 grid-flow-col auto-cols-max gap-1.5 min-h-[4.25rem] max-w-full content-start overflow-x-auto">
|
||||||
|
<label
|
||||||
|
v-for="choice in normalizedChoices(parameter)"
|
||||||
|
:key="choice.value"
|
||||||
|
class="inline-flex items-center gap-1 rounded bg-gray-50 px-1.5 py-0.5 text-xs text-gray-700 border border-gray-200 cursor-pointer hover:bg-blue-50 hover:border-blue-200"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:checked="isMultiSelected(project, parameter, choice.value)"
|
||||||
|
type="checkbox"
|
||||||
|
:disabled="triggering"
|
||||||
|
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:opacity-50"
|
||||||
|
@change="$emit('toggle-multi', project, parameter.name, choice.value)"
|
||||||
|
/>
|
||||||
|
{{ choice.label }}
|
||||||
|
</label>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span v-else-if="isBooleanParameter(parameter)" class="px-2 inline-flex items-center gap-1 text-xs text-gray-700">
|
||||||
|
<input
|
||||||
|
v-model="project.values[parameter.name]"
|
||||||
|
type="checkbox"
|
||||||
|
:disabled="triggering"
|
||||||
|
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:opacity-50"
|
||||||
|
@change="$emit('save')"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
v-model="project.values[parameter.name]"
|
||||||
|
type="text"
|
||||||
|
:disabled="triggering"
|
||||||
|
class="h-8 w-28 px-2 text-xs border-0 focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:text-gray-400"
|
||||||
|
:placeholder="formatDefault(parameter.default)"
|
||||||
|
@input="$emit('save')"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
`
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'JenkinsBuilds',
|
||||||
|
components: {
|
||||||
|
ParameterControl
|
||||||
|
},
|
||||||
|
preferenceKey: 'toolbox.jenkinsBuilds.preferences.v1',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
triggering: false,
|
||||||
|
projects: [],
|
||||||
|
results: [],
|
||||||
|
primaryParameterOrder: ['env', 'branchName', 'deploy', 'sql', 'masterCheck', 'deployVersion'],
|
||||||
|
message: '',
|
||||||
|
error: ''
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
selectedProjects() {
|
||||||
|
return this.projects.filter((project) => project.selected);
|
||||||
|
},
|
||||||
|
isAllSelected() {
|
||||||
|
return this.projects.length > 0 && this.selectedProjects.length === this.projects.length;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
await this.loadProjects();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async loadProjects() {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.message = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/jenkins/build-projects', {
|
||||||
|
headers: { Accept: 'application/json' }
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || !data.success) {
|
||||||
|
this.error = data.message || '加载 Jenkins 项目失败';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferences = this.loadPreferences();
|
||||||
|
this.projects = (data.data.projects || []).map((project) => {
|
||||||
|
const parameters = project.parameters || [];
|
||||||
|
const defaults = this.defaultValues(parameters);
|
||||||
|
const saved = preferences[this.preferenceProjectKey(project)] || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
selected: Boolean(saved.selected),
|
||||||
|
parameters,
|
||||||
|
values: this.mergeSavedValues(defaults, saved.values || {}, parameters)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultValues(parameters) {
|
||||||
|
return parameters.reduce((values, parameter) => {
|
||||||
|
if (parameter.multiple) {
|
||||||
|
values[parameter.name] = Array.isArray(parameter.default) ? parameter.default : [];
|
||||||
|
} else if (parameter.default !== null && parameter.default !== undefined) {
|
||||||
|
values[parameter.name] = parameter.default;
|
||||||
|
} else if (parameter.choices && parameter.choices.length > 0) {
|
||||||
|
values[parameter.name] = this.normalizedChoices(parameter)[0]?.value || '';
|
||||||
|
} else if (this.isBooleanParameter(parameter)) {
|
||||||
|
values[parameter.name] = false;
|
||||||
|
} else {
|
||||||
|
values[parameter.name] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}, {});
|
||||||
|
},
|
||||||
|
mergeSavedValues(defaults, savedValues, parameters) {
|
||||||
|
const merged = { ...defaults };
|
||||||
|
const parameterNames = new Set(parameters.map((parameter) => parameter.name));
|
||||||
|
|
||||||
|
Object.entries(savedValues).forEach(([name, value]) => {
|
||||||
|
if (!parameterNames.has(name)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
merged[name] = Array.isArray(defaults[name]) ? (Array.isArray(value) ? value : []) : value;
|
||||||
|
});
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
},
|
||||||
|
loadPreferences() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(window.localStorage.getItem(this.$options.preferenceKey) || '{}');
|
||||||
|
} catch (error) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
savePreferences() {
|
||||||
|
const preferences = this.projects.reduce((payload, project) => {
|
||||||
|
payload[this.preferenceProjectKey(project)] = {
|
||||||
|
selected: Boolean(project.selected),
|
||||||
|
values: project.values || {}
|
||||||
|
};
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
window.localStorage.setItem(this.$options.preferenceKey, JSON.stringify(preferences));
|
||||||
|
},
|
||||||
|
preferenceProjectKey(project) {
|
||||||
|
return project.jenkins_job_name || project.slug;
|
||||||
|
},
|
||||||
|
isBooleanParameter(parameter) {
|
||||||
|
return String(parameter.type || '').toLowerCase().includes('boolean');
|
||||||
|
},
|
||||||
|
normalizedChoices(parameter) {
|
||||||
|
return (parameter.choices || []).map((choice) => {
|
||||||
|
if (choice && typeof choice === 'object') {
|
||||||
|
return {
|
||||||
|
value: choice.value,
|
||||||
|
label: choice.label || choice.value,
|
||||||
|
selected: Boolean(choice.selected)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: choice,
|
||||||
|
label: choice,
|
||||||
|
selected: false
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
isMultiSelected(project, parameter, value) {
|
||||||
|
return (project.values[parameter.name] || []).includes(value);
|
||||||
|
},
|
||||||
|
hasSecondaryParameters(project) {
|
||||||
|
return Boolean(this.parameterByName(project, 'region') || this.parameterByName(project, 'project'));
|
||||||
|
},
|
||||||
|
parameterByName(project, name) {
|
||||||
|
return project.parameters.find((parameter) => parameter.name === name);
|
||||||
|
},
|
||||||
|
toggleMultiValue(project, parameterName, value) {
|
||||||
|
const current = project.values[parameterName] || [];
|
||||||
|
if (current.includes(value)) {
|
||||||
|
project.values[parameterName] = current.filter((item) => item !== value);
|
||||||
|
this.savePreferences();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
project.values[parameterName] = [...current, value];
|
||||||
|
this.savePreferences();
|
||||||
|
},
|
||||||
|
formatDefault(value) {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value);
|
||||||
|
},
|
||||||
|
toggleAll() {
|
||||||
|
const nextValue = !this.isAllSelected;
|
||||||
|
this.projects.forEach((project) => {
|
||||||
|
project.selected = nextValue;
|
||||||
|
});
|
||||||
|
this.savePreferences();
|
||||||
|
},
|
||||||
|
async triggerBuilds() {
|
||||||
|
if (this.selectedProjects.length === 0) {
|
||||||
|
this.error = '请至少选择一个项目';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.confirm(`确认触发 ${this.selectedProjects.length} 个 Jenkins Build 吗?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.triggering = true;
|
||||||
|
this.error = '';
|
||||||
|
this.message = '';
|
||||||
|
this.results = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/jenkins/trigger-builds', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
builds: this.selectedProjects.map((project) => ({
|
||||||
|
project_slug: project.slug,
|
||||||
|
parameters: project.values
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
this.results = data.data?.results || [];
|
||||||
|
if (!response.ok || !data.success) {
|
||||||
|
this.error = data.message || '触发失败';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.message = data.message || '触发成功';
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
} finally {
|
||||||
|
this.triggering = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
+12
-10
@@ -1,21 +1,22 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Route;
|
|
||||||
use App\Http\Controllers\EnvController;
|
|
||||||
use App\Http\Controllers\JiraController;
|
|
||||||
use App\Http\Controllers\TestMailController;
|
|
||||||
use App\Http\Controllers\LogAnalysisController;
|
|
||||||
use App\Http\Controllers\MessageSyncController;
|
|
||||||
use App\Http\Controllers\MessageDispatchController;
|
|
||||||
use App\Http\Controllers\ProductionDiagnosisController;
|
|
||||||
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\JenkinsBuildController;
|
||||||
use App\Http\Controllers\Admin\JenkinsDeploymentController;
|
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;
|
||||||
|
use App\Http\Controllers\EnvController;
|
||||||
|
use App\Http\Controllers\JiraController;
|
||||||
|
use App\Http\Controllers\LogAnalysisController;
|
||||||
|
use App\Http\Controllers\MessageDispatchController;
|
||||||
|
use App\Http\Controllers\MessageSyncController;
|
||||||
|
use App\Http\Controllers\ProductionDiagnosisController;
|
||||||
|
use App\Http\Controllers\SqlGeneratorController;
|
||||||
|
use App\Http\Controllers\TestMailController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
// 环境管理API路由
|
// 环境管理API路由
|
||||||
Route::prefix('env')->group(function () {
|
Route::prefix('env')->group(function () {
|
||||||
@@ -51,7 +52,6 @@ Route::prefix('jira')->group(function () {
|
|||||||
Route::get('/weekly-report/download', [JiraController::class, 'downloadWeeklyReport']);
|
Route::get('/weekly-report/download', [JiraController::class, 'downloadWeeklyReport']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// 提测邮件 API 路由
|
// 提测邮件 API 路由
|
||||||
Route::prefix('test-mail')->group(function () {
|
Route::prefix('test-mail')->group(function () {
|
||||||
Route::get('/sprints', [TestMailController::class, 'sprints']);
|
Route::get('/sprints', [TestMailController::class, 'sprints']);
|
||||||
@@ -116,6 +116,8 @@ Route::prefix('admin')->middleware('admin.ip')->group(function () {
|
|||||||
Route::post('/scheduled-tasks/{name}/toggle', [ScheduledTaskController::class, 'toggle']);
|
Route::post('/scheduled-tasks/{name}/toggle', [ScheduledTaskController::class, 'toggle']);
|
||||||
|
|
||||||
// Jenkins 发布历史
|
// Jenkins 发布历史
|
||||||
|
Route::get('/jenkins/build-projects', [JenkinsBuildController::class, 'projects']);
|
||||||
|
Route::post('/jenkins/trigger-builds', [JenkinsBuildController::class, 'trigger']);
|
||||||
Route::get('/jenkins/deployments', [JenkinsDeploymentController::class, 'index']);
|
Route::get('/jenkins/deployments', [JenkinsDeploymentController::class, 'index']);
|
||||||
Route::get('/jenkins/deployments/{id}', [JenkinsDeploymentController::class, 'show']);
|
Route::get('/jenkins/deployments/{id}', [JenkinsDeploymentController::class, 'show']);
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Route;
|
|
||||||
use App\Http\Controllers\AdminController;
|
use App\Http\Controllers\AdminController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
// 首页 - 显示admin框架
|
// 首页 - 显示admin框架
|
||||||
Route::get('/', [AdminController::class, 'index'])->name('home');
|
Route::get('/', [AdminController::class, 'index'])->name('home');
|
||||||
@@ -22,3 +22,4 @@ Route::get('/logs', [AdminController::class, 'index'])->name('admin.logs');
|
|||||||
Route::get('/ip-mappings', [AdminController::class, 'index'])->name('admin.ip-mappings')->middleware('admin.ip');
|
Route::get('/ip-mappings', [AdminController::class, 'index'])->name('admin.ip-mappings')->middleware('admin.ip');
|
||||||
Route::get('/projects', [AdminController::class, 'index'])->name('admin.projects')->middleware('admin.ip');
|
Route::get('/projects', [AdminController::class, 'index'])->name('admin.projects')->middleware('admin.ip');
|
||||||
Route::get('/scheduled-tasks', [AdminController::class, 'index'])->name('admin.scheduled-tasks')->middleware('admin.ip');
|
Route::get('/scheduled-tasks', [AdminController::class, 'index'])->name('admin.scheduled-tasks')->middleware('admin.ip');
|
||||||
|
Route::get('/jenkins-builds', [AdminController::class, 'index'])->name('admin.jenkins-builds')->middleware('admin.ip');
|
||||||
|
|||||||
Reference in New Issue
Block a user