/** * 前端唯一 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 } 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 variableDescriptions?: Record 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 { const { data } = await http.post('/auth/login', payload) lastDataSource = 'backend' return data }, async getCurrentUser(): Promise { const { data } = await http.get('/auth/me') lastDataSource = 'backend' return data }, async changePassword(payload: ChangePasswordPayload): Promise { const { data } = await http.post('/auth/change-password', payload) lastDataSource = 'backend' return data }, async forgotPassword(payload: ForgotPasswordPayload): Promise { await http.post('/auth/forgot-password', payload) lastDataSource = 'backend' }, async logout(): Promise { await http.post('/auth/logout') lastDataSource = 'backend' }, async getMembers(): Promise { const { data } = await http.get('/members') lastDataSource = 'backend' return Array.isArray(data) ? data : [] }, async createMember(payload: CreateMemberPayload): Promise { const { data } = await http.post('/members', payload) lastDataSource = 'backend' return data }, async updateMemberPermissions( memberId: string, payload: UpdateMemberPermissionsPayload, ): Promise { const { data } = await http.patch( `/members/${memberId}/permissions`, payload, ) lastDataSource = 'backend' return data }, async resetMemberPassword(memberId: string): Promise { const { data } = await http.post( `/members/${memberId}/reset-password`, ) lastDataSource = 'backend' return data }, async updateMemberStatus( memberId: string, payload: UpdateMemberStatusPayload, ): Promise { const { data } = await http.patch( `/members/${memberId}/status`, payload, ) lastDataSource = 'backend' return data }, async deleteMember(memberId: string): Promise { await http.delete(`/members/${memberId}`) lastDataSource = 'backend' }, async getMessages(): Promise { const { data } = await http.get('/messages') lastDataSource = 'backend' return Array.isArray(data) ? data : [] }, async markMessageHandled(messageId: string): Promise { await http.post(`/messages/${messageId}/handled`) lastDataSource = 'backend' }, async getDashboard(options: DashboardOptions = {}): Promise { 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 { lastDataSource = 'backend' return toIntegrationStatuses(await fetchHealth()) }, async getProjects(): Promise { lastDataSource = 'backend' return toProjectConfigs(await fetchProjects()) }, async getProjectJenkinsDiagnostics(): Promise { const { data } = await http.get( '/projects/jenkins-diagnostics', ) lastDataSource = 'backend' return sanitizeProjectJenkinsDiagnostics(data) }, async getProjectGiteaDiagnostics(): Promise { const { data } = await http.get( '/projects/gitea-diagnostics', ) lastDataSource = 'backend' return sanitizeProjectGiteaDiagnostics(data) }, async getProjectRefs(projectKey: string): Promise { const { data } = await http.get(`/projects/${projectKey}/refs`) lastDataSource = 'backend' return data }, async getRuns(projectList: ProjectConfig[] = []): Promise { lastDataSource = 'backend' return (await fetchRuns()).map((run) => toReleaseRun(run, projectList)) }, async createDeployRun( payload: CreateDeployRunPayload, projectList: ProjectConfig[] = [], ): Promise { const { data } = await http.post('/deploy-runs', payload) lastDataSource = 'backend' return toReleaseRun(data, projectList) }, async cancelDeployRun( runId: string, projectList: ProjectConfig[] = [], ): Promise { const { data } = await http.post(`/deploy-runs/${runId}/cancel`) lastDataSource = 'backend' return toReleaseRun(data, projectList) }, async retryDeployRun( runId: string, projectList: ProjectConfig[] = [], ): Promise { const { data } = await http.post(`/deploy-runs/${runId}/retry`) lastDataSource = 'backend' return toReleaseRun(data, projectList) }, async syncJenkinsRun( runId: string, projectList: ProjectConfig[] = [], ): Promise { const { data } = await http.post(`/deploy-runs/${runId}/sync-jenkins`) lastDataSource = 'backend' return toReleaseRun(data, projectList) }, async syncJenkinsRuns(projectList: ProjectConfig[] = []): Promise { const { data } = await http.post( '/deploy-runs/sync-jenkins', ) lastDataSource = 'backend' return toJenkinsSyncSummary(data, projectList) }, async importJenkinsBuilds(projectList: ProjectConfig[] = []): Promise { const { data } = await http.post( '/deploy-runs/import-jenkins', ) lastDataSource = 'backend' return toJenkinsImportSummary(data, projectList) }, async getJenkinsLog(runId: string, start = 0): Promise { const { data } = await http.get(`/deploy-runs/${runId}/jenkins-log`, { params: { start }, }) lastDataSource = 'backend' return data }, async getAuditLogs(): Promise { const { data } = await http.get('/audit-logs') lastDataSource = 'backend' return Array.isArray(data) ? data.map(sanitizeAuditLogEntry) : [] }, async getNotificationOutbox(): Promise { const { data } = await http.get('/notification-outbox') lastDataSource = 'backend' return Array.isArray(data) ? data.map(sanitizeNotificationOutboxMessage) : [] }, async retryNotificationOutboxMessage(id: string): Promise { const { data } = await http.post( `/notification-outbox/${id}/retry`, ) lastDataSource = 'backend' return sanitizeNotificationOutboxMessage(data) }, async getAgentCards(): Promise { return agentCards }, async getIntegrationConfigStatus(): Promise { lastDataSource = 'backend' return fetchIntegrationConfigStatus() }, async getAgentConfig(): Promise { const { data } = await http.get('/agent/config') lastDataSource = 'backend' return data }, async saveAgentConfig( payload: SaveAgentConfigPayload, ): Promise { const { data } = await http.put('/agent/config', payload) lastDataSource = 'backend' return data }, async testAgentConfig(): Promise { const { data } = await http.post( '/agent/config/test', ) lastDataSource = 'backend' return data }, async getReleaseProcessXml(): Promise { const { data } = await http.get( '/process-definitions/release', ) lastDataSource = 'backend' return data.xml }, async createAgentInvocation( payload: CreateAgentInvocationPayload, ): Promise { const { data } = await http.post('/agent/invocations', payload) lastDataSource = 'backend' return data }, async listAgentInvocations(): Promise { const { data } = await http.get('/agent/invocations') lastDataSource = 'backend' return data }, async getSettings(): Promise { const [health, integrationConfig] = await Promise.all([ fetchHealth(), fetchIntegrationConfigStatus(), ]) lastDataSource = 'backend' return toSystemSettings(health, integrationConfig) }, } async function fetchHealth(): Promise { const { data } = await http.get('/health') return data } async function fetchProjects(): Promise { const { data } = await http.get('/projects') return data } async function fetchRuns(): Promise { const { data } = await http.get('/deploy-runs') return data } async function fetchIntegrationConfigStatus(): Promise { const { data } = await http.get( '/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 { const help: Record = {} collectVariableHelpMap(help, item.variableHelp) collectVariableDescriptionMap(help, item.variableDescriptions) collectVariableHelpArray(help, item.variables) return help } function collectVariableHelpMap( target: Record, value?: Record, ) { 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, value?: Record, ) { 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, 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 | undefined { if (!value || typeof value !== 'object' || Array.isArray(value)) { return undefined } return value as Record } 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' }