Files
devops-platform-web/src/services/devopsApi.ts
T
2026-06-12 16:36:20 +08:00

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'
}