feat: 初始化DevOps平台前端
This commit is contained in:
@@ -0,0 +1,875 @@
|
||||
/**
|
||||
* 前端唯一 DevOps API 入口;优先联调后端,失败时降级到本地 mock 保持页面可用。
|
||||
*/
|
||||
import {
|
||||
agentCards,
|
||||
dashboardSummary,
|
||||
integrationConfigStatus as mockIntegrationConfigStatus,
|
||||
integrations,
|
||||
projects as mockProjects,
|
||||
releaseProcessXml,
|
||||
runs as mockRuns,
|
||||
settings,
|
||||
} from '../mocks/devops'
|
||||
import type {
|
||||
AgentCard,
|
||||
AgentInvocation,
|
||||
AgentMode,
|
||||
AgentPurpose,
|
||||
CreateAgentInvocationPayload,
|
||||
CreateDeployRunPayload,
|
||||
DashboardSummary,
|
||||
EnvironmentName,
|
||||
HealthState,
|
||||
IntegrationConfigKey,
|
||||
IntegrationConfigState,
|
||||
IntegrationConfigStatus,
|
||||
IntegrationStatus,
|
||||
JenkinsSyncSummary,
|
||||
PipelineStep,
|
||||
ProjectConfig,
|
||||
ProjectRefs,
|
||||
ReleaseRun,
|
||||
ReleaseStatus,
|
||||
StepStatus,
|
||||
SystemSetting,
|
||||
} from '../types/devops'
|
||||
import { http } from './http'
|
||||
|
||||
type DataSource = 'backend' | 'mock'
|
||||
type BackendHealthStatus = 'ok' | 'not_configured' | 'unavailable'
|
||||
type BackendRunStatus = 'pending' | 'queued' | 'running' | 'success' | 'failed' | 'canceled'
|
||||
|
||||
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'
|
||||
environments: Array<{
|
||||
name: EnvironmentName
|
||||
displayName: string
|
||||
jenkinsJobPath: 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
|
||||
projectKey?: string
|
||||
environment?: EnvironmentName
|
||||
ref?: string
|
||||
status?: BackendRunStatus
|
||||
trigger?: 'manual' | 'gitea_webhook' | 'retry'
|
||||
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 BackendProcessDefinition {
|
||||
key: 'release'
|
||||
version: string
|
||||
xml: string
|
||||
nodeIds: string[]
|
||||
}
|
||||
|
||||
let lastDataSource: DataSource = 'mock'
|
||||
|
||||
const wait = async () => new Promise((resolve) => window.setTimeout(resolve, 180))
|
||||
const integrationConfigKeys: IntegrationConfigKey[] = [
|
||||
'jenkins',
|
||||
'gitea',
|
||||
'notification',
|
||||
'wecom',
|
||||
'llm',
|
||||
'database',
|
||||
'redis',
|
||||
'secrets',
|
||||
]
|
||||
const redactedVariableName = '非变量名内容已隐藏'
|
||||
|
||||
export const devopsApi = {
|
||||
getLastDataSource(): DataSource {
|
||||
return lastDataSource
|
||||
},
|
||||
|
||||
async getDashboard(): Promise<DashboardSummary> {
|
||||
return withBackendFallback(
|
||||
async () => {
|
||||
const [health, backendProjects, backendRuns, integrationConfig] = await Promise.all([
|
||||
fetchHealth(),
|
||||
fetchProjects(),
|
||||
fetchRuns(),
|
||||
fetchIntegrationConfigStatusWithFallback(),
|
||||
])
|
||||
const adaptedProjects = toProjectConfigs(backendProjects)
|
||||
|
||||
return {
|
||||
integrations: toIntegrationStatuses(health),
|
||||
projects: adaptedProjects,
|
||||
runs: backendRuns.map((run) => toReleaseRun(run, adaptedProjects)),
|
||||
agentCards,
|
||||
settings: toSystemSettings(health, integrationConfig),
|
||||
integrationConfig,
|
||||
}
|
||||
},
|
||||
() => dashboardSummary,
|
||||
)
|
||||
},
|
||||
|
||||
async getIntegrations(): Promise<IntegrationStatus[]> {
|
||||
return withBackendFallback(
|
||||
async () => toIntegrationStatuses(await fetchHealth()),
|
||||
() => integrations,
|
||||
)
|
||||
},
|
||||
|
||||
async getProjects(): Promise<ProjectConfig[]> {
|
||||
return withBackendFallback(
|
||||
async () => toProjectConfigs(await fetchProjects()),
|
||||
() => mockProjects,
|
||||
)
|
||||
},
|
||||
|
||||
async getProjectRefs(projectKey: string, projectList = mockProjects): Promise<ProjectRefs> {
|
||||
return withBackendFallback(
|
||||
async () => {
|
||||
const { data } = await http.get<ProjectRefs>(`/projects/${projectKey}/refs`)
|
||||
return data
|
||||
},
|
||||
() => createFallbackRefs(projectKey, projectList),
|
||||
)
|
||||
},
|
||||
|
||||
async getRuns(projectList = mockProjects): Promise<ReleaseRun[]> {
|
||||
return withBackendFallback(
|
||||
async () => (await fetchRuns()).map((run) => toReleaseRun(run, projectList)),
|
||||
() => mockRuns,
|
||||
)
|
||||
},
|
||||
|
||||
async createDeployRun(
|
||||
payload: CreateDeployRunPayload,
|
||||
projectList = mockProjects,
|
||||
): Promise<ReleaseRun> {
|
||||
return withBackendFallback(
|
||||
async () => {
|
||||
const { data } = await http.post<BackendDeployRun>('/deploy-runs', payload)
|
||||
return toReleaseRun(data, projectList)
|
||||
},
|
||||
() => createFallbackRun(payload, projectList, 'queued'),
|
||||
)
|
||||
},
|
||||
|
||||
async cancelDeployRun(runId: string, source?: ReleaseRun): Promise<ReleaseRun> {
|
||||
return withBackendFallback(
|
||||
async () => {
|
||||
const { data } = await http.post<BackendDeployRun>(`/deploy-runs/${runId}/cancel`)
|
||||
return toReleaseRun(data, mockProjects)
|
||||
},
|
||||
() => ({
|
||||
...(source ?? mockRuns[0]),
|
||||
id: source?.id ?? runId,
|
||||
status: 'canceled',
|
||||
duration: source?.duration ?? '-',
|
||||
steps: (source?.steps ?? mockRuns[0].steps).map((step) => ({
|
||||
...step,
|
||||
status: step.status === 'success' ? 'success' : 'canceled',
|
||||
summary: step.status === 'success' ? step.summary : '人工取消,本节点未继续执行。',
|
||||
})),
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
async retryDeployRun(runId: string, source?: ReleaseRun): Promise<ReleaseRun> {
|
||||
return withBackendFallback(
|
||||
async () => {
|
||||
const { data } = await http.post<BackendDeployRun>(`/deploy-runs/${runId}/retry`)
|
||||
return toReleaseRun(data, mockProjects)
|
||||
},
|
||||
() =>
|
||||
createFallbackRun(
|
||||
{
|
||||
projectKey: source?.projectKey ?? 'access-manage',
|
||||
environment: source?.environment ?? 'test',
|
||||
ref: source?.ref ?? 'develop',
|
||||
operator: source?.actor ?? 'ops-admin',
|
||||
remark: source ? `重试 ${source.id}` : `重试 ${runId}`,
|
||||
},
|
||||
mockProjects,
|
||||
'queued',
|
||||
),
|
||||
)
|
||||
},
|
||||
|
||||
async syncJenkinsRun(
|
||||
runId: string,
|
||||
source?: ReleaseRun,
|
||||
projectList = mockProjects,
|
||||
): Promise<ReleaseRun> {
|
||||
return withBackendFallback(
|
||||
async () => {
|
||||
const { data } = await http.post<BackendDeployRun>(`/deploy-runs/${runId}/sync-jenkins`)
|
||||
return toReleaseRun(data, projectList)
|
||||
},
|
||||
() => source ?? mockRuns.find((run) => run.id === runId) ?? mockRuns[0],
|
||||
)
|
||||
},
|
||||
|
||||
async syncJenkinsRuns(projectList = mockProjects): Promise<JenkinsSyncSummary> {
|
||||
return withBackendFallback(
|
||||
async () => {
|
||||
const { data } = await http.post<BackendJenkinsSyncSummary>(
|
||||
'/deploy-runs/sync-jenkins',
|
||||
)
|
||||
return toJenkinsSyncSummary(data, projectList)
|
||||
},
|
||||
() => ({
|
||||
checkedAt: new Date().toISOString(),
|
||||
totalCandidates: 0,
|
||||
syncedRuns: [],
|
||||
failedRuns: [],
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
async getAgentCards(): Promise<AgentCard[]> {
|
||||
await wait()
|
||||
return agentCards
|
||||
},
|
||||
|
||||
async getIntegrationConfigStatus(): Promise<IntegrationConfigStatus> {
|
||||
return withBackendFallback(
|
||||
async () => fetchIntegrationConfigStatus(),
|
||||
() => mockIntegrationConfigStatus,
|
||||
)
|
||||
},
|
||||
|
||||
async createAgentInvocation(
|
||||
payload: CreateAgentInvocationPayload,
|
||||
): Promise<AgentInvocation> {
|
||||
return withBackendFallback(
|
||||
async () => {
|
||||
const { data } = await http.post<AgentInvocation>('/agent/invocations', payload)
|
||||
return data
|
||||
},
|
||||
() => createFallbackAgentInvocation(payload),
|
||||
)
|
||||
},
|
||||
|
||||
async getSettings(): Promise<SystemSetting[]> {
|
||||
return withBackendFallback(
|
||||
async () => {
|
||||
const [health, integrationConfig] = await Promise.all([
|
||||
fetchHealth(),
|
||||
fetchIntegrationConfigStatusWithFallback(),
|
||||
])
|
||||
|
||||
return toSystemSettings(health, integrationConfig)
|
||||
},
|
||||
() => settings,
|
||||
)
|
||||
},
|
||||
|
||||
async getReleaseProcessXml(): Promise<string> {
|
||||
return withBackendFallback(
|
||||
async () => {
|
||||
const { data } = await http.get<BackendProcessDefinition>(
|
||||
'/process-definitions/release',
|
||||
)
|
||||
return data.xml
|
||||
},
|
||||
() => releaseProcessXml,
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
async function withBackendFallback<T>(
|
||||
request: () => Promise<T>,
|
||||
fallback: () => T | Promise<T>,
|
||||
): Promise<T> {
|
||||
if (import.meta.env.VITE_DEVOPS_USE_MOCK === 'true') {
|
||||
lastDataSource = 'mock'
|
||||
await wait()
|
||||
return fallback()
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await request()
|
||||
lastDataSource = 'backend'
|
||||
return result
|
||||
} catch {
|
||||
lastDataSource = 'mock'
|
||||
await wait()
|
||||
return fallback()
|
||||
}
|
||||
}
|
||||
|
||||
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<IntegrationConfigStatus>('/settings/integration-config')
|
||||
return sanitizeIntegrationConfigStatus(data)
|
||||
}
|
||||
|
||||
async function fetchIntegrationConfigStatusWithFallback(): Promise<IntegrationConfigStatus> {
|
||||
try {
|
||||
return await fetchIntegrationConfigStatus()
|
||||
} catch {
|
||||
return mockIntegrationConfigStatus
|
||||
}
|
||||
}
|
||||
|
||||
function toIntegrationStatuses(health: BackendHealth): IntegrationStatus[] {
|
||||
return [
|
||||
toIntegrationStatus('jenkins', 'Jenkins', health),
|
||||
toIntegrationStatus('gitea', 'Gitea', health),
|
||||
toIntegrationStatus('wecom', '通知平台', health),
|
||||
toIntegrationStatus('llm', 'LLM Agent', health),
|
||||
]
|
||||
}
|
||||
|
||||
function toIntegrationStatus(
|
||||
key: IntegrationStatus['key'],
|
||||
name: string,
|
||||
health: BackendHealth,
|
||||
): IntegrationStatus {
|
||||
const dependency = health.dependencies[key] ?? { status: 'unavailable' as const }
|
||||
|
||||
return {
|
||||
key,
|
||||
name,
|
||||
state: toHealthState(dependency.status),
|
||||
summary: dependency.message || healthMessage(dependency.status),
|
||||
checkedAt: formatDateTime(health.checkedAt),
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeIntegrationConfigStatus(
|
||||
payload: IntegrationConfigStatus,
|
||||
): 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)
|
||||
|
||||
return {
|
||||
key: item.key,
|
||||
name: typeof item.name === 'string' && item.name ? item.name : item.key,
|
||||
status: sanitizeIntegrationConfigState(item.status),
|
||||
required,
|
||||
configured,
|
||||
missing,
|
||||
optional: sanitizeVariableNames(item.optional),
|
||||
note: sanitizeIntegrationNote(item.note),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
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 toProjectConfigs(backendProjects: BackendProject[]): ProjectConfig[] {
|
||||
return backendProjects.map((project) => {
|
||||
const mockProject = mockProjects.find((item) => item.key === project.key)
|
||||
const repo = parseRepository(project.repositoryUrl, project.key)
|
||||
|
||||
return {
|
||||
key: project.key,
|
||||
name: project.name,
|
||||
repository: project.repositoryUrl,
|
||||
owner: repo.owner,
|
||||
repo: repo.repo,
|
||||
serviceType: mockProject?.serviceType ?? '服务',
|
||||
defaultBranch: project.defaultBranch,
|
||||
tagPolicy: mockProject?.tagPolicy ?? '生产发布优先选择 v* tag',
|
||||
environments: project.environments.map((environment) => {
|
||||
const mockEnvironment = mockProject?.environments.find(
|
||||
(item) => item.name === environment.name,
|
||||
)
|
||||
|
||||
return {
|
||||
name: environment.name,
|
||||
label: environment.displayName,
|
||||
branchPolicy:
|
||||
mockEnvironment?.branchPolicy ??
|
||||
(environment.name === 'production' ? 'v* tag' : project.defaultBranch),
|
||||
status: mockEnvironment?.status ?? 'idle',
|
||||
version: mockEnvironment?.version ?? '-',
|
||||
lastRunId: mockEnvironment?.lastRunId ?? '-',
|
||||
lastReleasedAt: mockEnvironment?.lastReleasedAt ?? '-',
|
||||
lastActor: mockEnvironment?.lastActor ?? '-',
|
||||
jenkinsJob: environment.jenkinsJobPath,
|
||||
wecomRobot: mockEnvironment?.wecomRobot ?? '后端通知配置',
|
||||
}
|
||||
}),
|
||||
integrations: mockProject?.integrations ?? {
|
||||
jenkins: 'warning',
|
||||
gitea: 'warning',
|
||||
wecom: 'warning',
|
||||
llm: 'warning',
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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()}`,
|
||||
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 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 createFallbackRun(
|
||||
payload: CreateDeployRunPayload,
|
||||
projectList: ProjectConfig[],
|
||||
status: ReleaseStatus,
|
||||
): ReleaseRun {
|
||||
const project = projectList.find((item) => item.key === payload.projectKey)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
return {
|
||||
id: payload.idempotencyKey || `local-${Date.now()}`,
|
||||
projectKey: payload.projectKey,
|
||||
projectName: project?.name ?? payload.projectKey,
|
||||
environment: payload.environment,
|
||||
ref: payload.ref,
|
||||
status,
|
||||
actor: payload.operator,
|
||||
startedAt: formatDateTime(now),
|
||||
duration: status === 'queued' ? '等待 Jenkins queue' : '-',
|
||||
note: payload.remark || '本地降级发布单',
|
||||
jenkinsSummary: '等待 Jenkins 返回',
|
||||
jenkinsBuildUrl: '',
|
||||
commitSummary: '本地降级:后端不可用时保留操作上下文',
|
||||
steps: mockRuns[0].steps.map((step) => ({
|
||||
...step,
|
||||
status: step.bpmnNodeId === 'start-release' ? 'success' : 'pending',
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function createFallbackAgentInvocation(
|
||||
payload: CreateAgentInvocationPayload,
|
||||
): AgentInvocation {
|
||||
return {
|
||||
id: `local-agent-${Date.now()}`,
|
||||
runId: payload.runId,
|
||||
projectKey: payload.projectKey,
|
||||
type: payload.type,
|
||||
status: 'success',
|
||||
promptSummary: payload.promptSummary,
|
||||
resultMarkdown: fallbackAgentResult(payload.type),
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
function createFallbackRefs(projectKey: string, projectList: ProjectConfig[]): ProjectRefs {
|
||||
const project = projectList.find((item) => item.key === projectKey)
|
||||
const defaultBranch = project?.defaultBranch ?? 'develop'
|
||||
|
||||
return {
|
||||
source: 'mock',
|
||||
branches: Array.from(new Set([defaultBranch, 'develop', 'master']))
|
||||
.filter(Boolean)
|
||||
.map((name) => ({ name })),
|
||||
tags: [
|
||||
{ name: 'v2026.06.05-envsplit.7' },
|
||||
{ name: 'v2026.06.11-api.1' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
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 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 '失败后重试'
|
||||
}
|
||||
|
||||
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 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 = mockIntegrationConfigStatus,
|
||||
): 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;后端不可用时降级到本地 mock。',
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
function fallbackAgentResult(type: AgentPurpose): string {
|
||||
const mode: Record<AgentPurpose, AgentMode> = {
|
||||
'release-risk': 'risk',
|
||||
'failure-diagnosis': 'diagnosis',
|
||||
'runbook-qa': 'runbook',
|
||||
'release-note': 'releaseNotes',
|
||||
'incident-review': 'diagnosis',
|
||||
}
|
||||
const card = agentCards.find((item) => item.mode === mode[type])
|
||||
|
||||
return [
|
||||
`${card?.title ?? 'Agent'} 本地降级结果`,
|
||||
'后端或 LLM 未配置时,前端只保留 DevOps 场景输入,不保存任何模型密钥。',
|
||||
].join('\n\n')
|
||||
}
|
||||
Reference in New Issue
Block a user