1459 lines
41 KiB
TypeScript
1459 lines
41 KiB
TypeScript
/**
|
|
* 前端唯一 DevOps API 入口;所有运维数据都来自后端真实接口。
|
|
*/
|
|
import type {
|
|
AgentCard,
|
|
AgentConfigSummary,
|
|
AgentConnectionTestResult,
|
|
AgentInvocation,
|
|
AuthenticatedUser,
|
|
AuditLogEntry,
|
|
ChangePasswordPayload,
|
|
CreateAgentInvocationPayload,
|
|
CreateDeployRunPayload,
|
|
CreateMemberPayload,
|
|
DashboardSummary,
|
|
EnvironmentName,
|
|
ForgotPasswordPayload,
|
|
HealthState,
|
|
IntegrationConfigKey,
|
|
IntegrationConfigState,
|
|
IntegrationConfigStatus,
|
|
IntegrationVariableHelp,
|
|
IntegrationStatus,
|
|
JenkinsImportSummary,
|
|
JenkinsBuildLog,
|
|
JenkinsSyncSummary,
|
|
LoginPayload,
|
|
LoginResult,
|
|
MemberSummary,
|
|
NotificationOutboxMessage,
|
|
PipelineStep,
|
|
PlatformMessageSummary,
|
|
ProjectConfig,
|
|
ProjectGiteaDiagnosticsSummary,
|
|
ProjectGiteaRepositoryDiagnostic,
|
|
ProjectJenkinsDiagnosticsSummary,
|
|
ProjectJenkinsJobDiagnostic,
|
|
ProjectPermissionLevel,
|
|
ProjectRefs,
|
|
ReleaseRun,
|
|
ReleaseStatus,
|
|
SaveAgentConfigPayload,
|
|
StepStatus,
|
|
SystemSetting,
|
|
UpdateMemberPermissionsPayload,
|
|
UpdateMemberStatusPayload,
|
|
} from '../types/devops'
|
|
import { http } from './http'
|
|
|
|
type DataSource = 'backend'
|
|
type BackendHealthStatus = 'ok' | 'not_configured' | 'unavailable'
|
|
type BackendRunStatus = 'pending' | 'queued' | 'running' | 'success' | 'failed' | 'canceled'
|
|
type DashboardOptions = {
|
|
includeAdminConfig?: boolean
|
|
}
|
|
type ProjectGiteaWebhookStatus = NonNullable<
|
|
ProjectGiteaRepositoryDiagnostic['webhook']
|
|
>['status']
|
|
type BackendVariableHelpValue =
|
|
| string
|
|
| {
|
|
description?: unknown
|
|
example?: unknown
|
|
docUrl?: unknown
|
|
}
|
|
|
|
interface BackendHealth {
|
|
status: BackendHealthStatus
|
|
checkedAt: string
|
|
dependencies: Record<string, { status: BackendHealthStatus; message?: string }>
|
|
}
|
|
|
|
interface BackendProject {
|
|
id: string
|
|
key: string
|
|
name: string
|
|
repositoryUrl: string
|
|
defaultBranch: string
|
|
status: 'active' | 'archived'
|
|
permissionLevel?: ProjectPermissionLevel
|
|
permissionLevelName?: string
|
|
environments: Array<{
|
|
name: EnvironmentName
|
|
displayName: string
|
|
jenkinsJobPath: string
|
|
releasePolicy?: 'branch' | 'tag' | 'master'
|
|
refPattern?: string
|
|
}>
|
|
}
|
|
|
|
interface BackendDeployRunStep {
|
|
id?: string
|
|
bpmnNodeId?: string
|
|
name?: string
|
|
status?: BackendRunStatus
|
|
order?: number
|
|
startedAt?: string
|
|
finishedAt?: string
|
|
message?: string
|
|
errorSummary?: string
|
|
logExcerpt?: string
|
|
}
|
|
|
|
interface BackendDeployRun {
|
|
id?: string
|
|
version?: number
|
|
projectKey?: string
|
|
environment?: EnvironmentName
|
|
ref?: string
|
|
status?: BackendRunStatus
|
|
trigger?: 'manual' | 'gitea_webhook' | 'retry' | 'jenkins_external'
|
|
operator?: string
|
|
jenkinsQueueId?: string | number
|
|
jenkinsBuildNumber?: number
|
|
jenkinsBuildUrl?: string
|
|
startedAt?: string
|
|
finishedAt?: string
|
|
createdAt?: string
|
|
steps?: BackendDeployRunStep[]
|
|
}
|
|
|
|
interface BackendJenkinsSyncSummary {
|
|
checkedAt: string
|
|
totalCandidates: number
|
|
syncedRuns: BackendDeployRun[]
|
|
failedRuns: Array<{
|
|
id: string
|
|
projectKey: string
|
|
message: string
|
|
}>
|
|
}
|
|
|
|
interface BackendJenkinsImportSummary {
|
|
checkedAt: string
|
|
totalJobs: number
|
|
totalBuilds: number
|
|
importedRuns: BackendDeployRun[]
|
|
syncedRuns: BackendDeployRun[]
|
|
skippedBuilds: Array<{
|
|
projectKey: string
|
|
environment: EnvironmentName
|
|
buildNumber: number
|
|
reason: 'already_imported' | 'linked_platform_run' | 'parameter_mismatch'
|
|
}>
|
|
failedBuilds: Array<{
|
|
projectKey: string
|
|
environment: EnvironmentName
|
|
buildNumber?: number
|
|
message: string
|
|
}>
|
|
}
|
|
|
|
interface BackendProcessDefinition {
|
|
key: 'release'
|
|
version: string
|
|
xml: string
|
|
nodeIds: string[]
|
|
}
|
|
|
|
interface BackendIntegrationConfigItem {
|
|
key: IntegrationConfigKey
|
|
name: string
|
|
status: IntegrationConfigState
|
|
required?: string[]
|
|
configured?: string[]
|
|
missing?: string[]
|
|
optional?: string[]
|
|
enabled?: string[]
|
|
disabled?: string[]
|
|
note?: string
|
|
variableHelp?: Record<string, BackendVariableHelpValue>
|
|
variableDescriptions?: Record<string, unknown>
|
|
variables?: Array<{
|
|
name?: unknown
|
|
key?: unknown
|
|
description?: unknown
|
|
example?: unknown
|
|
docUrl?: unknown
|
|
}>
|
|
}
|
|
|
|
interface BackendIntegrationConfigStatus {
|
|
checkedAt?: string
|
|
integrations?: BackendIntegrationConfigItem[]
|
|
}
|
|
|
|
let lastDataSource: DataSource = 'backend'
|
|
const integrationConfigKeys: IntegrationConfigKey[] = [
|
|
'jenkins',
|
|
'gitea',
|
|
'notification',
|
|
'wecom',
|
|
'llm',
|
|
'database',
|
|
'redis',
|
|
'secrets',
|
|
]
|
|
const redactedVariableName = '非变量名内容已隐藏'
|
|
|
|
const agentCards: AgentCard[] = [
|
|
{
|
|
mode: 'risk',
|
|
title: '发布风险摘要',
|
|
description: '基于 ref diff、项目配置、历史失败记录生成检查项。',
|
|
inputScope: ['项目', '环境', 'ref/tag', '最近失败 run'],
|
|
outputPreview: ['配置变更影响范围', '构建和迁移风险', '发布前人工确认项'],
|
|
},
|
|
{
|
|
mode: 'diagnosis',
|
|
title: '失败诊断',
|
|
description: '只读取 Jenkins 日志片段、run steps 和已知 runbook。',
|
|
inputScope: ['失败 run', '失败节点', '脱敏日志片段'],
|
|
outputPreview: ['失败原因摘要', '下一步排查命令', '建议责任边界'],
|
|
},
|
|
{
|
|
mode: 'runbook',
|
|
title: 'Runbook 问答',
|
|
description: '围绕平台文档、项目 README、历史发布记录回答运维问题。',
|
|
inputScope: ['白名单文档', '项目', '环境'],
|
|
outputPreview: ['操作步骤', '风险提醒', '需要升级给后端的事项'],
|
|
},
|
|
{
|
|
mode: 'releaseNotes',
|
|
title: '发布说明草稿',
|
|
description: '根据 commit、PR 和 tag 生成生产发布说明草稿。',
|
|
inputScope: ['commit 列表', 'PR 信息', 'tag'],
|
|
outputPreview: ['变更摘要', '验证范围', '回滚提示'],
|
|
},
|
|
{
|
|
mode: 'incidentReview',
|
|
title: '事故复盘草稿',
|
|
description: '基于失败 run、流程耗时、Jenkins 摘要和 Runbook 生成复盘草稿。',
|
|
inputScope: ['失败 run', 'BPMN 节点', '脱敏日志片段', 'Runbook'],
|
|
outputPreview: ['时间线', '根因假设', '修复和预防项'],
|
|
},
|
|
]
|
|
|
|
const settings: SystemSetting[] = [
|
|
{
|
|
key: 'api-contract',
|
|
name: 'API 契约来源',
|
|
status: 'warning',
|
|
owner: '后端 OpenAPI',
|
|
contract: '所有项目、发布、refs、流程和集成状态均通过 devops-platform-api 获取。',
|
|
boundary: '前端不写死真实 token,不直接访问 Jenkins/Gitea/LLM。',
|
|
},
|
|
{
|
|
key: 'bpmn',
|
|
name: 'BPMN 流程定义',
|
|
status: 'healthy',
|
|
owner: '平台流程配置',
|
|
contract: 'step.bpmnNodeId 必须和 BPMN XML 节点 id 一致。',
|
|
boundary: '前端只展示流程和状态,不推进状态机。',
|
|
},
|
|
{
|
|
key: 'agent',
|
|
name: 'Agent 边界',
|
|
status: 'warning',
|
|
owner: '后端 Agent proxy',
|
|
contract: '只支持发布风险、失败诊断、Runbook、发布说明和事故复盘五类用途。',
|
|
boundary: '无泛聊天入口;模型 key/baseURL/model 仅后端保存。',
|
|
},
|
|
{
|
|
key: 'notification',
|
|
name: '通知平台',
|
|
status: 'warning',
|
|
owner: '后端 notification adapter',
|
|
contract: '通知模板由后端返回,后续可接企微、飞书或通用 webhook。',
|
|
boundary: 'webhook 不进入前端,不在浏览器保存。',
|
|
},
|
|
]
|
|
|
|
export const devopsApi = {
|
|
getLastDataSource(): DataSource {
|
|
return lastDataSource
|
|
},
|
|
|
|
async login(payload: LoginPayload): Promise<LoginResult> {
|
|
const { data } = await http.post<LoginResult>('/auth/login', payload)
|
|
lastDataSource = 'backend'
|
|
return data
|
|
},
|
|
|
|
async getCurrentUser(): Promise<AuthenticatedUser> {
|
|
const { data } = await http.get<AuthenticatedUser>('/auth/me')
|
|
lastDataSource = 'backend'
|
|
return data
|
|
},
|
|
|
|
async changePassword(payload: ChangePasswordPayload): Promise<AuthenticatedUser> {
|
|
const { data } = await http.post<AuthenticatedUser>('/auth/change-password', payload)
|
|
lastDataSource = 'backend'
|
|
return data
|
|
},
|
|
|
|
async forgotPassword(payload: ForgotPasswordPayload): Promise<void> {
|
|
await http.post('/auth/forgot-password', payload)
|
|
lastDataSource = 'backend'
|
|
},
|
|
|
|
async logout(): Promise<void> {
|
|
await http.post('/auth/logout')
|
|
lastDataSource = 'backend'
|
|
},
|
|
|
|
async getMembers(): Promise<MemberSummary[]> {
|
|
const { data } = await http.get<MemberSummary[]>('/members')
|
|
lastDataSource = 'backend'
|
|
return Array.isArray(data) ? data : []
|
|
},
|
|
|
|
async createMember(payload: CreateMemberPayload): Promise<MemberSummary> {
|
|
const { data } = await http.post<MemberSummary>('/members', payload)
|
|
lastDataSource = 'backend'
|
|
return data
|
|
},
|
|
|
|
async updateMemberPermissions(
|
|
memberId: string,
|
|
payload: UpdateMemberPermissionsPayload,
|
|
): Promise<MemberSummary> {
|
|
const { data } = await http.patch<MemberSummary>(
|
|
`/members/${memberId}/permissions`,
|
|
payload,
|
|
)
|
|
lastDataSource = 'backend'
|
|
return data
|
|
},
|
|
|
|
async resetMemberPassword(memberId: string): Promise<MemberSummary> {
|
|
const { data } = await http.post<MemberSummary>(
|
|
`/members/${memberId}/reset-password`,
|
|
)
|
|
lastDataSource = 'backend'
|
|
return data
|
|
},
|
|
|
|
async updateMemberStatus(
|
|
memberId: string,
|
|
payload: UpdateMemberStatusPayload,
|
|
): Promise<MemberSummary> {
|
|
const { data } = await http.patch<MemberSummary>(
|
|
`/members/${memberId}/status`,
|
|
payload,
|
|
)
|
|
lastDataSource = 'backend'
|
|
return data
|
|
},
|
|
|
|
async deleteMember(memberId: string): Promise<void> {
|
|
await http.delete(`/members/${memberId}`)
|
|
lastDataSource = 'backend'
|
|
},
|
|
|
|
async getMessages(): Promise<PlatformMessageSummary[]> {
|
|
const { data } = await http.get<PlatformMessageSummary[]>('/messages')
|
|
lastDataSource = 'backend'
|
|
return Array.isArray(data) ? data : []
|
|
},
|
|
|
|
async markMessageHandled(messageId: string): Promise<void> {
|
|
await http.post(`/messages/${messageId}/handled`)
|
|
lastDataSource = 'backend'
|
|
},
|
|
|
|
async getDashboard(options: DashboardOptions = {}): Promise<DashboardSummary> {
|
|
const integrationConfigPromise = options.includeAdminConfig
|
|
? fetchIntegrationConfigStatus()
|
|
: Promise.resolve(emptyIntegrationConfigStatus())
|
|
const [health, backendProjects, backendRuns, integrationConfig] = await Promise.all([
|
|
fetchHealth(),
|
|
fetchProjects(),
|
|
fetchRuns(),
|
|
integrationConfigPromise,
|
|
])
|
|
const adaptedProjects = toProjectConfigs(backendProjects)
|
|
lastDataSource = 'backend'
|
|
|
|
return {
|
|
integrations: toIntegrationStatuses(health),
|
|
projects: adaptedProjects,
|
|
runs: backendRuns.map((run) => toReleaseRun(run, adaptedProjects)),
|
|
agentCards,
|
|
settings: toSystemSettings(health, integrationConfig),
|
|
integrationConfig,
|
|
}
|
|
},
|
|
|
|
async getIntegrations(): Promise<IntegrationStatus[]> {
|
|
lastDataSource = 'backend'
|
|
return toIntegrationStatuses(await fetchHealth())
|
|
},
|
|
|
|
async getProjects(): Promise<ProjectConfig[]> {
|
|
lastDataSource = 'backend'
|
|
return toProjectConfigs(await fetchProjects())
|
|
},
|
|
|
|
async getProjectJenkinsDiagnostics(): Promise<ProjectJenkinsDiagnosticsSummary> {
|
|
const { data } = await http.get<ProjectJenkinsDiagnosticsSummary>(
|
|
'/projects/jenkins-diagnostics',
|
|
)
|
|
lastDataSource = 'backend'
|
|
return sanitizeProjectJenkinsDiagnostics(data)
|
|
},
|
|
|
|
async getProjectGiteaDiagnostics(): Promise<ProjectGiteaDiagnosticsSummary> {
|
|
const { data } = await http.get<ProjectGiteaDiagnosticsSummary>(
|
|
'/projects/gitea-diagnostics',
|
|
)
|
|
lastDataSource = 'backend'
|
|
return sanitizeProjectGiteaDiagnostics(data)
|
|
},
|
|
|
|
async getProjectRefs(projectKey: string): Promise<ProjectRefs> {
|
|
const { data } = await http.get<ProjectRefs>(`/projects/${projectKey}/refs`)
|
|
lastDataSource = 'backend'
|
|
return data
|
|
},
|
|
|
|
async getRuns(projectList: ProjectConfig[] = []): Promise<ReleaseRun[]> {
|
|
lastDataSource = 'backend'
|
|
return (await fetchRuns()).map((run) => toReleaseRun(run, projectList))
|
|
},
|
|
|
|
async createDeployRun(
|
|
payload: CreateDeployRunPayload,
|
|
projectList: ProjectConfig[] = [],
|
|
): Promise<ReleaseRun> {
|
|
const { data } = await http.post<BackendDeployRun>('/deploy-runs', payload)
|
|
lastDataSource = 'backend'
|
|
return toReleaseRun(data, projectList)
|
|
},
|
|
|
|
async cancelDeployRun(
|
|
runId: string,
|
|
projectList: ProjectConfig[] = [],
|
|
): Promise<ReleaseRun> {
|
|
const { data } = await http.post<BackendDeployRun>(`/deploy-runs/${runId}/cancel`)
|
|
lastDataSource = 'backend'
|
|
return toReleaseRun(data, projectList)
|
|
},
|
|
|
|
async retryDeployRun(
|
|
runId: string,
|
|
projectList: ProjectConfig[] = [],
|
|
): Promise<ReleaseRun> {
|
|
const { data } = await http.post<BackendDeployRun>(`/deploy-runs/${runId}/retry`)
|
|
lastDataSource = 'backend'
|
|
return toReleaseRun(data, projectList)
|
|
},
|
|
|
|
async syncJenkinsRun(
|
|
runId: string,
|
|
projectList: ProjectConfig[] = [],
|
|
): Promise<ReleaseRun> {
|
|
const { data } = await http.post<BackendDeployRun>(`/deploy-runs/${runId}/sync-jenkins`)
|
|
lastDataSource = 'backend'
|
|
return toReleaseRun(data, projectList)
|
|
},
|
|
|
|
async syncJenkinsRuns(projectList: ProjectConfig[] = []): Promise<JenkinsSyncSummary> {
|
|
const { data } = await http.post<BackendJenkinsSyncSummary>(
|
|
'/deploy-runs/sync-jenkins',
|
|
)
|
|
lastDataSource = 'backend'
|
|
return toJenkinsSyncSummary(data, projectList)
|
|
},
|
|
|
|
async importJenkinsBuilds(projectList: ProjectConfig[] = []): Promise<JenkinsImportSummary> {
|
|
const { data } = await http.post<BackendJenkinsImportSummary>(
|
|
'/deploy-runs/import-jenkins',
|
|
)
|
|
lastDataSource = 'backend'
|
|
return toJenkinsImportSummary(data, projectList)
|
|
},
|
|
|
|
async getJenkinsLog(runId: string, start = 0): Promise<JenkinsBuildLog> {
|
|
const { data } = await http.get<JenkinsBuildLog>(`/deploy-runs/${runId}/jenkins-log`, {
|
|
params: { start },
|
|
})
|
|
lastDataSource = 'backend'
|
|
return data
|
|
},
|
|
|
|
async getAuditLogs(): Promise<AuditLogEntry[]> {
|
|
const { data } = await http.get<AuditLogEntry[]>('/audit-logs')
|
|
lastDataSource = 'backend'
|
|
return Array.isArray(data) ? data.map(sanitizeAuditLogEntry) : []
|
|
},
|
|
|
|
async getNotificationOutbox(): Promise<NotificationOutboxMessage[]> {
|
|
const { data } = await http.get<NotificationOutboxMessage[]>('/notification-outbox')
|
|
lastDataSource = 'backend'
|
|
return Array.isArray(data) ? data.map(sanitizeNotificationOutboxMessage) : []
|
|
},
|
|
|
|
async retryNotificationOutboxMessage(id: string): Promise<NotificationOutboxMessage> {
|
|
const { data } = await http.post<NotificationOutboxMessage>(
|
|
`/notification-outbox/${id}/retry`,
|
|
)
|
|
lastDataSource = 'backend'
|
|
return sanitizeNotificationOutboxMessage(data)
|
|
},
|
|
|
|
async getAgentCards(): Promise<AgentCard[]> {
|
|
return agentCards
|
|
},
|
|
|
|
async getIntegrationConfigStatus(): Promise<IntegrationConfigStatus> {
|
|
lastDataSource = 'backend'
|
|
return fetchIntegrationConfigStatus()
|
|
},
|
|
|
|
async getAgentConfig(): Promise<AgentConfigSummary> {
|
|
const { data } = await http.get<AgentConfigSummary>('/agent/config')
|
|
lastDataSource = 'backend'
|
|
return data
|
|
},
|
|
|
|
async saveAgentConfig(
|
|
payload: SaveAgentConfigPayload,
|
|
): Promise<AgentConfigSummary> {
|
|
const { data } = await http.put<AgentConfigSummary>('/agent/config', payload)
|
|
lastDataSource = 'backend'
|
|
return data
|
|
},
|
|
|
|
async testAgentConfig(): Promise<AgentConnectionTestResult> {
|
|
const { data } = await http.post<AgentConnectionTestResult>(
|
|
'/agent/config/test',
|
|
)
|
|
lastDataSource = 'backend'
|
|
return data
|
|
},
|
|
|
|
async getReleaseProcessXml(): Promise<string> {
|
|
const { data } = await http.get<BackendProcessDefinition>(
|
|
'/process-definitions/release',
|
|
)
|
|
lastDataSource = 'backend'
|
|
return data.xml
|
|
},
|
|
|
|
async createAgentInvocation(
|
|
payload: CreateAgentInvocationPayload,
|
|
): Promise<AgentInvocation> {
|
|
const { data } = await http.post<AgentInvocation>('/agent/invocations', payload)
|
|
lastDataSource = 'backend'
|
|
return data
|
|
},
|
|
|
|
async listAgentInvocations(): Promise<AgentInvocation[]> {
|
|
const { data } = await http.get<AgentInvocation[]>('/agent/invocations')
|
|
lastDataSource = 'backend'
|
|
return data
|
|
},
|
|
|
|
async getSettings(): Promise<SystemSetting[]> {
|
|
const [health, integrationConfig] = await Promise.all([
|
|
fetchHealth(),
|
|
fetchIntegrationConfigStatus(),
|
|
])
|
|
lastDataSource = 'backend'
|
|
return toSystemSettings(health, integrationConfig)
|
|
},
|
|
|
|
}
|
|
|
|
async function fetchHealth(): Promise<BackendHealth> {
|
|
const { data } = await http.get<BackendHealth>('/health')
|
|
return data
|
|
}
|
|
|
|
async function fetchProjects(): Promise<BackendProject[]> {
|
|
const { data } = await http.get<BackendProject[]>('/projects')
|
|
return data
|
|
}
|
|
|
|
async function fetchRuns(): Promise<BackendDeployRun[]> {
|
|
const { data } = await http.get<BackendDeployRun[]>('/deploy-runs')
|
|
return data
|
|
}
|
|
|
|
async function fetchIntegrationConfigStatus(): Promise<IntegrationConfigStatus> {
|
|
const { data } = await http.get<BackendIntegrationConfigStatus>(
|
|
'/settings/integration-config',
|
|
)
|
|
return sanitizeIntegrationConfigStatus(data)
|
|
}
|
|
|
|
function emptyIntegrationConfigStatus(): IntegrationConfigStatus {
|
|
return {
|
|
checkedAt: new Date().toISOString(),
|
|
integrations: [],
|
|
}
|
|
}
|
|
|
|
function toIntegrationStatuses(health: BackendHealth): IntegrationStatus[] {
|
|
return [
|
|
toIntegrationStatus('jenkins', 'Jenkins', health),
|
|
toIntegrationStatus('gitea', 'Gitea', health),
|
|
toIntegrationStatus('notification', '通知平台', health),
|
|
toIntegrationStatus('llm', 'LLM Agent', health),
|
|
]
|
|
}
|
|
|
|
function toIntegrationStatus(
|
|
key: IntegrationStatus['key'],
|
|
name: string,
|
|
health: BackendHealth,
|
|
): IntegrationStatus {
|
|
const dependency =
|
|
health.dependencies[key] ??
|
|
(key === 'notification' ? health.dependencies.wecom : undefined) ??
|
|
{ status: 'unavailable' as const }
|
|
|
|
return {
|
|
key,
|
|
name,
|
|
state: toHealthState(dependency.status),
|
|
summary: dependency.message || healthMessage(dependency.status),
|
|
checkedAt: formatDateTime(health.checkedAt),
|
|
}
|
|
}
|
|
|
|
function sanitizeIntegrationConfigStatus(
|
|
payload: BackendIntegrationConfigStatus,
|
|
): IntegrationConfigStatus {
|
|
const integrations = Array.isArray(payload?.integrations) ? payload.integrations : []
|
|
|
|
return {
|
|
checkedAt:
|
|
typeof payload?.checkedAt === 'string'
|
|
? payload.checkedAt
|
|
: new Date().toISOString(),
|
|
integrations: integrations
|
|
.filter((item) => integrationConfigKeys.includes(item.key))
|
|
.map((item) => {
|
|
const required = sanitizeVariableNames(item.required)
|
|
const configured = sanitizeVariableNames(item.configured)
|
|
const missing = sanitizeVariableNames(item.missing)
|
|
const optional = sanitizeVariableNames(item.optional)
|
|
const enabled = sanitizeVariableNames(item.enabled)
|
|
const disabled = sanitizeVariableNames(item.disabled)
|
|
const variableHelp = sanitizeVariableHelp(item)
|
|
|
|
return {
|
|
key: item.key,
|
|
name: typeof item.name === 'string' && item.name ? item.name : item.key,
|
|
status: sanitizeIntegrationConfigState(item.status),
|
|
required,
|
|
configured,
|
|
missing,
|
|
optional,
|
|
enabled,
|
|
disabled,
|
|
note: sanitizeIntegrationNote(item.note),
|
|
...(Object.keys(variableHelp).length > 0 ? { variableHelp } : {}),
|
|
}
|
|
}),
|
|
}
|
|
}
|
|
|
|
function sanitizeVariableNames(values?: string[]): string[] {
|
|
if (!Array.isArray(values)) {
|
|
return []
|
|
}
|
|
|
|
return Array.from(
|
|
new Set(
|
|
values
|
|
.map((value) => String(value).trim())
|
|
.filter(Boolean)
|
|
.map((value) =>
|
|
/^[A-Z][A-Z0-9_]*$/.test(value) ? value : redactedVariableName,
|
|
),
|
|
),
|
|
)
|
|
}
|
|
|
|
function sanitizeIntegrationConfigState(
|
|
state: IntegrationConfigState,
|
|
): IntegrationConfigState {
|
|
return state === 'configured' || state === 'partial' || state === 'missing'
|
|
? state
|
|
: 'missing'
|
|
}
|
|
|
|
function sanitizeIntegrationNote(note?: string): string | undefined {
|
|
if (!note) {
|
|
return undefined
|
|
}
|
|
|
|
return note
|
|
.replace(/https?:\/\/\S+/g, '[已隐藏地址]')
|
|
.replace(/key=[^&\s]+/g, 'key=[已隐藏]')
|
|
}
|
|
|
|
function sanitizeVariableHelp(
|
|
item: BackendIntegrationConfigItem,
|
|
): Record<string, IntegrationVariableHelp> {
|
|
const help: Record<string, IntegrationVariableHelp> = {}
|
|
|
|
collectVariableHelpMap(help, item.variableHelp)
|
|
collectVariableDescriptionMap(help, item.variableDescriptions)
|
|
collectVariableHelpArray(help, item.variables)
|
|
|
|
return help
|
|
}
|
|
|
|
function collectVariableHelpMap(
|
|
target: Record<string, IntegrationVariableHelp>,
|
|
value?: Record<string, BackendVariableHelpValue>,
|
|
) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return
|
|
}
|
|
|
|
Object.entries(value).forEach(([rawName, rawHelp]) => {
|
|
const name = sanitizeVariableName(rawName)
|
|
if (!name) {
|
|
return
|
|
}
|
|
|
|
const help =
|
|
typeof rawHelp === 'string'
|
|
? sanitizeVariableHelpValue({ description: rawHelp })
|
|
: sanitizeVariableHelpValue(rawHelp)
|
|
|
|
if (help) {
|
|
target[name] = help
|
|
}
|
|
})
|
|
}
|
|
|
|
function collectVariableDescriptionMap(
|
|
target: Record<string, IntegrationVariableHelp>,
|
|
value?: Record<string, unknown>,
|
|
) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return
|
|
}
|
|
|
|
Object.entries(value).forEach(([rawName, rawDescription]) => {
|
|
const name = sanitizeVariableName(rawName)
|
|
const description = sanitizeVariableDescription(rawDescription)
|
|
|
|
if (name && description) {
|
|
target[name] = { description }
|
|
}
|
|
})
|
|
}
|
|
|
|
function collectVariableHelpArray(
|
|
target: Record<string, IntegrationVariableHelp>,
|
|
value?: BackendIntegrationConfigItem['variables'],
|
|
) {
|
|
if (!Array.isArray(value)) {
|
|
return
|
|
}
|
|
|
|
value.forEach((item) => {
|
|
const name = sanitizeVariableName(item.name ?? item.key)
|
|
const help = sanitizeVariableHelpValue(item)
|
|
|
|
if (name && help) {
|
|
target[name] = help
|
|
}
|
|
})
|
|
}
|
|
|
|
function sanitizeVariableHelpValue(
|
|
value?: {
|
|
description?: unknown
|
|
example?: unknown
|
|
docUrl?: unknown
|
|
},
|
|
): IntegrationVariableHelp | undefined {
|
|
const description = sanitizeVariableDescription(value?.description)
|
|
|
|
if (!description) {
|
|
return undefined
|
|
}
|
|
|
|
return {
|
|
description,
|
|
...(typeof value?.example === 'string' && value.example.trim()
|
|
? { example: sanitizeIntegrationNote(value.example.trim()) ?? value.example.trim() }
|
|
: {}),
|
|
...(typeof value?.docUrl === 'string' && isSafeDocUrl(value.docUrl)
|
|
? { docUrl: value.docUrl.trim() }
|
|
: {}),
|
|
}
|
|
}
|
|
|
|
function sanitizeVariableDescription(value: unknown): string | undefined {
|
|
if (typeof value !== 'string' || !value.trim()) {
|
|
return undefined
|
|
}
|
|
|
|
return sanitizeIntegrationNote(value.trim())
|
|
}
|
|
|
|
function sanitizeVariableName(value: unknown): string | undefined {
|
|
if (typeof value !== 'string') {
|
|
return undefined
|
|
}
|
|
|
|
const name = value.trim()
|
|
return /^[A-Z][A-Z0-9_]*$/.test(name) ? name : undefined
|
|
}
|
|
|
|
function isSafeDocUrl(value: string): boolean {
|
|
try {
|
|
const url = new URL(value)
|
|
const normalized = url.toString().toLowerCase()
|
|
|
|
return (
|
|
url.protocol === 'https:' &&
|
|
!url.searchParams.has('key') &&
|
|
!normalized.includes('/cgi-bin/webhook/') &&
|
|
!normalized.includes('/open-apis/bot/') &&
|
|
!normalized.includes('/webhook/')
|
|
)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function safeText(value: unknown, fallback: string): string {
|
|
return typeof value === 'string' && value.trim() ? value : fallback
|
|
}
|
|
|
|
function optionalSafeText(value: unknown): string | undefined {
|
|
return typeof value === 'string' && value.trim() ? value : undefined
|
|
}
|
|
|
|
function safeRecord(value: unknown): Record<string, unknown> | undefined {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return undefined
|
|
}
|
|
|
|
return value as Record<string, unknown>
|
|
}
|
|
|
|
function toProjectConfigs(backendProjects: BackendProject[]): ProjectConfig[] {
|
|
return backendProjects.map((project) => {
|
|
const repo = parseRepository(project.repositoryUrl, project.key)
|
|
|
|
return {
|
|
key: project.key,
|
|
name: project.name,
|
|
repository: project.repositoryUrl,
|
|
owner: repo.owner,
|
|
repo: repo.repo,
|
|
serviceType: serviceTypeLabel(project.key),
|
|
defaultBranch: project.defaultBranch,
|
|
tagPolicy: tagPolicyLabel(project.environments),
|
|
permissionLevel: project.permissionLevel === 'build' ? 'build' : 'read',
|
|
permissionLevelName:
|
|
project.permissionLevel === 'build' ? '构建权限' : '只读权限',
|
|
giteaStatus: 'warning',
|
|
giteaMessage: '尚未执行 Gitea 诊断',
|
|
giteaWebhookStatus: 'warning',
|
|
giteaWebhookMessage: '尚未执行 Webhook 诊断',
|
|
giteaWebhookHooks: [],
|
|
environments: project.environments.map((environment) => {
|
|
return {
|
|
name: environment.name,
|
|
label: environment.displayName,
|
|
branchPolicy: releasePolicyLabel(environment.releasePolicy, project.defaultBranch),
|
|
status: 'idle' as const,
|
|
version: '-',
|
|
lastRunId: '-',
|
|
lastReleasedAt: '-',
|
|
lastActor: '-',
|
|
jenkinsJob: environment.jenkinsJobPath,
|
|
jenkinsStatus: 'warning' as const,
|
|
jenkinsMessage: '尚未执行 Jenkins 诊断',
|
|
wecomRobot: '后端通知配置',
|
|
}
|
|
}),
|
|
integrations: {
|
|
jenkins: 'warning',
|
|
gitea: 'warning',
|
|
wecom: 'warning',
|
|
llm: 'warning',
|
|
},
|
|
}
|
|
})
|
|
}
|
|
|
|
function sanitizeProjectGiteaDiagnostics(
|
|
value: ProjectGiteaDiagnosticsSummary,
|
|
): ProjectGiteaDiagnosticsSummary {
|
|
return {
|
|
checkedAt: safeText(value.checkedAt, new Date().toISOString()),
|
|
repositories: Array.isArray(value.repositories)
|
|
? value.repositories.map(sanitizeProjectGiteaRepositoryDiagnostic)
|
|
: [],
|
|
}
|
|
}
|
|
|
|
function sanitizeProjectGiteaRepositoryDiagnostic(
|
|
value: ProjectGiteaRepositoryDiagnostic,
|
|
): ProjectGiteaRepositoryDiagnostic {
|
|
return {
|
|
projectKey: safeText(value.projectKey, 'unknown-project'),
|
|
repositoryUrl: safeText(value.repositoryUrl, ''),
|
|
owner: optionalSafeText(value.owner),
|
|
repo: optionalSafeText(value.repo),
|
|
status: toProjectGiteaDiagnosticStatus(value.status),
|
|
message: optionalSafeText(value.message),
|
|
defaultBranch: optionalSafeText(value.defaultBranch),
|
|
webUrl: optionalSafeText(value.webUrl),
|
|
cloneUrl: optionalSafeText(value.cloneUrl),
|
|
archived: typeof value.archived === 'boolean' ? value.archived : undefined,
|
|
private: typeof value.private === 'boolean' ? value.private : undefined,
|
|
empty: typeof value.empty === 'boolean' ? value.empty : undefined,
|
|
branchCount: Number.isFinite(value.branchCount) ? value.branchCount : undefined,
|
|
tagCount: Number.isFinite(value.tagCount) ? value.tagCount : undefined,
|
|
latestCommit: value.latestCommit
|
|
? {
|
|
sha: safeText(value.latestCommit.sha, ''),
|
|
message: safeText(value.latestCommit.message, ''),
|
|
authorName: optionalSafeText(value.latestCommit.authorName),
|
|
authoredAt: optionalSafeText(value.latestCommit.authoredAt),
|
|
}
|
|
: undefined,
|
|
webhook: value.webhook
|
|
? {
|
|
status: toProjectGiteaWebhookDiagnosticStatus(value.webhook.status),
|
|
message: optionalSafeText(value.webhook.message),
|
|
hookCount: Number.isFinite(value.webhook.hookCount) ? value.webhook.hookCount : 0,
|
|
activePushHookCount: Number.isFinite(value.webhook.activePushHookCount)
|
|
? value.webhook.activePushHookCount
|
|
: 0,
|
|
expectedRelayConfigured: value.webhook.expectedRelayConfigured === true,
|
|
expectedRelayTarget: optionalSafeText(value.webhook.expectedRelayTarget),
|
|
matchedHookId: Number.isFinite(value.webhook.matchedHookId)
|
|
? value.webhook.matchedHookId
|
|
: undefined,
|
|
hooks: Array.isArray(value.webhook.hooks)
|
|
? value.webhook.hooks.map((hook) => ({
|
|
id: Number.isFinite(hook.id) ? hook.id : 0,
|
|
type: safeText(hook.type, 'unknown'),
|
|
active: hook.active === true,
|
|
events: Array.isArray(hook.events)
|
|
? hook.events.filter((event): event is string => typeof event === 'string')
|
|
: [],
|
|
branchFilter: optionalSafeText(hook.branchFilter),
|
|
target: optionalSafeText(hook.target),
|
|
matchesExpectedRelay: hook.matchesExpectedRelay === true,
|
|
createdAt: optionalSafeText(hook.createdAt),
|
|
updatedAt: optionalSafeText(hook.updatedAt),
|
|
}))
|
|
: [],
|
|
}
|
|
: undefined,
|
|
updatedAt: optionalSafeText(value.updatedAt),
|
|
}
|
|
}
|
|
|
|
function toProjectGiteaWebhookDiagnosticStatus(
|
|
value: ProjectGiteaWebhookStatus,
|
|
): ProjectGiteaWebhookStatus {
|
|
if (
|
|
value === 'ok' ||
|
|
value === 'missing' ||
|
|
value === 'not_configured' ||
|
|
value === 'unavailable'
|
|
) {
|
|
return value
|
|
}
|
|
|
|
return 'unavailable'
|
|
}
|
|
|
|
function toProjectGiteaDiagnosticStatus(
|
|
value: ProjectGiteaRepositoryDiagnostic['status'],
|
|
): ProjectGiteaRepositoryDiagnostic['status'] {
|
|
if (
|
|
value === 'ok' ||
|
|
value === 'missing' ||
|
|
value === 'not_configured' ||
|
|
value === 'unavailable'
|
|
) {
|
|
return value
|
|
}
|
|
|
|
return 'unavailable'
|
|
}
|
|
|
|
function sanitizeProjectJenkinsDiagnostics(
|
|
value: ProjectJenkinsDiagnosticsSummary,
|
|
): ProjectJenkinsDiagnosticsSummary {
|
|
return {
|
|
checkedAt: safeText(value.checkedAt, new Date().toISOString()),
|
|
jobs: Array.isArray(value.jobs)
|
|
? value.jobs.map(sanitizeProjectJenkinsJobDiagnostic)
|
|
: [],
|
|
}
|
|
}
|
|
|
|
function sanitizeProjectJenkinsJobDiagnostic(
|
|
value: ProjectJenkinsJobDiagnostic,
|
|
): ProjectJenkinsJobDiagnostic {
|
|
return {
|
|
projectKey: safeText(value.projectKey, 'unknown-project'),
|
|
environment: toEnvironmentName(value.environment),
|
|
jenkinsJobPath: safeText(value.jenkinsJobPath, ''),
|
|
status: toProjectJenkinsDiagnosticStatus(value.status),
|
|
message: optionalSafeText(value.message),
|
|
buildable: typeof value.buildable === 'boolean' ? value.buildable : undefined,
|
|
url: optionalSafeText(value.url),
|
|
lastBuild: value.lastBuild
|
|
? {
|
|
number: Number.isFinite(value.lastBuild.number)
|
|
? value.lastBuild.number
|
|
: undefined,
|
|
result: value.lastBuild.result,
|
|
building:
|
|
typeof value.lastBuild.building === 'boolean'
|
|
? value.lastBuild.building
|
|
: undefined,
|
|
url: optionalSafeText(value.lastBuild.url),
|
|
}
|
|
: undefined,
|
|
}
|
|
}
|
|
|
|
function toProjectJenkinsDiagnosticStatus(
|
|
value: ProjectJenkinsJobDiagnostic['status'],
|
|
): ProjectJenkinsJobDiagnostic['status'] {
|
|
if (
|
|
value === 'ok' ||
|
|
value === 'missing' ||
|
|
value === 'not_configured' ||
|
|
value === 'unavailable'
|
|
) {
|
|
return value
|
|
}
|
|
|
|
return 'unavailable'
|
|
}
|
|
|
|
function toReleaseRun(run: BackendDeployRun, projectList: ProjectConfig[]): ReleaseRun {
|
|
const projectKey = run.projectKey || 'unknown-project'
|
|
const project = projectList.find((item) => item.key === projectKey)
|
|
const ref = run.ref || '-'
|
|
const trigger = run.trigger ? triggerLabel(run.trigger) : '-'
|
|
const startedAt = run.startedAt ?? run.createdAt
|
|
|
|
return {
|
|
id: run.id || `${projectKey}-${ref}-${run.createdAt ?? Date.now()}`,
|
|
version: typeof run.version === 'number' && Number.isFinite(run.version) ? run.version : 0,
|
|
projectKey,
|
|
projectName: project?.name ?? projectKey,
|
|
environment: toEnvironmentName(run.environment),
|
|
ref,
|
|
status: toReleaseStatus(run.status),
|
|
actor: run.operator || '-',
|
|
startedAt: formatDateTime(startedAt),
|
|
finishedAt: formatDateTime(run.finishedAt),
|
|
duration: formatDuration(startedAt, run.finishedAt, run.status),
|
|
note: trigger,
|
|
jenkinsQueueId: formatJenkinsQueueId(run.jenkinsQueueId),
|
|
jenkinsBuildNumber: run.jenkinsBuildNumber,
|
|
jenkinsBuildUrl: run.jenkinsBuildUrl,
|
|
jenkinsSummary: formatJenkinsBuild(run),
|
|
commitSummary: ref === '-' ? trigger : `${ref} · ${trigger}`,
|
|
steps: (Array.isArray(run.steps) ? run.steps : [])
|
|
.slice()
|
|
.sort((left, right) => (left.order ?? 0) - (right.order ?? 0))
|
|
.map(toPipelineStep),
|
|
}
|
|
}
|
|
|
|
function toJenkinsSyncSummary(
|
|
summary: BackendJenkinsSyncSummary,
|
|
projectList: ProjectConfig[],
|
|
): JenkinsSyncSummary {
|
|
return {
|
|
checkedAt: summary.checkedAt,
|
|
totalCandidates: summary.totalCandidates,
|
|
syncedRuns: (Array.isArray(summary.syncedRuns) ? summary.syncedRuns : []).map((run) =>
|
|
toReleaseRun(run, projectList),
|
|
),
|
|
failedRuns: Array.isArray(summary.failedRuns) ? summary.failedRuns : [],
|
|
}
|
|
}
|
|
|
|
function toJenkinsImportSummary(
|
|
summary: BackendJenkinsImportSummary,
|
|
projectList: ProjectConfig[],
|
|
): JenkinsImportSummary {
|
|
return {
|
|
checkedAt: summary.checkedAt,
|
|
totalJobs: Number.isFinite(summary.totalJobs) ? summary.totalJobs : 0,
|
|
totalBuilds: Number.isFinite(summary.totalBuilds) ? summary.totalBuilds : 0,
|
|
importedRuns: (Array.isArray(summary.importedRuns) ? summary.importedRuns : []).map((run) =>
|
|
toReleaseRun(run, projectList),
|
|
),
|
|
syncedRuns: (Array.isArray(summary.syncedRuns) ? summary.syncedRuns : []).map((run) =>
|
|
toReleaseRun(run, projectList),
|
|
),
|
|
skippedBuilds: Array.isArray(summary.skippedBuilds) ? summary.skippedBuilds : [],
|
|
failedBuilds: Array.isArray(summary.failedBuilds) ? summary.failedBuilds : [],
|
|
}
|
|
}
|
|
|
|
function sanitizeAuditLogEntry(record: AuditLogEntry): AuditLogEntry {
|
|
return {
|
|
id: safeText(record.id, 'audit-unknown'),
|
|
action: safeText(record.action, 'UNKNOWN_ACTION'),
|
|
resourceType: safeText(record.resourceType, 'unknown'),
|
|
resourceId: optionalSafeText(record.resourceId),
|
|
actorId: optionalSafeText(record.actorId),
|
|
actorName: optionalSafeText(record.actorName),
|
|
requestId: optionalSafeText(record.requestId),
|
|
sourceIp: optionalSafeText(record.sourceIp),
|
|
userAgent: optionalSafeText(record.userAgent),
|
|
before: safeRecord(record.before),
|
|
after: safeRecord(record.after),
|
|
parameterDigest: safeRecord(record.parameterDigest),
|
|
createdAt: safeText(record.createdAt, ''),
|
|
}
|
|
}
|
|
|
|
function sanitizeNotificationOutboxMessage(
|
|
record: NotificationOutboxMessage,
|
|
): NotificationOutboxMessage {
|
|
return {
|
|
id: safeText(record.id, 'outbox-unknown'),
|
|
deployRunId: optionalSafeText(record.deployRunId),
|
|
channel: safeText(record.channel, 'none'),
|
|
template: safeText(record.template, 'unknown-template'),
|
|
payloadSummary: safeRecord(record.payloadSummary),
|
|
status: toNotificationOutboxStatus(record.status),
|
|
attemptCount: Number.isFinite(record.attemptCount) ? record.attemptCount : 0,
|
|
nextAttemptAt: optionalSafeText(record.nextAttemptAt),
|
|
lastError: optionalSafeText(record.lastError),
|
|
idempotencyKey: safeText(record.idempotencyKey, 'unknown-idempotency'),
|
|
createdAt: safeText(record.createdAt, ''),
|
|
updatedAt: safeText(record.updatedAt, ''),
|
|
}
|
|
}
|
|
|
|
function toPipelineStep(step: BackendDeployRunStep): PipelineStep {
|
|
const bpmnNodeId = step.bpmnNodeId || step.id || 'unknown-step'
|
|
const name = step.name || bpmnNodeId
|
|
|
|
return {
|
|
id: step.id || bpmnNodeId,
|
|
bpmnNodeId,
|
|
name,
|
|
owner: stepOwner(bpmnNodeId),
|
|
status: toStepStatus(step.status),
|
|
duration: formatDuration(step.startedAt, step.finishedAt, step.status),
|
|
summary: step.errorSummary || step.message || name,
|
|
startedAt: formatDateTime(step.startedAt),
|
|
finishedAt: formatDateTime(step.finishedAt),
|
|
message: step.message,
|
|
errorSummary: step.errorSummary,
|
|
logExcerpt: step.logExcerpt,
|
|
}
|
|
}
|
|
|
|
function toHealthState(status: BackendHealthStatus): HealthState {
|
|
if (status === 'ok') {
|
|
return 'healthy'
|
|
}
|
|
|
|
if (status === 'not_configured') {
|
|
return 'warning'
|
|
}
|
|
|
|
return 'error'
|
|
}
|
|
|
|
function toReleaseStatus(status?: BackendRunStatus): ReleaseStatus {
|
|
if (status === 'pending') {
|
|
return 'queued'
|
|
}
|
|
|
|
if (
|
|
status === 'queued' ||
|
|
status === 'running' ||
|
|
status === 'success' ||
|
|
status === 'failed' ||
|
|
status === 'canceled'
|
|
) {
|
|
return status
|
|
}
|
|
|
|
return 'idle'
|
|
}
|
|
|
|
function toStepStatus(status?: BackendRunStatus): StepStatus {
|
|
if (status === 'pending') {
|
|
return 'pending'
|
|
}
|
|
|
|
if (
|
|
status === 'queued' ||
|
|
status === 'running' ||
|
|
status === 'success' ||
|
|
status === 'failed' ||
|
|
status === 'canceled'
|
|
) {
|
|
return status
|
|
}
|
|
|
|
return 'pending'
|
|
}
|
|
|
|
function toNotificationOutboxStatus(
|
|
status?: NotificationOutboxMessage['status'],
|
|
): NotificationOutboxMessage['status'] {
|
|
if (
|
|
status === 'pending' ||
|
|
status === 'sent' ||
|
|
status === 'failed' ||
|
|
status === 'dead'
|
|
) {
|
|
return status
|
|
}
|
|
|
|
return 'failed'
|
|
}
|
|
|
|
function stepOwner(nodeId: string): PipelineStep['owner'] {
|
|
if (nodeId.includes('gitea')) {
|
|
return 'Gitea'
|
|
}
|
|
|
|
if (nodeId.includes('jenkins')) {
|
|
return 'Jenkins'
|
|
}
|
|
|
|
if (nodeId.includes('health')) {
|
|
return 'HealthCheck'
|
|
}
|
|
|
|
if (nodeId.includes('wecom') || nodeId.includes('notify')) {
|
|
return 'Notification'
|
|
}
|
|
|
|
return 'Platform'
|
|
}
|
|
|
|
function healthMessage(status: BackendHealthStatus): string {
|
|
if (status === 'ok') {
|
|
return '后端检查通过'
|
|
}
|
|
|
|
if (status === 'not_configured') {
|
|
return '本地未配置,等待接入真实密钥'
|
|
}
|
|
|
|
return '后端检查不可用'
|
|
}
|
|
|
|
function triggerLabel(trigger: BackendDeployRun['trigger']): string {
|
|
if (trigger === 'gitea_webhook') {
|
|
return 'Gitea 自动触发'
|
|
}
|
|
|
|
if (trigger === 'retry') {
|
|
return '失败后重试'
|
|
}
|
|
|
|
if (trigger === 'jenkins_external') {
|
|
return 'Jenkins 直构导入'
|
|
}
|
|
|
|
return '人工触发'
|
|
}
|
|
|
|
function formatJenkinsBuild(run: BackendDeployRun): string {
|
|
if (run.jenkinsBuildNumber !== undefined && run.jenkinsBuildNumber !== null) {
|
|
return `Jenkins build #${run.jenkinsBuildNumber}`
|
|
}
|
|
|
|
if (run.jenkinsQueueId) {
|
|
return `Jenkins queue ${run.jenkinsQueueId}`
|
|
}
|
|
|
|
return '等待 Jenkins 返回'
|
|
}
|
|
|
|
function formatJenkinsQueueId(queueId?: string | number): string | undefined {
|
|
if (queueId === undefined || queueId === null || queueId === '') {
|
|
return undefined
|
|
}
|
|
|
|
return String(queueId)
|
|
}
|
|
|
|
function toEnvironmentName(environment?: EnvironmentName): EnvironmentName {
|
|
return environment === 'production' ? 'production' : 'test'
|
|
}
|
|
|
|
function parseRepository(repositoryUrl: string, fallbackRepo: string) {
|
|
try {
|
|
const pathname = new URL(repositoryUrl).pathname.replace(/\.git$/, '')
|
|
const parts = pathname.split('/').filter(Boolean)
|
|
const repo = parts.at(-1) ?? fallbackRepo
|
|
const owner = parts.at(-2) ?? 'my-project'
|
|
|
|
return { owner, repo }
|
|
} catch {
|
|
return { owner: 'my-project', repo: fallbackRepo }
|
|
}
|
|
}
|
|
|
|
function serviceTypeLabel(projectKey: string): string {
|
|
if (projectKey.endsWith('-web') || projectKey === 'role-admin' || projectKey === 'role-user') {
|
|
return '前端应用'
|
|
}
|
|
|
|
if (projectKey === 'my-resume') {
|
|
return '静态站点'
|
|
}
|
|
|
|
return '后端服务'
|
|
}
|
|
|
|
function tagPolicyLabel(environments: BackendProject['environments']): string {
|
|
const production = environments.find((environment) => environment.name === 'production')
|
|
|
|
if (production?.releasePolicy === 'master') {
|
|
return '生产发布固定 master'
|
|
}
|
|
|
|
if (production?.releasePolicy === 'tag') {
|
|
return '生产发布优先选择 v* tag'
|
|
}
|
|
|
|
return '按环境发布策略执行'
|
|
}
|
|
|
|
function releasePolicyLabel(
|
|
policy: BackendProject['environments'][number]['releasePolicy'],
|
|
defaultBranch: string,
|
|
): string {
|
|
if (policy === 'tag') {
|
|
return 'v* tag'
|
|
}
|
|
|
|
if (policy === 'master') {
|
|
return 'master'
|
|
}
|
|
|
|
return defaultBranch
|
|
}
|
|
|
|
function formatDateTime(value?: string): string {
|
|
if (!value) {
|
|
return '-'
|
|
}
|
|
|
|
const date = new Date(value)
|
|
|
|
if (Number.isNaN(date.getTime())) {
|
|
return value
|
|
}
|
|
|
|
return new Intl.DateTimeFormat('zh-CN', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false,
|
|
})
|
|
.format(date)
|
|
.replace(/\//g, '-')
|
|
}
|
|
|
|
function formatDuration(
|
|
startedAt?: string,
|
|
finishedAt?: string,
|
|
status?: BackendRunStatus,
|
|
): string {
|
|
if (!startedAt) {
|
|
return '-'
|
|
}
|
|
|
|
if (!finishedAt && !['queued', 'running', 'pending'].includes(status ?? 'success')) {
|
|
return '-'
|
|
}
|
|
|
|
const started = new Date(startedAt)
|
|
const finished = finishedAt ? new Date(finishedAt) : new Date()
|
|
|
|
if (Number.isNaN(started.getTime()) || Number.isNaN(finished.getTime())) {
|
|
return '-'
|
|
}
|
|
|
|
const seconds = Math.max(0, Math.round((finished.getTime() - started.getTime()) / 1000))
|
|
|
|
if (seconds < 60) {
|
|
return `${seconds}s`
|
|
}
|
|
|
|
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`
|
|
}
|
|
|
|
function toSystemSettings(
|
|
health: BackendHealth,
|
|
integrationConfig: IntegrationConfigStatus,
|
|
): SystemSetting[] {
|
|
const integrationByKey = new Map(
|
|
integrationConfig.integrations.map((item) => [item.key, item]),
|
|
)
|
|
|
|
return settings.map((item) => {
|
|
if (item.key === 'api-contract') {
|
|
return {
|
|
...item,
|
|
status: toHealthState(health.status),
|
|
contract: '已通过 devops-platform-api 的统一 envelope 读取真实接口数据。',
|
|
}
|
|
}
|
|
|
|
if (item.key === 'agent') {
|
|
return {
|
|
...item,
|
|
status: toHealthStateFromConfig(integrationByKey.get('llm')?.status),
|
|
}
|
|
}
|
|
|
|
if (item.key === 'notification') {
|
|
const notificationConfig = integrationByKey.get('notification') ?? integrationByKey.get('wecom')
|
|
|
|
return {
|
|
...item,
|
|
status: toHealthStateFromConfig(notificationConfig?.status),
|
|
}
|
|
}
|
|
|
|
return item
|
|
})
|
|
}
|
|
|
|
function toHealthStateFromConfig(state?: IntegrationConfigState): HealthState {
|
|
if (state === 'configured') {
|
|
return 'healthy'
|
|
}
|
|
|
|
if (state === 'partial') {
|
|
return 'warning'
|
|
}
|
|
|
|
return 'error'
|
|
}
|