diff --git a/AGENTS.md b/AGENTS.md index 6b6519d..03c4360 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,5 +29,5 @@ - BPMN 组件只展示流程图、节点状态和节点详情,不推进状态机。 - `step.bpmnNodeId` 必须与 BPMN XML 节点 id 一致。 -- Agent 只能作为全局右侧抽屉工具存在,不作为独立侧边栏路由;能力只允许 DevOps 场景:发布风险摘要、失败诊断、Runbook 问答、发布说明草稿。 +- Agent 只能作为全局右侧抽屉工具存在,不作为独立侧边栏路由;能力只允许 DevOps 场景:发布风险摘要、失败诊断、Runbook 问答、发布说明草稿和事故复盘草稿。 - 禁止在前端新增泛聊天入口。 diff --git a/README.md b/README.md index 2552cb8..c9d108e 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,14 @@ Vue 3 + Vite + TypeScript 实现的 DevOps 运维控制台第一阶段,用于 - 登录、总览、项目配置、成员权限、发布中心、运行记录、系统配置,以及全局右侧 Agent 抽屉。 - 所有项目、发布、refs、流程和集成状态均调用 `devops-platform-api`,前端不再提供本地 mock 数据降级。 -- 成员权限页调用后端 `/members` 和 `/messages`,维护普通成员账号、项目权限、密码重置和平台消息。 -- 发布中心包含项目、环境、ref/tag、备注、生产确认提示、触发、取消、重试和 Jenkins 状态同步入口。 -- 运行记录展示 Jenkins queue/build、Build URL、开始/结束时间、执行提示、失败摘要和日志摘要。 +- 总览页提供值班焦点、优先 Run 队列、项目发布健康、项目环境摘要和跨页快捷入口,用于快速定位失败发布、项目阻断、配置缺口和通知 outbox 风险。 +- 成员权限页调用后端 `/members` 和 `/messages`,展示账号/构建权限/安全状态概览,支持角色、启停、安全状态和关键词筛选,并维护普通成员账号、项目权限、密码重置和平台消息。 +- 项目配置提供项目可发布性概览、项目/仓库搜索、发布态/权限筛选、Gitea/Jenkins/Webhook 诊断展开和发布/运行记录上下文跳转。 +- 发布中心包含项目、环境、后端返回的 ref/tag、备注、发布前检查、生产发布回滚/窗口/通知确认、触发、取消、重试和 Jenkins 状态同步入口。 +- 运行记录展示状态概览、项目/环境/状态/关键词筛选、Jenkins queue/build、Build URL、开始/结束时间、执行提示、失败摘要和日志摘要,并提供超级管理员导入 Jenkins 直接构建记录的入口。 +- 系统配置页展示集成配置、Agent 密钥状态、通知 outbox 和审计日志概览,支持通知投递与审计记录筛选,用于定位配置缺失、通知失败和敏感操作。 - BPMN 状态区使用后端流程 XML 和 run steps 作为状态来源,左侧展示节点输出或 Jenkins 构建日志,右侧粘性展示进度统计、节点时间线和详情面板。 -- Agent 作为全局工具从顶部打开,只包含发布风险摘要、失败诊断、Runbook 问答、发布说明草稿,并通过后端 Agent API 创建调用记录。 +- Agent 作为全局工具从顶部打开,只包含发布风险摘要、失败诊断、Runbook 问答、发布说明草稿和事故复盘草稿,并通过后端 Agent API 创建调用记录。 ## 技术栈 @@ -65,5 +68,6 @@ pnpm build - 失败诊断 - Runbook 问答 - 发布说明草稿 +- 事故复盘草稿 模型调用、脱敏、审计、白名单知识库和密钥管理由后端代理实现。 diff --git a/RTK.md b/RTK.md index 1a4930c..5189370 100644 --- a/RTK.md +++ b/RTK.md @@ -16,13 +16,13 @@ ## 页面结构 - `/login`:真实平台账号登录入口,支持 token 恢复和首次改密。 -- `/overview`:总览,展示服务状态、最近发布和项目环境。 -- `/projects`:项目配置,展示 Gitea 仓库、Jenkins Job、通知通道和环境策略。 -- `/members`:成员权限,仅超级管理员可见,维护普通成员、项目权限和密码重置消息。 -- `/releases`:发布中心,提供发布参数、二次确认、触发、取消、重试和 BPMN 流程状态。 -- `/runs`:运行记录,展示 run 列表和流程轨迹。 +- `/overview`:总览,展示值班焦点、优先 Run 队列、项目发布健康、服务状态和项目环境快捷入口。 +- `/projects`:项目配置,展示项目可发布性概览、发布态/权限筛选、Gitea 仓库、Jenkins Job、通知通道、环境策略和上下文跳转。 +- `/members`:成员权限,仅超级管理员可见,展示账号/构建权限/安全状态概览,支持成员筛选,并维护普通成员、项目权限和密码重置消息。 +- `/releases`:发布中心,提供发布参数、后端 ref/tag 选择、发布前检查、生产发布确认、触发、取消、重试和 BPMN 流程状态。 +- `/runs`:运行记录,展示 run 状态概览、筛选列表、执行详情、流程轨迹,并支持超级管理员导入 Jenkins 直接构建记录。 - 顶部 Agent 按钮:打开全局右侧抽屉,只保留 DevOps 场景能力;`/agent` 仅重定向到总览,不作为独立侧边栏入口。 -- `/settings`:系统配置,展示 API、BPMN、Agent、通知边界,并维护后端 Agent 模型配置。 +- `/settings`:系统配置,展示集成配置、Agent、通知 outbox 和审计日志概览,支持通知/审计筛选,并维护后端 Agent 模型配置。 ## 数据约定 @@ -36,6 +36,8 @@ - `devopsApi` 所有项目、发布、refs、流程、成员、消息和 Agent 调用均走后端接口。 - 发布中心触发按钮接入后端发布单创建接口。 - 运行记录接入后端 run/step 查询接口。 +- Jenkins 直接构建不由前端直连 Jenkins;运行记录页调用后端 `/deploy-runs/import-jenkins` 扫描已纳管 job 并导入 run。 - BPMN XML 由后端流程定义接口返回。 - Agent 面板调用后端 Agent proxy,前端只传 DevOps 场景参数。 - Agent 配置页调用后端 `/agent/config`,前端不持久化 key、baseURL 或 model。 +- Agent 能力限定为发布风险摘要、失败诊断、Runbook 问答、发布说明草稿和事故复盘草稿。 diff --git a/src/components/GlobalAgentDrawer.vue b/src/components/GlobalAgentDrawer.vue index 5c37783..e19793d 100644 --- a/src/components/GlobalAgentDrawer.vue +++ b/src/components/GlobalAgentDrawer.vue @@ -55,11 +55,27 @@ const failedRunOptions = computed(() => { return platform.failedRuns.filter((run) => !selectedProject.value || run.projectKey === selectedProject.value) }) +const requiresRunSelection = computed(() => + selectedMode.value === 'diagnosis' || selectedMode.value === 'incidentReview', +) + +const sendDisabled = computed(() => { + if (sending.value || !prompt.value.trim()) { + return true + } + + return requiresRunSelection.value && !selectedRun.value +}) + const purpose = computed(() => { if (selectedMode.value === 'diagnosis') { return 'failure-diagnosis' } + if (selectedMode.value === 'incidentReview') { + return 'incident-review' + } + if (selectedMode.value === 'runbook') { return 'runbook-qa' } @@ -82,18 +98,26 @@ watch( ) watch(failedRunOptions, (runs) => { - if (selectedMode.value === 'diagnosis') { + if (requiresRunSelection.value) { selectedRun.value = runs[0]?.id ?? '' } }) watch(selectedMode, (mode) => { - if (mode === 'diagnosis') { + if (mode === 'diagnosis' || mode === 'incidentReview') { selectedRun.value = failedRunOptions.value[0]?.id ?? '' + } + + if (mode === 'diagnosis') { prompt.value = '请根据最近失败 run 的 Jenkins 日志摘要和流程节点给出排查步骤。' return } + if (mode === 'incidentReview') { + prompt.value = '请基于这个失败 run 生成事故复盘草稿,包含时间线、影响、根因假设和预防项。' + return + } + if (mode === 'runbook') { prompt.value = '生产发布失败后,应该先检查哪些平台信息?' return @@ -147,7 +171,7 @@ async function sendPrompt() { const invocation = await devopsApi.createAgentInvocation({ type: purpose.value, projectKey: selectedProject.value || undefined, - runId: selectedMode.value === 'diagnosis' ? selectedRun.value || undefined : undefined, + runId: requiresRunSelection.value ? selectedRun.value || undefined : undefined, promptSummary: text, }) @@ -240,6 +264,7 @@ function formatTime(value: string): string { { label: '诊断', value: 'diagnosis' }, { label: 'Runbook', value: 'runbook' }, { label: '发布说明', value: 'releaseNotes' }, + { label: '复盘', value: 'incidentReview' }, ]" /> @@ -253,7 +278,7 @@ function formatTime(value: string): string { +
+ 当前项目没有可用于分析的失败 run。 +
@@ -291,7 +319,13 @@ function formatTime(value: string): string { resize="none" @keydown="handleComposerKeydown" /> - + 发送 @@ -351,6 +385,12 @@ function formatTime(value: string): string { width: 100%; } +.agent-context-hint { + color: #8a5a00; + font-size: 12px; + line-height: 1.5; +} + .message-list { display: flex; flex: 1; diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index 91cdc85..be157dc 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -116,6 +116,27 @@ async function logout() {
+ @@ -189,6 +210,51 @@ async function logout() { background: #ffffff; } +.mobile-nav { + display: none; + gap: 8px; + max-width: 100%; + padding: 10px 12px; + overflow-x: auto; + border-bottom: 1px solid #e0e6ef; + background: #ffffff; + scrollbar-width: none; +} + +.mobile-nav::-webkit-scrollbar { + display: none; +} + +.mobile-nav-item { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + gap: 6px; + min-width: 88px; + height: 38px; + padding: 0 12px; + border: 1px solid #d8e2ef; + border-radius: 8px; + background: #f8fafc; + color: #475467; + font-size: 13px; + white-space: nowrap; + cursor: pointer; +} + +.mobile-nav-item.active { + border-color: #7aa7f7; + background: #edf4ff; + color: #1d5fd0; + font-weight: 600; +} + +.mobile-nav-item .el-icon { + flex: 0 0 auto; + font-size: 15px; +} + .env-strip { display: flex; align-items: center; @@ -207,6 +273,10 @@ async function logout() { display: none; } + .mobile-nav { + display: flex; + } + .topbar { flex-wrap: wrap; height: auto; @@ -222,4 +292,21 @@ async function logout() { width: 100%; } } + +@media (max-width: 520px) { + .topbar > .toolbar { + width: 100%; + justify-content: space-between; + } + + .topbar .env-strip { + order: 2; + width: 100%; + margin-top: 8px; + } + + .mobile-nav-item { + min-width: 82px; + } +} diff --git a/src/services/devopsApi.ts b/src/services/devopsApi.ts index 265c2d2..5d6a8d1 100644 --- a/src/services/devopsApi.ts +++ b/src/services/devopsApi.ts @@ -21,6 +21,7 @@ import type { IntegrationConfigStatus, IntegrationVariableHelp, IntegrationStatus, + JenkinsImportSummary, JenkinsBuildLog, JenkinsSyncSummary, LoginPayload, @@ -107,7 +108,7 @@ interface BackendDeployRun { environment?: EnvironmentName ref?: string status?: BackendRunStatus - trigger?: 'manual' | 'gitea_webhook' | 'retry' + trigger?: 'manual' | 'gitea_webhook' | 'retry' | 'jenkins_external' operator?: string jenkinsQueueId?: string | number jenkinsBuildNumber?: number @@ -129,6 +130,26 @@ interface BackendJenkinsSyncSummary { }> } +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 @@ -205,6 +226,13 @@ const agentCards: AgentCard[] = [ inputScope: ['commit 列表', 'PR 信息', 'tag'], outputPreview: ['变更摘要', '验证范围', '回滚提示'], }, + { + mode: 'incidentReview', + title: '事故复盘草稿', + description: '基于失败 run、流程耗时、Jenkins 摘要和 Runbook 生成复盘草稿。', + inputScope: ['失败 run', 'BPMN 节点', '脱敏日志片段', 'Runbook'], + outputPreview: ['时间线', '根因假设', '修复和预防项'], + }, ] const settings: SystemSetting[] = [ @@ -229,7 +257,7 @@ const settings: SystemSetting[] = [ name: 'Agent 边界', status: 'warning', owner: '后端 Agent proxy', - contract: '只支持发布风险、失败诊断、Runbook、发布说明四类用途。', + contract: '只支持发布风险、失败诊断、Runbook、发布说明和事故复盘五类用途。', boundary: '无泛聊天入口;模型 key/baseURL/model 仅后端保存。', }, { @@ -439,6 +467,14 @@ export const devopsApi = { 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 }, @@ -1055,6 +1091,25 @@ function toJenkinsSyncSummary( } } +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'), @@ -1216,6 +1271,10 @@ function triggerLabel(trigger: BackendDeployRun['trigger']): string { return '失败后重试' } + if (trigger === 'jenkins_external') { + return 'Jenkins 直构导入' + } + return '人工触发' } diff --git a/src/stores/platform.ts b/src/stores/platform.ts index af9bd68..4fe8576 100644 --- a/src/stores/platform.ts +++ b/src/stores/platform.ts @@ -16,6 +16,7 @@ import type { ProjectJenkinsDiagnosticsSummary, ProjectPermissionLevel, ProjectRefs, + JenkinsImportSummary, JenkinsSyncSummary, NotificationOutboxMessage, ReleaseRun, @@ -140,6 +141,15 @@ export const usePlatformStore = defineStore('platform', () => { return result } + async function importJenkinsBuilds(): Promise { + const result = await devopsApi.importJenkinsBuilds(projects.value) + result.importedRuns.forEach(upsertRun) + result.syncedRuns.forEach(upsertRun) + applyProjectRunSummaries() + dataSource.value = devopsApi.getLastDataSource() + return result + } + /** * 发布单状态被其他请求推进时,只刷新 run 列表并同步项目环境摘要。 */ @@ -360,6 +370,7 @@ export const usePlatformStore = defineStore('platform', () => { retryDeployRun, syncJenkinsRun, syncJenkinsRuns, + importJenkinsBuilds, refreshRuns, loadProjectRefs, loadProjectJenkinsDiagnostics, diff --git a/src/types/devops.ts b/src/types/devops.ts index 5e04b7c..197b9b8 100644 --- a/src/types/devops.ts +++ b/src/types/devops.ts @@ -22,7 +22,12 @@ export type StepStatus = | 'skipped' | 'canceled' -export type AgentMode = 'risk' | 'diagnosis' | 'runbook' | 'releaseNotes' +export type AgentMode = + | 'risk' + | 'diagnosis' + | 'runbook' + | 'releaseNotes' + | 'incidentReview' export type AgentPurpose = | 'release-risk' @@ -402,6 +407,30 @@ export interface JenkinsSyncSummary { failedRuns: JenkinsSyncError[] } +export interface JenkinsImportSkippedBuild { + projectKey: string + environment: EnvironmentName + buildNumber: number + reason: 'already_imported' | 'linked_platform_run' | 'parameter_mismatch' +} + +export interface JenkinsImportFailedBuild { + projectKey: string + environment: EnvironmentName + buildNumber?: number + message: string +} + +export interface JenkinsImportSummary { + checkedAt: string + totalJobs: number + totalBuilds: number + importedRuns: ReleaseRun[] + syncedRuns: ReleaseRun[] + skippedBuilds: JenkinsImportSkippedBuild[] + failedBuilds: JenkinsImportFailedBuild[] +} + export interface AuditLogEntry { id: string action: string diff --git a/src/views/MembersView.vue b/src/views/MembersView.vue index 460e658..ae4af18 100644 --- a/src/views/MembersView.vue +++ b/src/views/MembersView.vue @@ -9,6 +9,7 @@ import { Lock, Plus, Refresh, + Search, User, } from '@element-plus/icons-vue' import { computed, onMounted, reactive, ref, watch } from 'vue' @@ -17,8 +18,11 @@ import { ElMessage, ElMessageBox } from 'element-plus' import { devopsApi } from '../services/devopsApi' import { usePlatformStore } from '../stores/platform' import type { + AuthRole, + AuthStatus, CreateMemberPayload, MemberSummary, + PlatformMessageState, PlatformMessageSummary, ProjectConfig, ProjectPermissionLevel, @@ -30,6 +34,7 @@ type PermissionProjectGroup = { label: string children: ProjectConfig[] } +type MemberSecurityFilter = 'normal' | 'mustChangePassword' | 'passwordResetRequest' const route = useRoute() const platform = usePlatformStore() @@ -39,6 +44,10 @@ const editingMember = ref(null) const members = ref([]) const messages = ref([]) const accountKeyword = ref('') +const roleFilter = ref('') +const statusFilter = ref('') +const securityFilter = ref('') +const messageStatusFilter = ref('') const createForm = reactive({ account: '', displayName: '', @@ -51,19 +60,54 @@ const permissionOptions: Array<{ label: string; value: ProjectPermissionLevel }> { label: '只读', value: 'read' }, { label: '构建', value: 'build' }, ] +const roleOptions: Array<{ label: string; value: AuthRole }> = [ + { label: '超级管理员', value: 'super_admin' }, + { label: '普通成员', value: 'member' }, +] +const statusOptions: Array<{ label: string; value: AuthStatus }> = [ + { label: '启用', value: 'active' }, + { label: '禁用', value: 'disabled' }, +] +const securityOptions: Array<{ label: string; value: MemberSecurityFilter }> = [ + { label: '正常', value: 'normal' }, + { label: '待改密', value: 'mustChangePassword' }, + { label: '请求重置', value: 'passwordResetRequest' }, +] +const messageStatusOptions: Array<{ label: string; value: PlatformMessageState }> = [ + { label: '待处理', value: 'pending' }, + { label: '已处理', value: 'handled' }, +] const filteredMembers = computed(() => { const keyword = accountKeyword.value.trim().toLowerCase() - if (!keyword) { - return members.value - } - return members.value.filter((member) => { - return ( - member.account.toLowerCase().includes(keyword) || - member.displayName.toLowerCase().includes(keyword) - ) + if (roleFilter.value && member.role !== roleFilter.value) { + return false + } + + if (statusFilter.value && member.status !== statusFilter.value) { + return false + } + + if (securityFilter.value && !memberMatchesSecurityFilter(member, securityFilter.value)) { + return false + } + + if (!keyword) { + return true + } + + return [ + member.account, + member.displayName, + member.roleName, + member.statusName, + ...member.projectPermissions.map((permission) => permission.projectName), + ...member.projectPermissions.map((permission) => permission.levelName), + ] + .filter((value): value is string => typeof value === 'string' && value.length > 0) + .some((value) => value.toLowerCase().includes(keyword)) }) }) @@ -71,6 +115,56 @@ const pendingMessages = computed(() => messages.value.filter((message) => message.status === 'pending'), ) +const filteredMessages = computed(() => { + if (!messageStatusFilter.value) { + return messages.value + } + + return messages.value.filter((message) => message.status === messageStatusFilter.value) +}) + +const memberOverview = computed(() => { + const activeCount = members.value.filter((member) => member.status === 'active').length + const buildAccountCount = members.value.filter(memberHasBuildPermission).length + const mustChangePasswordCount = members.value.filter((member) => member.mustChangePassword).length + const resetRequestCount = members.value.filter((member) => member.hasPendingPasswordResetRequest).length + + return [ + { key: 'total', label: '平台账号', value: members.value.length, tone: 'neutral' }, + { key: 'active', label: '启用账号', value: activeCount, tone: 'success' }, + { key: 'build', label: '构建账号', value: buildAccountCount, tone: 'active' }, + { + key: 'must-change', + label: '待改密', + value: mustChangePasswordCount, + tone: mustChangePasswordCount > 0 ? 'warning' : 'neutral', + }, + { + key: 'reset', + label: '请求重置', + value: resetRequestCount, + tone: resetRequestCount > 0 ? 'danger' : 'neutral', + }, + { + key: 'messages', + label: '待处理消息', + value: pendingMessages.value.length, + tone: pendingMessages.value.length > 0 ? 'warning' : 'neutral', + }, + ] +}) + +const hasActiveMemberFilters = computed(() => + Boolean( + accountKeyword.value.trim() || + roleFilter.value || + statusFilter.value || + securityFilter.value, + ), +) + +const hasActiveMessageFilters = computed(() => Boolean(messageStatusFilter.value)) + const permissionProjectTree = computed(() => { const groupByOwner = new Map() @@ -280,6 +374,60 @@ function canMutate(member: MemberSummary) { return member.role !== 'super_admin' } +function memberMatchesSecurityFilter(member: MemberSummary, filter: MemberSecurityFilter) { + if (filter === 'mustChangePassword') { + return member.mustChangePassword + } + + if (filter === 'passwordResetRequest') { + return member.hasPendingPasswordResetRequest + } + + return !member.mustChangePassword && !member.hasPendingPasswordResetRequest +} + +function memberHasBuildPermission(member: MemberSummary) { + return member.role === 'super_admin' || + member.projectPermissions.some((permission) => permission.level === 'build') +} + +function memberBuildPermissionCount(member: MemberSummary) { + if (member.role === 'super_admin') { + return platform.projects.length + } + + return member.projectPermissions.filter((permission) => permission.level === 'build').length +} + +function memberReadPermissionCount(member: MemberSummary) { + return Math.max(member.projectPermissions.length - memberBuildPermissionCount(member), 0) +} + +function memberPermissionSummary(member: MemberSummary) { + if (member.role === 'super_admin') { + return '超级管理员拥有全部项目构建权限' + } + + const buildCount = memberBuildPermissionCount(member) + + if (buildCount === 0) { + return `${member.projectPermissions.length} 个项目只读` + } + + return `${buildCount} 个构建 / ${memberReadPermissionCount(member)} 个只读` +} + +function resetMemberFilters() { + accountKeyword.value = '' + roleFilter.value = '' + statusFilter.value = '' + securityFilter.value = '' +} + +function resetMessageFilters() { + messageStatusFilter.value = '' +} + function setAllProjectBuildPermissions() { setAllProjectPermissions(currentPermissionMap(), 'build') } @@ -377,12 +525,6 @@ function formatDateTime(value?: string) {

维护平台账号、项目只读/构建权限和密码重置消息。

- 刷新 新增成员 @@ -390,15 +532,40 @@ function formatDateTime(value?: string) {
+
+
+ {{ item.label }} + {{ item.value }} +
+
+

密码消息

- {{ pendingMessages.length }} 条待处理 + {{ filteredMessages.length }} / {{ messages.length }} 条,{{ pendingMessages.length }} 条待处理
-
+
+ + + + + 重置筛选 + +
+
已处理
- +
@@ -432,7 +603,43 @@ function formatDateTime(value?: string) { {{ filteredMembers.length }} / {{ members.length }}
- +
+ + + + + + + + + + + + 重置筛选 + +
+ diff --git a/src/views/ProjectsView.vue b/src/views/ProjectsView.vue index 53bfe00..e539141 100644 --- a/src/views/ProjectsView.vue +++ b/src/views/ProjectsView.vue @@ -2,13 +2,82 @@ /** * 项目配置页展示项目仓库、发布环境、Gitea 仓库诊断和真实 Jenkins job 诊断状态。 */ -import { onMounted, ref } from 'vue' +import { Promotion, Refresh, Search } from '@element-plus/icons-vue' +import { computed, onMounted, ref } from 'vue' import StatusPill from '../components/StatusPill.vue' import { usePlatformStore } from '../stores/platform' -import type { EnvironmentStatus, ProjectConfig } from '../types/devops' +import type { EnvironmentStatus, HealthState, ProjectConfig, ProjectPermissionLevel } from '../types/devops' const platform = usePlatformStore() const diagnosticsLoading = ref(false) +const searchKeyword = ref('') +const readinessFilter = ref('') +const permissionFilter = ref('') + +type ProjectReadinessState = 'ready' | 'attention' | 'blocked' | 'readonly' + +const readinessOptions: Array<{ label: string; value: ProjectReadinessState }> = [ + { label: '可发布', value: 'ready' }, + { label: '需关注', value: 'attention' }, + { label: '有阻断', value: 'blocked' }, + { label: '只读', value: 'readonly' }, +] + +const permissionOptions: Array<{ label: string; value: ProjectPermissionLevel }> = [ + { label: '构建权限', value: 'build' }, + { label: '只读权限', value: 'read' }, +] + +const filteredProjects = computed(() => { + const keyword = searchKeyword.value.trim().toLowerCase() + + return platform.projects.filter((project) => { + if (readinessFilter.value && projectReadinessState(project) !== readinessFilter.value) { + return false + } + + if (permissionFilter.value && platform.projectPermissionLevel(project.key) !== permissionFilter.value) { + return false + } + + if (!keyword) { + return true + } + + return [ + project.name, + project.key, + project.serviceType, + project.repository, + project.defaultBranch, + project.tagPolicy, + project.permissionLevelName, + ] + .filter((value): value is string => typeof value === 'string' && value.length > 0) + .some((value) => value.toLowerCase().includes(keyword)) + }) +}) + +const projectOverview = computed(() => { + const ready = platform.projects.filter((project) => projectReadinessState(project) === 'ready').length + const attention = platform.projects.filter((project) => projectReadinessState(project) === 'attention').length + const blocked = platform.projects.filter((project) => projectReadinessState(project) === 'blocked').length + const readOnly = platform.projects.filter((project) => projectReadinessState(project) === 'readonly').length + + return [ + { key: 'total', label: '纳管项目', value: platform.projects.length, tone: 'neutral' }, + { key: 'ready', label: '可发布', value: ready, tone: 'success' }, + { key: 'attention', label: '需关注', value: attention, tone: attention > 0 ? 'warning' : 'neutral' }, + { key: 'blocked', label: '有阻断', value: blocked, tone: blocked > 0 ? 'danger' : 'neutral' }, + { key: 'readonly', label: '只读项目', value: readOnly, tone: readOnly > 0 ? 'muted' : 'neutral' }, + ] +}) + +const hasActiveFilters = computed(() => Boolean( + searchKeyword.value.trim() || + readinessFilter.value || + permissionFilter.value, +)) onMounted(() => { void refreshProjectDiagnostics() @@ -80,23 +149,136 @@ function webhookHookText(hook: ProjectConfig['giteaWebhookHooks'][number]): stri return `#${hook.id} ${hook.type} · ${active} · ${events}${branch}${matched}` } + +function projectReadinessState(project: ProjectConfig): ProjectReadinessState { + if (projectHasBlockingIssue(project)) { + return 'blocked' + } + + if (projectHasWarningIssue(project)) { + return 'attention' + } + + if (!platform.canBuildProject(project.key)) { + return 'readonly' + } + + return 'ready' +} + +function projectReadinessLabel(project: ProjectConfig): string { + const state = projectReadinessState(project) + + if (state === 'ready') { + return '可发布' + } + + if (state === 'blocked') { + return '有阻断' + } + + if (state === 'readonly') { + return '只读' + } + + return '需关注' +} + +function projectReadinessTagType( + project: ProjectConfig, +): 'success' | 'warning' | 'danger' | 'info' { + const state = projectReadinessState(project) + + if (state === 'ready') { + return 'success' + } + + if (state === 'blocked') { + return 'danger' + } + + return state === 'attention' ? 'warning' : 'info' +} + +function projectReadinessSummary(project: ProjectConfig): string { + const issues = projectIssueSummaries(project) + + if (issues.length > 0) { + return issues[0] + } + + if (!platform.canBuildProject(project.key)) { + return '当前账号只有只读权限' + } + + return 'Gitea、Jenkins、Webhook 和通知链路均未发现异常' +} + +function projectIssueSummaries(project: ProjectConfig): string[] { + const issues: string[] = [] + + appendHealthIssue(issues, 'Gitea 仓库', project.giteaStatus, project.giteaMessage) + appendHealthIssue(issues, 'Webhook / Relay', project.giteaWebhookStatus, project.giteaWebhookMessage) + + for (const env of project.environments) { + appendHealthIssue(issues, `${env.label} Jenkins`, env.jenkinsStatus, env.jenkinsMessage) + } + + appendHealthIssue(issues, '通知链路', project.integrations.wecom) + + return issues +} + +function appendHealthIssue( + issues: string[], + label: string, + state: HealthState, + message?: string, +) { + if (state === 'healthy') { + return + } + + const stateText = state === 'warning' ? '待配置' : state === 'offline' ? '离线' : '异常' + issues.push(`${label}${message ? `:${message}` : `:${stateText}`}`) +} + +function projectHasBlockingIssue(project: ProjectConfig): boolean { + return projectHealthStates(project).some((state) => state === 'error' || state === 'offline') +} + +function projectHasWarningIssue(project: ProjectConfig): boolean { + return projectHealthStates(project).some((state) => state === 'warning') +} + +function projectHealthStates(project: ProjectConfig): HealthState[] { + return [ + project.giteaStatus, + project.giteaWebhookStatus, + project.integrations.wecom, + ...project.environments.map((env) => env.jenkinsStatus), + ] +} + +function resetProjectFilters() { + searchKeyword.value = '' + readinessFilter.value = '' + permissionFilter.value = '' +} diff --git a/src/views/SettingsView.vue b/src/views/SettingsView.vue index 81d8ee5..8d2f60a 100644 --- a/src/views/SettingsView.vue +++ b/src/views/SettingsView.vue @@ -2,9 +2,9 @@ /** * 系统配置页展示集成变量说明、配置边界和审计日志脱敏 JSON。 */ -import { Connection, Refresh } from '@element-plus/icons-vue' +import { Connection, Refresh, Search } from '@element-plus/icons-vue' import { ElMessage } from 'element-plus' -import { onMounted, reactive, ref } from 'vue' +import { computed, onMounted, reactive, ref } from 'vue' import AuditJsonCell from '../components/AuditJsonCell.vue' import StatusPill from '../components/StatusPill.vue' import { devopsApi } from '../services/devopsApi' @@ -16,6 +16,7 @@ import type { IntegrationConfigItem, IntegrationConfigState, NotificationOutboxMessage, + NotificationOutboxStatus, } from '../types/devops' const platform = usePlatformStore() @@ -24,6 +25,10 @@ const agentConfigLoading = ref(false) const agentConfigTesting = ref(false) const agentConfig = ref(null) const retryingOutboxIds = ref>(new Set()) +const outboxStatusFilter = ref('') +const outboxKeyword = ref('') +const auditResourceFilter = ref('') +const auditKeyword = ref('') const agentConfigForm = reactive({ key: '', baseURL: '', @@ -55,11 +60,142 @@ const outboxStatusMeta: Record< failed: { label: '投递失败', type: 'danger' }, dead: { label: '已终止', type: 'info' }, } +const outboxStatusOptions: Array<{ label: string; value: NotificationOutboxStatus }> = [ + { label: '待投递', value: 'pending' }, + { label: '已投递', value: 'sent' }, + { label: '投递失败', value: 'failed' }, + { label: '已终止', value: 'dead' }, +] onMounted(() => { - void loadAgentConfig() + if (canManageSystemConfig.value) { + void loadAgentConfig() + } }) +const canManageSystemConfig = computed(() => session.role === 'super_admin') + +const operationOverview = computed(() => { + const integrations = platform.integrationConfig.integrations + const configured = integrations.filter((item) => item.status === 'configured').length + const missing = integrations.filter((item) => item.status === 'missing').length + const partial = integrations.filter((item) => item.status === 'partial').length + const outboxAttention = platform.notificationOutbox.filter( + (item) => item.status === 'pending' || item.status === 'failed' || item.status === 'dead', + ).length + + return [ + { key: 'integrations', label: '集成项', value: integrations.length, tone: 'neutral' }, + { key: 'configured', label: '已配置', value: configured, tone: 'success' }, + { + key: 'attention', + label: '配置关注', + value: missing + partial, + tone: missing + partial > 0 ? 'warning' : 'neutral', + }, + { + key: 'agent', + label: 'Agent 密钥', + value: agentConfig.value?.hasKey ? '已保存' : '未配置', + tone: agentConfig.value?.hasKey ? 'success' : 'warning', + }, + { + key: 'outbox', + label: '通知待排查', + value: outboxAttention, + tone: outboxAttention > 0 ? 'danger' : 'neutral', + }, + { key: 'audit', label: '审计记录', value: platform.auditLogs.length, tone: 'active' }, + ] +}) + +const agentConfigDisabledReason = computed(() => { + if (!canManageSystemConfig.value) { + return '仅超级管理员可维护 Agent 模型配置' + } + + if (!agentConfigForm.baseURL.trim()) { + return '请填写 Base URL' + } + + if (!agentConfigForm.model.trim()) { + return '请填写模型' + } + + if (!agentConfig.value?.hasKey && !agentConfigForm.key.trim()) { + return '首次保存需要填写 API Key' + } + + return '' +}) + +const canSaveAgentConfig = computed(() => !agentConfigDisabledReason.value) + +const filteredNotificationOutbox = computed(() => { + const keyword = outboxKeyword.value.trim().toLowerCase() + + return platform.notificationOutbox.filter((record) => { + if (outboxStatusFilter.value && record.status !== outboxStatusFilter.value) { + return false + } + + if (!keyword) { + return true + } + + return [ + record.id, + record.deployRunId, + record.channel, + record.template, + record.lastError, + record.idempotencyKey, + ] + .filter((value): value is string => typeof value === 'string' && value.length > 0) + .some((value) => value.toLowerCase().includes(keyword)) + }) +}) + +const auditResourceOptions = computed(() => { + return Array.from(new Set(platform.auditLogs.map((record) => record.resourceType))) + .filter(Boolean) + .sort((left, right) => left.localeCompare(right)) +}) + +const filteredAuditLogs = computed(() => { + const keyword = auditKeyword.value.trim().toLowerCase() + + return platform.auditLogs.filter((record) => { + if (auditResourceFilter.value && record.resourceType !== auditResourceFilter.value) { + return false + } + + if (!keyword) { + return true + } + + return [ + record.action, + record.resourceType, + record.resourceId, + record.actorName, + record.actorId, + record.requestId, + record.sourceIp, + ] + .filter((value): value is string => typeof value === 'string' && value.length > 0) + .some((value) => value.toLowerCase().includes(keyword)) + }) +}) + +const hasActiveOutboxFilters = computed(() => + Boolean(outboxStatusFilter.value || outboxKeyword.value.trim()), +) + +const hasActiveAuditFilters = computed(() => + Boolean(auditResourceFilter.value || auditKeyword.value.trim()), +) + function configStatusLabel(status: IntegrationConfigState): string { return configStatusMeta[status]?.label ?? '未配置' } @@ -229,6 +365,10 @@ function variableSourceLabel(item: IntegrationConfigItem, name: string): string } async function loadAgentConfig() { + if (!canManageSystemConfig.value) { + return + } + agentConfigLoading.value = true try { @@ -240,6 +380,11 @@ async function loadAgentConfig() { } async function saveAgentConfig() { + if (!canSaveAgentConfig.value) { + ElMessage.warning(agentConfigDisabledReason.value) + return + } + agentConfigLoading.value = true try { @@ -260,6 +405,11 @@ async function saveAgentConfig() { } async function testAgentConfig() { + if (!canManageSystemConfig.value) { + ElMessage.warning('仅超级管理员可测试 Agent 模型连接') + return + } + agentConfigTesting.value = true try { @@ -300,6 +450,16 @@ function applyAgentConfig(config: AgentConfigSummary) { agentConfigForm.baseURL = config.baseURL agentConfigForm.model = config.model } + +function resetOutboxFilters() { + outboxStatusFilter.value = '' + outboxKeyword.value = '' +} + +function resetAuditFilters() { + auditResourceFilter.value = '' + auditKeyword.value = '' +}