feat: 初始化DevOps平台前端

This commit is contained in:
湛兮
2026-06-11 20:49:59 +08:00
commit 60931e2c21
38 changed files with 7033 additions and 0 deletions
+875
View File
@@ -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')
}
+66
View File
@@ -0,0 +1,66 @@
import axios from 'axios'
/**
* DevOps 平台统一 HTTP 实例,只识别后端 envelope,不承接任何第三方密钥。
*/
export const http = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 12_000,
withCredentials: true,
})
http.interceptors.response.use(
(response) => {
response.data = unwrapApiPayload(response.data)
return response
},
(error) => {
const message =
error.response?.data?.error?.message ||
error.response?.data?.message ||
error.message ||
'请求失败,请稍后重试'
return Promise.reject(new Error(message))
},
)
interface ApiEnvelope<T> {
success: boolean
data?: T
error?: {
code: string
message: string
details?: unknown
}
}
function unwrapApiPayload(payload: unknown): unknown {
if (!isRecord(payload)) {
return payload
}
if (isApiEnvelope(payload)) {
const envelope = payload
if (envelope.success) {
return envelope.data
}
throw new Error(envelope.error?.message || '请求失败,请稍后重试')
}
if (Object.keys(payload).length === 1 && 'data' in payload) {
return payload.data
}
return payload
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isApiEnvelope(value: unknown): value is ApiEnvelope<unknown> {
return isRecord(value) && typeof value.success === 'boolean'
}