feat: 展示集成变量说明与流程详情

This commit is contained in:
湛兮
2026-06-11 23:52:01 +08:00
parent 88836590cd
commit 2daf93ab24
6 changed files with 982 additions and 131 deletions
+3 -1
View File
@@ -8,7 +8,7 @@ Vue 3 + Vite + TypeScript 实现的 DevOps 运维控制台第一阶段,用于
- 所有项目、发布、refs、流程和集成状态均调用 `devops-platform-api`,前端不再提供本地 mock 数据降级。
- 发布中心包含项目、环境、ref/tag、备注、生产二次确认、触发、取消、重试和 Jenkins 状态同步入口。
- 运行记录展示 Jenkins queue/build、Build URL、开始/结束时间、执行提示、失败摘要和日志摘要。
- BPMN Viewer 使用 `bpmn-js` 渲染后端流程 XML,并 queued/running/success/failed/skipped/canceled 状态高亮节点
- BPMN Viewer 使用 `bpmn-js` 渲染后端流程 XML,并通过进度统计、节点状态 badge、右侧时间线和详情面板展示 queued/running/success/failed/skipped/canceled 状态。
- Agent 作为全局工具从顶部打开,只包含发布风险摘要、失败诊断、Runbook 问答、发布说明草稿,并通过后端 Agent API 创建调用记录。
## 技术栈
@@ -41,6 +41,8 @@ pnpm build
接口入口为 `src/services/devopsApi.ts`,只调用后端统一 envelope 响应;后端不可用时页面应暴露真实接口错误,不伪造项目、运行记录或 refs。后续以 `/Users/mac033/Desktop/my-project/devops-platform-docs/devops-platform-implementation-tracker.md` 中的产品约束和后端 OpenAPI 为准继续收敛字段。
通知平台配置方案见 [docs/notification-platform-config.md](docs/notification-platform-config.md),包含企业微信、飞书和通用 webhook 的后端变量、安全边界与验证步骤。`/settings` 页面支持展示后端返回的变量说明字段,用于 hover 查看每个变量含义。
浏览器端不得保存或直连以下密钥和服务:
- Jenkins token
+166
View File
@@ -0,0 +1,166 @@
# DevOps 通知平台配置方案
更新时间:2026-06-11
## 当前结论
通知平台不是前端能力,web 端只展示后端返回的配置状态、变量名和变量说明。真实 webhook、token、签名密钥只能存在于 `devops-platform-api` 的服务端环境变量、服务器 secret 或后续加密存储中。
当前后端通知 provider 已按以下变量预留:
| Provider | 适用平台 | 必需变量 | Webhook 变量 | 当前策略 |
| --- | --- | --- | --- | --- |
| `wecom` | 企业微信 | `NOTIFICATION_PROVIDER=wecom` | `WECOM_WEBHOOK_URL` | 发送 markdown 发布通知 |
| `feishu` | 飞书 | `NOTIFICATION_PROVIDER=feishu` | `FEISHU_WEBHOOK_URL` | 发送 text 发布通知 |
| `generic` | 内部通知网关 | `NOTIFICATION_PROVIDER=generic` | `NOTIFICATION_WEBHOOK_URL` | 发送结构化 JSON |
未配置 provider 或 webhook 时,发布流程不应被阻断;通知节点应显示 skipped/failed,并把脱敏摘要写入审计日志。
## 后端环境变量
```env
DEVOPS_PUBLIC_URL=https://devops.mrzhan.top
# 任选其一:wecom / feishu / generic
NOTIFICATION_PROVIDER=wecom
# 企业微信机器人
WECOM_WEBHOOK_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace-with-real-key
# 飞书自定义机器人
FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/replace-with-real-token
# 通用内部 webhook
NOTIFICATION_WEBHOOK_URL=https://notification.example.internal/webhook/replace-with-secret
```
安全要求:
| 要求 | 说明 |
| --- | --- |
| 不进前端 | web 端只拿变量名、状态和说明,不接收真实 URL |
| 不进 Git | `.env`、服务器 secret、后续 `secrets` 表均不得提交真实 webhook |
| 日志脱敏 | 任何异常日志、审计摘要和 Agent 上下文都不能包含 webhook 原文 |
| 不阻断发布 | 通知失败只影响通知节点和审计,不回滚 Jenkins 构建结果 |
## 企业微信配置
官方参考:`https://developer.work.weixin.qq.com/document/path/91770`
1. 在企业微信群中添加群机器人。
2. 复制机器人 webhook,保存到服务端环境变量 `WECOM_WEBHOOK_URL`
3. 设置 `NOTIFICATION_PROVIDER=wecom`
4. 重启 `devops-platform-api`
5. 访问 `/settings`,确认“通知平台”从未配置变成已配置或部分配置。
6. 创建一次测试发布单,检查流程中的通知节点和群内消息。
本地验证命令:
```bash
curl -X POST "$WECOM_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d '{"msgtype":"markdown","markdown":{"content":"# DevOps 通知测试\n\n> 项目:devops-platform-web\n> 环境:production\n> 状态:success"}}'
```
## 飞书配置
官方参考:`https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot`
1. 在飞书目标群添加“自定义机器人”。
2. 复制 webhook,保存到服务端环境变量 `FEISHU_WEBHOOK_URL`
3. 建议开启安全设置:关键词、IP 白名单或签名校验。
4. 设置 `NOTIFICATION_PROVIDER=feishu`
5. 重启 `devops-platform-api`
6. 创建测试发布单,检查通知节点和飞书群消息。
基础 text 消息验证:
```bash
curl -X POST "$FEISHU_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d '{"msg_type":"text","content":{"text":"DevOps 通知测试:devops-platform-web production success"}}'
```
如果开启飞书签名校验,需要后端按飞书规则计算 timestamp/sign,再放入请求 body;不要在前端计算或保存签名密钥。
## 通用 webhook 配置
适合接内部通知网关或消息中转服务:
```env
NOTIFICATION_PROVIDER=generic
NOTIFICATION_WEBHOOK_URL=https://notification.example.internal/webhook/replace-with-secret
```
建议后端投递结构:
```json
{
"event": "deploy.succeeded",
"title": "DevOps 发布成功",
"run": {
"id": "run_xxx",
"projectKey": "devops-platform-web",
"environment": "production",
"ref": "master",
"status": "success",
"operator": "ops-admin"
},
"publicUrl": "https://devops.mrzhan.top"
}
```
## Settings 变量说明契约
`/settings` 页面已按后端返回字段展示 hover 说明。后端推荐在 `GET /settings/integration-config` 中为每个变量补充 `variableHelp`
```json
{
"key": "notification",
"name": "通知平台",
"status": "partial",
"required": ["NOTIFICATION_PROVIDER", "WECOM_WEBHOOK_URL"],
"configured": ["NOTIFICATION_PROVIDER"],
"missing": ["WECOM_WEBHOOK_URL"],
"optional": ["FEISHU_WEBHOOK_URL", "NOTIFICATION_WEBHOOK_URL", "DEVOPS_PUBLIC_URL"],
"variableHelp": {
"NOTIFICATION_PROVIDER": {
"description": "通知 provider,可选 wecom、feishu、generic。",
"example": "wecom",
"docUrl": "https://developer.work.weixin.qq.com/document/path/91770"
},
"WECOM_WEBHOOK_URL": {
"description": "企业微信群机器人的服务端 webhook,只能保存在后端。",
"docUrl": "https://developer.work.weixin.qq.com/document/path/91770"
},
"FEISHU_WEBHOOK_URL": {
"description": "飞书自定义机器人的服务端 webhook,只能保存在后端。",
"docUrl": "https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot"
},
"NOTIFICATION_WEBHOOK_URL": {
"description": "内部通知网关或通用 webhook 的服务端地址。"
},
"DEVOPS_PUBLIC_URL": {
"description": "通知消息里跳转回 DevOps 平台的公开访问地址。"
}
}
}
```
前端兼容字段:
| 字段 | 说明 |
| --- | --- |
| `variableHelp` | 推荐格式,支持 description、example、docUrl |
| `variableDescriptions` | 兼容 `变量名 -> 说明字符串` 的简单格式 |
| `variables[]` | 兼容 `{ name, description, example, docUrl }` 数组格式 |
## 上线检查
| 检查项 | 通过标准 |
| --- | --- |
| `/settings` | 只展示变量名、配置状态和后端说明,不展示真实 webhook |
| 发布流程 | 通知未配置时节点 skipped,不阻断成功发布 |
| 审计日志 | 记录通知投递结果,摘要已脱敏 |
| Agent | 只能解释通知 skipped/failed,不输出或猜测 webhook |
| 服务器 | 真实 webhook 仅在 `.env`、secret manager 或加密存储中存在 |
+481 -49
View File
@@ -12,9 +12,19 @@ const props = defineProps<{
steps: PipelineStep[]
}>()
type StatusMeta = {
label: string
short: string
className: string
description: string
}
const canvasEl = ref<HTMLDivElement>()
const selectedNodeId = ref('')
const viewer = shallowRef<InstanceType<typeof BpmnViewer>>()
let eventBusBound = false
let overlayIds: Array<string | number> = []
let markedNodeIds = new Set<string>()
const markerClasses: Record<StepStatus, string> = {
pending: 'bpmn-step-pending',
@@ -26,18 +36,103 @@ const markerClasses: Record<StepStatus, string> = {
canceled: 'bpmn-step-canceled',
}
function findStep(nodeId: string) {
return props.steps.find((step) => step.bpmnNodeId === nodeId)
const statusMeta: Record<StepStatus, StatusMeta> = {
pending: {
label: '等待中',
short: '等',
className: 'pending',
description: '前置节点尚未完成,流程还没有执行到这里。',
},
queued: {
label: '排队中',
short: '排',
className: 'queued',
description: '任务已经提交,正在等待 Jenkins 或后端调度。',
},
running: {
label: '运行中',
short: '行',
className: 'running',
description: '当前节点正在执行,状态会随后端同步刷新。',
},
success: {
label: '成功',
short: '成',
className: 'success',
description: '节点已经完成且没有阻断后续流程。',
},
failed: {
label: '失败',
short: '败',
className: 'failed',
description: '节点执行失败,需要查看失败摘要和日志片段。',
},
skipped: {
label: '跳过',
short: '跳',
className: 'skipped',
description: '节点被后端判定为可跳过,通常不阻断发布。',
},
canceled: {
label: '取消',
short: '取',
className: 'canceled',
description: '流程被人工取消或由后端终止。',
},
}
const statusLegend = Object.values(statusMeta)
const orderedSteps = computed(() => props.steps)
const currentStep = computed(() => {
const active = orderedSteps.value.find((step) =>
['running', 'queued', 'failed'].includes(step.status),
)
if (active) {
return active
}
return (
orderedSteps.value
.slice()
.reverse()
.find((step) => ['success', 'skipped', 'canceled'].includes(step.status)) ??
orderedSteps.value[0]
)
})
const selectedStep = computed(() => {
if (!selectedNodeId.value) {
return undefined
return currentStep.value
}
return findStep(selectedNodeId.value)
return findStep(selectedNodeId.value) ?? currentStep.value
})
const stepStats = computed(() => {
const total = orderedSteps.value.length
const done = orderedSteps.value.filter((step) =>
['success', 'failed', 'skipped', 'canceled'].includes(step.status),
).length
const running = orderedSteps.value.filter((step) => step.status === 'running').length
const failed = orderedSteps.value.filter((step) => step.status === 'failed').length
return { total, done, running, failed }
})
const progressPercent = computed(() => {
if (stepStats.value.total === 0) {
return 0
}
return Math.round((stepStats.value.done / stepStats.value.total) * 100)
})
function findStep(nodeId: string) {
return props.steps.find((step) => step.bpmnNodeId === nodeId)
}
async function renderDiagram() {
if (!canvasEl.value || !props.xml) {
return
@@ -52,12 +147,17 @@ async function renderDiagram() {
await viewer.value.importXML(props.xml)
const canvas = viewer.value.get('canvas') as {
zoom: (mode: string) => void
addMarker: (id: string, marker: string) => void
removeMarker: (id: string, marker: string) => void
}
canvas.zoom('fit-viewport')
bindEventBus()
applyMarkers()
}
function bindEventBus() {
if (!viewer.value || eventBusBound) {
return
}
const eventBus = viewer.value.get('eventBus') as {
on: (event: string, callback: (event: { element?: { id: string } }) => void) => void
@@ -65,10 +165,12 @@ async function renderDiagram() {
eventBus.on('element.click', (event) => {
const nodeId = event.element?.id
if (nodeId && findStep(nodeId)) {
selectedNodeId.value = nodeId
}
})
eventBusBound = true
}
function applyMarkers() {
@@ -80,17 +182,90 @@ function applyMarkers() {
addMarker: (id: string, marker: string) => void
removeMarker: (id: string, marker: string) => void
}
const elementRegistry = viewer.value.get('elementRegistry') as {
get: (id: string) => unknown
}
markedNodeIds.forEach((nodeId) => {
Object.values(markerClasses).forEach((marker) => {
canvas.removeMarker(nodeId, marker)
})
canvas.removeMarker(nodeId, 'bpmn-step-selected')
})
markedNodeIds = new Set()
clearOverlays()
props.steps.forEach((step) => {
Object.values(markerClasses).forEach((marker) => {
canvas.removeMarker(step.bpmnNodeId, marker)
})
if (!elementRegistry.get(step.bpmnNodeId)) {
return
}
canvas.addMarker(step.bpmnNodeId, markerClasses[step.status] ?? markerClasses.pending)
if (selectedStep.value?.bpmnNodeId === step.bpmnNodeId) {
canvas.addMarker(step.bpmnNodeId, 'bpmn-step-selected')
}
markedNodeIds.add(step.bpmnNodeId)
addStepOverlay(step)
})
}
function clearOverlays() {
if (!viewer.value || overlayIds.length === 0) {
overlayIds = []
return
}
const overlays = viewer.value.get('overlays') as {
remove: (id: string | number) => void
}
overlayIds.forEach((id) => overlays.remove(id))
overlayIds = []
}
function addStepOverlay(step: PipelineStep) {
if (!viewer.value) {
return
}
const overlays = viewer.value.get('overlays') as {
add: (
elementId: string,
type: string,
options: { position: { top?: number; right?: number }; html: HTMLElement },
) => string | number
}
const meta = statusMeta[step.status]
const badge = document.createElement('button')
badge.type = 'button'
badge.className = `bpmn-step-badge is-${meta.className}`
badge.textContent = meta.short
badge.title = `${step.name}${meta.label}`
badge.addEventListener('click', (event) => {
event.stopPropagation()
selectedNodeId.value = step.bpmnNodeId
})
overlayIds.push(
overlays.add(step.bpmnNodeId, 'step-status', {
position: { top: -12, right: -12 },
html: badge,
}),
)
}
function statusClass(status: StepStatus): string {
return statusMeta[status]?.className ?? 'pending'
}
function isSelectedStep(step: PipelineStep): boolean {
return selectedStep.value?.bpmnNodeId === step.bpmnNodeId
}
watch(
() => [props.xml, props.steps.map((step) => `${step.bpmnNodeId}:${step.status}`).join('|')],
() => props.xml,
async () => {
await nextTick()
await renderDiagram()
@@ -100,41 +275,100 @@ watch(
},
)
watch(
() => props.steps.map((step) => `${step.bpmnNodeId}:${step.status}`).join('|'),
() => {
applyMarkers()
},
)
watch(selectedNodeId, () => {
applyMarkers()
})
onBeforeUnmount(() => {
clearOverlays()
viewer.value?.destroy()
viewer.value = undefined
eventBusBound = false
})
</script>
<template>
<div class="bpmn-viewer">
<div ref="canvasEl" class="bpmn-canvas" />
<div class="bpmn-side">
<div class="legend">
<span><i class="dot success" />成功</span>
<span><i class="dot queued" />排队</span>
<span><i class="dot running" />运行</span>
<span><i class="dot failed" />失败</span>
<span><i class="dot pending" />等待</span>
<div class="flow-main">
<div class="flow-toolbar">
<div>
<strong>发布执行链路</strong>
<span>当前{{ selectedStep?.name || '等待后端步骤' }}</span>
</div>
<div class="compact-list">
<div class="legend">
<span v-for="meta in statusLegend" :key="meta.className">
<i class="dot" :class="meta.className" />{{ meta.label }}
</span>
</div>
</div>
<div ref="canvasEl" class="bpmn-canvas" />
</div>
<aside class="bpmn-side">
<div class="flow-stats">
<div>
<small>流程进度</small>
<strong>{{ progressPercent }}%</strong>
</div>
<div>
<small>节点</small>
<strong>{{ stepStats.done }}/{{ stepStats.total }}</strong>
</div>
<div>
<small>运行</small>
<strong>{{ stepStats.running }}</strong>
</div>
<div>
<small>失败</small>
<strong>{{ stepStats.failed }}</strong>
</div>
<div class="progress-track">
<span :style="{ width: `${progressPercent}%` }" />
</div>
</div>
<div class="timeline-list">
<button
v-for="step in steps"
v-for="(step, index) in orderedSteps"
:key="step.id"
class="step-row"
:class="{ active: selectedNodeId === step.bpmnNodeId }"
class="timeline-step"
:class="[statusClass(step.status), { active: isSelectedStep(step) }]"
type="button"
@click="selectedNodeId = step.bpmnNodeId"
>
<span>
<i class="timeline-index">{{ index + 1 }}</i>
<span class="timeline-text">
<strong>{{ step.name }}</strong>
<small>{{ step.owner }} · {{ step.duration }}</small>
<small>{{ step.owner }} · {{ statusMeta[step.status].description }}</small>
</span>
<StatusPill :state="step.status" />
</button>
</div>
<div v-if="selectedStep" class="node-detail">
<div class="node-detail-head">
<span>
<strong>{{ selectedStep.name }}</strong>
<small>{{ selectedStep.owner }} · {{ selectedStep.bpmnNodeId }}</small>
</span>
<StatusPill :state="selectedStep.status" />
</div>
<p>{{ selectedStep.summary }}</p>
<dl>
<dt>耗时</dt>
<dd>{{ selectedStep.duration }}</dd>
<dt>开始</dt>
<dd>{{ selectedStep.startedAt || '-' }}</dd>
<dt>结束</dt>
<dd>{{ selectedStep.finishedAt || '-' }}</dd>
</dl>
<p v-if="selectedStep.message">
<span>执行消息</span>
{{ selectedStep.message }}
@@ -145,28 +379,65 @@ onBeforeUnmount(() => {
</p>
<pre v-if="selectedStep.logExcerpt">{{ selectedStep.logExcerpt }}</pre>
</div>
</div>
</aside>
</div>
</template>
<style scoped>
.bpmn-viewer {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 14px;
min-height: 360px;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 16px;
min-height: 420px;
}
.bpmn-canvas {
min-height: 360px;
.flow-main {
min-width: 0;
overflow: hidden;
border: 1px solid #dfe6f0;
border-radius: 8px;
background: #ffffff;
}
.flow-toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
padding: 14px 16px;
border-bottom: 1px solid #edf1f7;
background: #fbfcff;
}
.flow-toolbar strong,
.flow-toolbar span {
display: block;
}
.flow-toolbar strong {
color: #172033;
font-size: 14px;
}
.flow-toolbar > div > span {
margin-top: 4px;
color: #748093;
font-size: 12px;
}
.bpmn-canvas {
min-height: 360px;
overflow: hidden;
background:
linear-gradient(#f6f8fb 1px, transparent 1px),
linear-gradient(90deg, #f6f8fb 1px, transparent 1px),
#ffffff;
background-size: 18px 18px;
}
.bpmn-side {
display: flex;
min-width: 0;
flex-direction: column;
gap: 12px;
}
@@ -175,10 +446,6 @@ onBeforeUnmount(() => {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
padding: 10px 12px;
border: 1px solid #e6ebf3;
border-radius: 8px;
background: #fbfcff;
color: #596578;
font-size: 12px;
}
@@ -195,33 +462,88 @@ onBeforeUnmount(() => {
border-radius: 999px;
}
.success {
.dot.success,
.timeline-step.success .timeline-index {
background: #18a058;
}
.running {
.dot.running,
.timeline-step.running .timeline-index {
background: #1d5fd0;
}
.queued {
.dot.queued,
.timeline-step.queued .timeline-index {
background: #b7791f;
}
.failed {
.dot.failed,
.timeline-step.failed .timeline-index {
background: #d03050;
}
.pending {
.dot.pending,
.dot.skipped,
.dot.canceled,
.timeline-step.pending .timeline-index,
.timeline-step.skipped .timeline-index,
.timeline-step.canceled .timeline-index {
background: #9aa4b2;
}
.step-row {
.flow-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
padding: 12px;
border: 1px solid #e3e8f2;
border-radius: 8px;
background: #ffffff;
}
.flow-stats small,
.flow-stats strong {
display: block;
}
.flow-stats small {
color: #748093;
font-size: 12px;
}
.flow-stats strong {
margin-top: 4px;
color: #172033;
font-size: 16px;
}
.progress-track {
grid-column: 1 / -1;
height: 6px;
overflow: hidden;
border-radius: 999px;
background: #e8eef7;
}
.progress-track span {
display: block;
height: 100%;
border-radius: inherit;
background: #1d5fd0;
}
.timeline-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.timeline-step {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px;
gap: 10px;
padding: 10px 12px;
border: 1px solid #e7edf5;
border-radius: 8px;
background: #ffffff;
@@ -230,25 +552,49 @@ onBeforeUnmount(() => {
text-align: left;
}
.step-row.active {
.timeline-step.active {
border-color: #1d5fd0;
background: #f3f7ff;
box-shadow: 0 0 0 2px rgb(29 95 208 / 10%);
}
.step-row strong,
.step-row small {
.timeline-index {
display: inline-flex;
flex: 0 0 auto;
width: 24px;
height: 24px;
align-items: center;
justify-content: center;
border-radius: 999px;
color: #ffffff;
font-size: 12px;
font-style: normal;
font-weight: 700;
}
.timeline-text {
min-width: 0;
flex: 1 1 auto;
}
.timeline-step strong,
.timeline-step small {
display: block;
}
.step-row strong {
.timeline-step strong {
overflow: hidden;
color: #182033;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.step-row small {
.timeline-step small {
margin-top: 3px;
color: #748093;
font-size: 12px;
line-height: 1.4;
}
.node-detail {
@@ -258,11 +604,29 @@ onBeforeUnmount(() => {
background: #f7faff;
}
.node-detail strong {
.node-detail-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.node-detail-head strong,
.node-detail-head small {
display: block;
}
.node-detail-head strong {
color: #172033;
font-size: 14px;
}
.node-detail-head small {
margin-top: 4px;
color: #748093;
font-size: 12px;
}
.node-detail p {
margin: 6px 0 0;
color: #596578;
@@ -277,6 +641,29 @@ onBeforeUnmount(() => {
font-weight: 600;
}
.node-detail dl {
display: grid;
grid-template-columns: 46px minmax(0, 1fr);
gap: 6px 10px;
margin: 10px 0 0;
padding: 10px;
border-radius: 6px;
background: #ffffff;
font-size: 12px;
}
.node-detail dt {
color: #7b8798;
}
.node-detail dd {
margin: 0;
overflow: hidden;
color: #172033;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-detail pre {
max-height: 160px;
margin: 10px 0 0;
@@ -290,6 +677,47 @@ onBeforeUnmount(() => {
white-space: pre-wrap;
}
:global(.bpmn-step-badge) {
display: inline-flex;
width: 24px;
height: 24px;
align-items: center;
justify-content: center;
border: 2px solid #ffffff;
border-radius: 999px;
box-shadow: 0 8px 18px rgb(15 23 42 / 16%);
color: #ffffff;
cursor: pointer;
font-size: 12px;
font-weight: 700;
}
:global(.bpmn-step-badge.is-success) {
background: #18a058;
}
:global(.bpmn-step-badge.is-queued) {
background: #b7791f;
}
:global(.bpmn-step-badge.is-running) {
background: #1d5fd0;
}
:global(.bpmn-step-badge.is-failed) {
background: #d03050;
}
:global(.bpmn-step-badge.is-pending),
:global(.bpmn-step-badge.is-skipped),
:global(.bpmn-step-badge.is-canceled) {
background: #8b96a8;
}
:global(.bpmn-step-selected .djs-visual > :first-child) {
filter: drop-shadow(0 8px 12px rgb(29 95 208 / 22%));
}
:global(.bpmn-step-success .djs-visual > :first-child) {
stroke: #18a058 !important;
stroke-width: 3px !important;
@@ -322,5 +750,9 @@ onBeforeUnmount(() => {
.bpmn-viewer {
grid-template-columns: 1fr;
}
.flow-toolbar {
flex-direction: column;
}
}
</style>
+180 -10
View File
@@ -13,6 +13,7 @@ import type {
IntegrationConfigKey,
IntegrationConfigState,
IntegrationConfigStatus,
IntegrationVariableHelp,
IntegrationStatus,
JenkinsSyncSummary,
PipelineStep,
@@ -28,6 +29,13 @@ import { http } from './http'
type DataSource = 'backend'
type BackendHealthStatus = 'ok' | 'not_configured' | 'unavailable'
type BackendRunStatus = 'pending' | 'queued' | 'running' | 'success' | 'failed' | 'canceled'
type BackendVariableHelpValue =
| string
| {
description?: unknown
example?: unknown
docUrl?: unknown
}
interface BackendHealth {
status: BackendHealthStatus
@@ -99,6 +107,31 @@ interface BackendProcessDefinition {
nodeIds: string[]
}
interface BackendIntegrationConfigItem {
key: IntegrationConfigKey
name: string
status: IntegrationConfigState
required?: string[]
configured?: string[]
missing?: string[]
optional?: 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',
@@ -283,6 +316,14 @@ export const devopsApi = {
return fetchIntegrationConfigStatus()
},
async getReleaseProcessXml(): Promise<string> {
const { data } = await http.get<BackendProcessDefinition>(
'/process-definitions/release',
)
lastDataSource = 'backend'
return data.xml
},
async createAgentInvocation(
payload: CreateAgentInvocationPayload,
): Promise<AgentInvocation> {
@@ -300,13 +341,6 @@ export const devopsApi = {
return toSystemSettings(health, integrationConfig)
},
async getReleaseProcessXml(): Promise<string> {
const { data } = await http.get<BackendProcessDefinition>(
'/process-definitions/release',
)
lastDataSource = 'backend'
return data.xml
},
}
async function fetchHealth(): Promise<BackendHealth> {
@@ -325,7 +359,9 @@ async function fetchRuns(): Promise<BackendDeployRun[]> {
}
async function fetchIntegrationConfigStatus(): Promise<IntegrationConfigStatus> {
const { data } = await http.get<IntegrationConfigStatus>('/settings/integration-config')
const { data } = await http.get<BackendIntegrationConfigStatus>(
'/settings/integration-config',
)
return sanitizeIntegrationConfigStatus(data)
}
@@ -358,7 +394,7 @@ function toIntegrationStatus(
}
function sanitizeIntegrationConfigStatus(
payload: IntegrationConfigStatus,
payload: BackendIntegrationConfigStatus,
): IntegrationConfigStatus {
const integrations = Array.isArray(payload?.integrations) ? payload.integrations : []
@@ -373,6 +409,8 @@ function sanitizeIntegrationConfigStatus(
const required = sanitizeVariableNames(item.required)
const configured = sanitizeVariableNames(item.configured)
const missing = sanitizeVariableNames(item.missing)
const optional = sanitizeVariableNames(item.optional)
const variableHelp = sanitizeVariableHelp(item)
return {
key: item.key,
@@ -381,8 +419,9 @@ function sanitizeIntegrationConfigStatus(
required,
configured,
missing,
optional: sanitizeVariableNames(item.optional),
optional,
note: sanitizeIntegrationNote(item.note),
...(Object.keys(variableHelp).length > 0 ? { variableHelp } : {}),
}
}),
}
@@ -423,6 +462,137 @@ function sanitizeIntegrationNote(note?: string): string | undefined {
.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
}
+7
View File
@@ -43,6 +43,12 @@ export type IntegrationConfigKey =
export type IntegrationConfigState = 'configured' | 'partial' | 'missing'
export interface IntegrationVariableHelp {
description: string
example?: string
docUrl?: string
}
/**
* 后端安全配置检查结果;数组字段只承载变量名,不承载任何密钥值或 URL 原文。
*/
@@ -55,6 +61,7 @@ export interface IntegrationConfigItem {
missing: string[]
optional?: string[]
note?: string
variableHelp?: Record<string, IntegrationVariableHelp>
}
/**
+141 -67
View File
@@ -1,11 +1,23 @@
<script setup lang="ts">
import { Refresh } from '@element-plus/icons-vue'
import AuditJsonCell from '../components/AuditJsonCell.vue'
import StatusPill from '../components/StatusPill.vue'
import { usePlatformStore } from '../stores/platform'
import type { AuditLogEntry } from '../types/devops'
import type { IntegrationConfigState } from '../types/devops'
import type {
AuditLogEntry,
IntegrationConfigItem,
IntegrationConfigState,
} from '../types/devops'
const platform = usePlatformStore()
type VariableTagType = 'success' | 'warning' | 'danger' | 'info'
type VariableGroup = {
key: string
label: string
names: string[]
emptyText: string
tagType?: VariableTagType
}
const configStatusMeta: Record<
IntegrationConfigState,
@@ -56,14 +68,66 @@ function auditResource(record: AuditLogEntry): string {
: record.resourceType
}
function auditDigest(record: AuditLogEntry): string {
const payload = record.after ?? record.parameterDigest ?? record.before
function auditPayload(record: AuditLogEntry): unknown {
return record.after ?? record.parameterDigest ?? record.before ?? null
}
if (!payload) {
return '-'
function variableGroups(item: IntegrationConfigItem): VariableGroup[] {
return [
{
key: 'required',
label: '必需变量',
names: item.required,
emptyText: '暂无',
},
{
key: 'configured',
label: '已识别',
names: item.configured,
emptyText: '暂无',
tagType: 'success',
},
{
key: 'missing',
label: '缺失',
names: item.missing,
emptyText: '无',
tagType: 'danger',
},
{
key: 'optional',
label: '可选变量',
names: item.optional ?? [],
emptyText: '暂无',
tagType: 'info',
},
]
}
function variableHelp(item: IntegrationConfigItem, name: string) {
return item.variableHelp?.[name]
}
function variableDescription(
item: IntegrationConfigItem,
name: string,
group: VariableGroup,
): string {
const help = variableHelp(item, name)
if (help?.description) {
return help.description
}
return JSON.stringify(payload)
if (item.note) {
return item.note
}
return `后端暂未返回 ${item.name} ${group.label} ${name} 的变量说明;请在 /settings/integration-config 响应中补充 variableHelp。`
}
function variableSourceLabel(item: IntegrationConfigItem, name: string): string {
return variableHelp(item, name)?.description ? '后端返回' : '等待后端说明'
}
</script>
@@ -72,7 +136,7 @@ function auditDigest(record: AuditLogEntry): string {
<div class="page-header">
<div>
<h1>系统配置</h1>
<p>展示后端集成配置状态API 契约来源BPMN 映射Agent 边界和通知维护状态</p>
<p>展示后端集成配置状态API 契约来源流程映射Agent 边界和通知维护状态</p>
</div>
</div>
@@ -102,57 +166,45 @@ function auditDigest(record: AuditLogEntry): string {
</div>
<div class="variable-grid">
<div class="variable-group">
<strong>必需变量</strong>
<div class="variable-tags">
<ElTag v-for="name in item.required" :key="name" class="mono" effect="plain">
{{ name }}
</ElTag>
</div>
</div>
<div class="variable-group">
<strong>已识别</strong>
<div class="variable-tags">
<ElTag
v-for="name in item.configured"
:key="name"
class="mono"
type="success"
effect="plain"
<div
v-for="group in variableGroups(item)"
:key="group.key"
class="variable-group"
>
{{ name }}
</ElTag>
<span v-if="item.configured.length === 0" class="empty-value">暂无</span>
</div>
</div>
<div class="variable-group">
<strong>缺失</strong>
<strong>{{ group.label }}</strong>
<div class="variable-tags">
<ElTag
v-for="name in item.missing"
:key="name"
class="mono"
type="danger"
effect="plain"
<ElTooltip
v-for="name in group.names"
:key="`${group.key}-${name}`"
effect="light"
placement="top"
:show-after="160"
>
<template #content>
<div class="variable-tooltip">
<strong class="mono">{{ name }}</strong>
<p>{{ variableDescription(item, name, group) }}</p>
<small>{{ group.label }} · {{ variableSourceLabel(item, name) }}</small>
<small v-if="variableHelp(item, name)?.example">
示例{{ variableHelp(item, name)?.example }}
</small>
<a
v-if="variableHelp(item, name)?.docUrl"
:href="variableHelp(item, name)?.docUrl"
target="_blank"
rel="noreferrer"
>
查看官方文档
</a>
</div>
</template>
<ElTag class="mono variable-tag" :type="group.tagType" effect="plain">
{{ name }}
</ElTag>
<span v-if="item.missing.length === 0" class="empty-value"></span>
</div>
</div>
<div class="variable-group">
<strong>可选变量</strong>
<div class="variable-tags">
<ElTag
v-for="name in item.optional ?? []"
:key="name"
class="mono"
type="info"
effect="plain"
>
{{ name }}
</ElTag>
<span v-if="!item.optional?.length" class="empty-value">暂无</span>
</ElTooltip>
<span v-if="group.names.length === 0" class="empty-value">
{{ group.emptyText }}
</span>
</div>
</div>
</div>
@@ -205,9 +257,9 @@ function auditDigest(record: AuditLogEntry): string {
{{ auditActor(row) }}
</template>
</ElTableColumn>
<ElTableColumn label="脱敏摘要" min-width="280">
<ElTableColumn label="脱敏摘要" min-width="420">
<template #default="{ row }">
<span class="audit-digest">{{ auditDigest(row) }}</span>
<AuditJsonCell :value="auditPayload(row)" />
</template>
</ElTableColumn>
</ElTable>
@@ -334,23 +386,45 @@ function auditDigest(record: AuditLogEntry): string {
min-height: 24px;
}
.variable-tag {
cursor: help;
}
.variable-tooltip {
max-width: 320px;
}
.variable-tooltip strong,
.variable-tooltip small,
.variable-tooltip a {
display: block;
}
.variable-tooltip p {
margin: 6px 0;
color: #4b5565;
font-size: 12px;
line-height: 1.6;
}
.variable-tooltip small {
margin-top: 4px;
color: #7b8798;
font-size: 12px;
}
.variable-tooltip a {
margin-top: 6px;
color: #1d5fd0;
font-size: 12px;
}
.empty-value {
color: #9aa4b2;
font-size: 12px;
line-height: 24px;
}
.audit-digest {
display: block;
max-width: 100%;
overflow: hidden;
color: #4b5565;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 1080px) {
.variable-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));