From e4f93f7193496a204efe4e753018a1c1ceb35742 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Sun, 6 Sep 2026 22:16:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E6=95=B4=E4=BD=93?= =?UTF-8?q?=E5=85=9C=E5=BA=95=E4=B8=AA=E6=80=A7=E5=8C=96=E8=A7=84=E5=88=99?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../monitor-rule-management.service.ts | 114 +++++++ .../sending-monitor/sending-monitor.module.ts | 54 ++- .../sending-monitor-redesign-plan-20260906.md | 12 + docs/system-functional-test-cases.md | 9 + docs/testing-progress.md | 10 + .../sending-monitor/MonitorConfiguration.tsx | 11 +- .../sending-monitor/MonitorObjectSelect.tsx | 176 ++++++++++ .../sending-monitor/MonitorRuleEditor.tsx | 313 +++++++++++++++++ .../admin/sending-monitor/MonitorRuleForm.tsx | 76 +++++ .../sending-monitor/MonitorRuleManager.css | 159 +++++++++ .../MonitorRuleManager.test.tsx | 105 ++++++ .../sending-monitor/MonitorRuleManager.tsx | 316 ++++++++++++++++++ src/apps/admin/sending-monitor/monitorApi.ts | 35 ++ .../admin/sending-monitor/monitorRuleDraft.ts | 28 ++ tools/quality/css-ownership.json | 6 + ...erify-monitor-rule-management-postgres.mjs | 160 +++++++++ 16 files changed, 1580 insertions(+), 4 deletions(-) create mode 100644 api/src/sending-monitor/monitor-rule-management.service.ts create mode 100644 src/apps/admin/sending-monitor/MonitorObjectSelect.tsx create mode 100644 src/apps/admin/sending-monitor/MonitorRuleEditor.tsx create mode 100644 src/apps/admin/sending-monitor/MonitorRuleForm.tsx create mode 100644 src/apps/admin/sending-monitor/MonitorRuleManager.css create mode 100644 src/apps/admin/sending-monitor/MonitorRuleManager.test.tsx create mode 100644 src/apps/admin/sending-monitor/MonitorRuleManager.tsx create mode 100644 src/apps/admin/sending-monitor/monitorRuleDraft.ts create mode 100644 tools/testing/verify-monitor-rule-management-postgres.mjs diff --git a/api/src/sending-monitor/monitor-rule-management.service.ts b/api/src/sending-monitor/monitor-rule-management.service.ts new file mode 100644 index 0000000..9066e84 --- /dev/null +++ b/api/src/sending-monitor/monitor-rule-management.service.ts @@ -0,0 +1,114 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { matchRules, type MonitorScope, type Rule } from './monitor-metrics'; + +type Query = Record; +const names = `jsonb_build_object('tenantName',t.name,'applicationName',a.name,'signatureName',s.name, + 'tenantStatus',t.status,'applicationStatus',a.status,'signatureStatus',s."auditStatus")`; +const joins = `LEFT JOIN "Tenant" t ON t.id=r.scope->>'tenantId' + LEFT JOIN "SmsApplication" a ON a.id=r.scope->>'applicationId' + LEFT JOIN "SmsSignature" s ON s.id=r.scope->>'signatureId'`; +const pageOf = (value = '1') => { + const page = Number(value); + if (!Number.isSafeInteger(page) || page < 1 || page > 100000) throw new BadRequestException('分页参数无效'); + return page; +}; +export function managementScope(q: Query): MonitorScope { + const scope = Object.fromEntries( + ['tenantId', 'applicationId', 'signatureId'].filter((key) => q[key]).map((key) => [key, q[key]]), + ); + if (Object.values(scope).some((v) => typeof v !== 'string' || !v.trim() || v.length > 200)) + throw new BadRequestException('规则范围无效'); + if (Object.keys(scope).length && (!scope.tenantId || (!scope.applicationId && !scope.signatureId))) + throw new BadRequestException('请选择企业应用或签名'); + return scope; +} + +@Injectable() +export class MonitorRuleManagementService { + constructor(private readonly prisma: PrismaService) {} + + async list(q: Query) { + const page = pageOf(q.page), + keyword = q.keyword?.trim() ?? '', + kind = q.kind ?? ''; + if (!['', 'application', 'signature', 'combined'].includes(kind)) throw new BadRequestException('覆盖类型无效'); + const where = `r.type='overall' AND r.scope<>'{}'::jsonb + AND (COALESCE((r.config->>'deleted')::boolean,false)=false OR r."effectiveAt">(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')) + AND ($1='' OR concat_ws(' ',t.name,a.name,s.name) ILIKE '%'||$1||'%') + AND ($2='' OR CASE WHEN r.scope ? 'applicationId' AND r.scope ? 'signatureId' THEN 'combined' + WHEN r.scope ? 'signatureId' THEN 'signature' ELSE 'application' END=$2)`; + return this.prisma.$transaction( + async (tx) => { + const items = await tx.$queryRawUnsafe( + `SELECT r.*,${names} AS names, + (SELECT to_jsonb(v) FROM "SendingMonitorRuleVersion" v WHERE v."ruleId"=r.id + AND v."effectiveAt"<=(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') ORDER BY version DESC LIMIT 1) AS active + FROM "SendingMonitorRule" r ${joins} WHERE ${where} ORDER BY r."updatedAt" DESC,r.id LIMIT 20 OFFSET $3`, + keyword, + kind, + (page - 1) * 20, + ); + const count = await tx.$queryRawUnsafe>( + `SELECT count(*)::int total FROM "SendingMonitorRule" r ${joins} WHERE ${where}`, + keyword, + kind, + ); + return { items, total: count[0].total, page, pageSize: 20, serverTime: new Date().toISOString() }; + }, + { isolationLevel: 'RepeatableRead' }, + ); + } + + async options(q: Query) { + const page = pageOf(q.page), + keyword = q.keyword?.trim() ?? ''; + let from: string, fields: string, restriction: string; + if (q.kind === 'tenant') { + from = '"Tenant" t'; + fields = `t.id,t.name,t.id AS "tenantId"`; + restriction = `t.status<>'deleted' AND ($1='' OR t.name ILIKE '%'||$1||'%') AND $2::text IS NOT NULL AND $3::text IS NOT NULL`; + } else if (q.kind === 'application') { + from = '"SmsApplication" a JOIN "Tenant" t ON t.id=a."tenantId"'; + fields = `a.id,concat_ws(' · ',t.name,a.name) AS name,t.id AS "tenantId"`; + restriction = `a.status<>'deleted' AND t.status<>'deleted' AND ($1='' OR concat_ws(' ',t.name,a.name) ILIKE '%'||$1||'%') AND ($2='' OR t.id=$2) AND $3::text IS NOT NULL`; + } else if (q.kind === 'signature' && q.tenantId) { + from = + '"SmsSignature" s JOIN "Tenant" t ON t.id=s."tenantId" LEFT JOIN "SmsApplication" a ON a.id=s."applicationId"'; + fields = `s.id,concat_ws(' · ',s.name,a.name) AS name,t.id AS "tenantId"`; + restriction = `s."auditStatus"<>'deleted' AND t.status<>'deleted' AND (a.id IS NULL OR a.status<>'deleted') AND ($1='' OR s.name ILIKE '%'||$1||'%') AND t.id=$2 AND ($3='' OR s."applicationId"=$3)`; + } else throw new BadRequestException('选项类型无效或尚未选择企业'); + const items = await this.prisma.$queryRawUnsafe>( + `SELECT ${fields} FROM ${from} WHERE ${restriction} ORDER BY name,id LIMIT 21 OFFSET $4`, + keyword, + q.tenantId ?? '', + q.applicationId ?? '', + (page - 1) * 20, + ); + return { items: items.slice(0, 20), page, hasMore: items.length > 20 }; + } + + async editor(q: Query) { + const scope = managementScope(q), + scopeKey = JSON.stringify(Object.fromEntries(Object.entries(scope).sort())); + return this.prisma.$transaction( + async (tx) => { + const current = await tx.$queryRawUnsafe>( + `SELECT r.*,${names} AS names FROM "SendingMonitorRule" r ${joins} WHERE r.type='overall' AND r."scopeKey"=$1`, + scopeKey, + ); + const versions = await tx.$queryRawUnsafe(`SELECT r.*,${names} AS names FROM + (SELECT DISTINCT ON ("ruleId") * FROM "SendingMonitorRuleVersion" WHERE type='overall' + AND "effectiveAt"<=(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') ORDER BY "ruleId",version DESC) r ${joins}`); + const matched = matchRules(versions, 'overall', scope); + return { + current: current[0] ?? null, + matched, + inherited: matched.filter((r) => r.ruleId !== current[0]?.id), + serverTime: new Date().toISOString(), + }; + }, + { isolationLevel: 'RepeatableRead' }, + ); + } +} diff --git a/api/src/sending-monitor/sending-monitor.module.ts b/api/src/sending-monitor/sending-monitor.module.ts index 0789d62..d0f2bb4 100644 --- a/api/src/sending-monitor/sending-monitor.module.ts +++ b/api/src/sending-monitor/sending-monitor.module.ts @@ -18,6 +18,7 @@ import { randomUUID } from 'node:crypto'; import { PrismaService } from '../prisma/prisma.service'; import { PrismaModule } from '../prisma/prisma.module'; import type { SessionRequest } from '../auth/session-validation.middleware'; +import { MonitorRuleManagementService } from './monitor-rule-management.service'; import { horizons, matchRules, @@ -217,19 +218,31 @@ export class SendingMonitorService { .sort(), ) as MonitorScope; if ( + !body.config.deleted && + scope.tenantId && + !(await this.prisma.tenant.findFirst({ + where: { id: scope.tenantId, status: { not: 'deleted' } }, + select: { id: true }, + })) + ) + throw new BadRequestException('企业不存在或已删除'); + if ( + !body.config.deleted && scope.applicationId && !(await this.prisma.smsApplication.findFirst({ - where: { id: scope.applicationId, tenantId: scope.tenantId }, + where: { id: scope.applicationId, tenantId: scope.tenantId, status: { not: 'deleted' } }, select: { id: true }, })) ) throw new BadRequestException('应用不属于该企业'); if ( + !body.config.deleted && scope.signatureId && !(await this.prisma.smsSignature.findFirst({ where: { id: scope.signatureId, tenantId: scope.tenantId, + auditStatus: { not: 'deleted' }, ...(scope.applicationId ? { applicationId: scope.applicationId } : {}), }, select: { id: true }, @@ -245,6 +258,7 @@ export class SendingMonitorService { type, scopeKey, ); + if (body.config.deleted && !old[0]) throw new NotFoundException('要恢复继承的规则不存在'); if ((old[0]?.version ?? 0) !== body.version) throw new ConflictException('规则已被修改,请刷新后重试'); const id = old[0]?.id ?? randomUUID(), period = type === 'overall' ? 600 : 300; @@ -285,6 +299,14 @@ export class SendingMonitorService { return result; }); } + async restoreRule(id: string, version: number, actor: string) { + const rows = await this.prisma.$queryRawUnsafe>( + `SELECT type,scope,config FROM "SendingMonitorRule" WHERE id=$1 AND type='overall' AND scope<>'{}'::jsonb`, + id, + ); + if (!rows[0]) throw new NotFoundException('个性化规则不存在'); + return this.saveRule({ ...rows[0], config: { ...rows[0].config, deleted: true }, version }, actor); + } async alerts(query: QueryParams, user: string) { const page = pageNumber(query.page, 1), size = pageNumber(query.pageSize, 20, 100); @@ -338,7 +360,29 @@ export class SendingMonitorService { @Controller('admin/sending-monitor') class SendingMonitorController { - constructor(private readonly service: SendingMonitorService) {} + constructor( + private readonly service: SendingMonitorService, + private readonly management: MonitorRuleManagementService, + ) {} + @Get('rule-management') async managementList(@Req() req: SessionRequest, @Query() q: QueryParams) { + await this.service.authorize(req); + return this.management.list(q); + } + @Get('rule-options') async ruleOptions(@Req() req: SessionRequest, @Query() q: QueryParams) { + await this.service.authorize(req); + return this.management.options(q); + } + @Get('rule-editor') async ruleEditor(@Req() req: SessionRequest, @Query() q: QueryParams) { + await this.service.authorize(req); + return this.management.editor(q); + } + @Post('rules/:id/restore') async restore( + @Req() req: SessionRequest, + @Param('id') id: string, + @Body() body: { version?: number }, + ) { + return this.service.restoreRule(id, body?.version as number, await this.service.authorize(req, 'rules')); + } @Get('rows') async rows(@Req() req: SessionRequest, @Query() q: QueryParams) { await this.service.authorize(req); return this.service.rows(q); @@ -393,5 +437,9 @@ class SendingMonitorController { return this.service.read(id, await this.service.authorize(req)); } } -@Module({ imports: [PrismaModule], controllers: [SendingMonitorController], providers: [SendingMonitorService] }) +@Module({ + imports: [PrismaModule], + controllers: [SendingMonitorController], + providers: [SendingMonitorService, MonitorRuleManagementService], +}) export class SendingMonitorModule {} diff --git a/docs/sending-monitor-redesign-plan-20260906.md b/docs/sending-monitor-redesign-plan-20260906.md index 4c0ffe3..e018911 100644 --- a/docs/sending-monitor-redesign-plan-20260906.md +++ b/docs/sending-monitor-redesign-plan-20260906.md @@ -311,3 +311,15 @@ R_h = S_h / N_h × 100% # N_h=0时为null 事实保留期本次采用72小时(第8节2小时是原建议),以保证迟到修改能替换原贡献、不会在删除事实后把旧桶重建成局部样本;分钟7天、快照30天、关闭告警90天。新增表不存短信正文/手机号。需要以真实峰值容量预算再优化事实压缩/冷热分层;当前不得据此宣称500TPS下预算达标。真实短信压测未获授权,只使用隔离数据库样本验证统计和查询成本。 纳管范围使用不可变版本,按窗口评估时刻解析;以后移除/重新加入不改写旧统计。当前API规则按type+scope通过POST完整保存(version乐观锁),不另外提供重复的PUT更新入口。新Worker健康异常通过发送监控数据延迟和预警中心不可用说明展示;监控性能指标接Prometheus和真实峰值对照尚未验收。 + +## 13. 整体兜底规则管理交互改造(2026-09-06) + +状态:用户已授权实施、提交、推送并分别发布测试和预生产。替代第5.3节配置操作方式,统计口径、整套覆盖顺序和下一周期生效规则保持。 + +- 阈值设置分“通用规则”和“个性化规则”页签。后者为可搜索、分页的规则列表,展示企业/应用/签名名称、覆盖类型、阈值摘要、状态、生效时间,提供新增覆盖、编辑和恢复继承;不以内部ID作为主要展示。 +- 新增覆盖默认应用级;支持应用、签名、应用×签名。应用选择器直接搜索“企业·应用”;签名级先选企业,组合级签名限定该应用。搜索和分页放入对应下拉框,选中持续显示名称;上级变化清空下级,未保存变更先确认。 +- 编辑器与列表在同一弹窗切换,已有规则范围锁定;新增重复范围提示编辑已有规则。新规则按实际继承来源预填整套配置,没有来源则保持空值,不从原型填阈值。异步加载失败阻止提交、提供重试;迟到请求不能覆盖已修改草稿。 +- 表单分适用范围、告警阈值、触发与恢复三组;保存按钮防重、字段就近校验、失败保留草稿、409允许显式重新加载。保存成功返回列表并保留筛选,显示真实版本及下一周期生效时间。切换页签、返回列表、关闭和刷新保护未保存内容。 +- 停用仍保留该层覆盖;恢复继承移除该层覆盖,使用数据库已保存配置和version,不受编辑草稿必填校验干扰。确认说明剩余规则匹配顺序,具体应用×签名可查看准确匹配链;没有下层规则明确不告警,更具体范围仍按自身规则匹配。恢复仍通过版本记录/审计事务生效,不物理删除历史。 +- 新增受既有管理员授权保护的规则管理分页、对象远程搜索、编辑上下文和恢复继承API;保留原rules/options/effective-rule接口兼容其他使用者。返回名称及对象状态、当前生效版本/待生效版本/服务器时间;历史已删除对象保留名称或明确占位,不进入新增选项。使用参数绑定、分页上限和确定性排序;新增查询不进入发送热路径,不改变表结构、Redis或Worker计算。 +- 样式归sending-monitor组件目录,由所有者import,不修改历史CSS模块或公共Select。验收覆盖真实API/PG持久化、三种范围及跨企业校验、继承/停用差异、重复/并发409、异步及失败、三尺寸和历史对象;发布仅重建前后端并重启API,其他服务按实际兼容检查保留。預生产缺少有效登录入口时如实保留登录后验收缺口,不擅自新增账号。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index b478544..0ad98df 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -5178,3 +5178,12 @@ npm run verify:phase8 - SMR-027:运行概况三尺寸首次进入、刷新和跨tab切换,三个最近列表均不存在;真实GET /admin/operations/monitor仅含byStatus,状态卡片/通道表/基础设施入口可用,独立记录页面保留。 - SMR-028:真实Prisma加入、移除、重新加入及规则保存,版本和审计持久化;同版本并发仅一次成功,另一次409;不存在通道404,非法enabled/version400;审计失败回滚当前行和版本行。真实PG隔离schema验证,不用mock掩盖void返回类型。 - SMR-029:测试环境人工生成并标注“测试演示”的三类监控已恢复/已关闭告警,列表、筛选、详情和历史曲线来自真实API/PG;演示阈值仅存在历史快照,不写业务规则或发送短信,不能用该数据证明自动告警链路或真实投递成功。 + +### SMR-030~035 整体兜底规则管理(2026-09-06) + +- SMR-030:通用/个性化页签、名称列表、搜索/类型筛选/分页,已配置及空态、失败重试;实际接口有界分页,不能用ID代替名称或失败显示空列表。 +- SMR-031:新增应用、签名、应用×签名三种覆盖;企业应用可按企业/应用名远程搜索,签名限定企业/应用;选中名称持续显示,上级变化清除下级,不可跨企业保存。已删除对象不进新增选项,历史覆盖仍显示名称与状态。 +- SMR-032:已有范围提示编辑已有规则,编辑锁定范围;新增预填整套继承值、无来源为空;异步加载期间不可保存,迟到响应不覆盖草稿;字段校验、保存失败、409、未保存离开保护正常。 +- SMR-033:列表与编辑器区分当前生效版本和下一周期待生效版本,展示真实生效时间;应用×签名>签名>应用>通用,停用保留覆盖,不等于恢复继承。 +- SMR-034:恢复继承使用数据库保存配置与version,说明无下层规则/更具体规则的影响;新版本、历史版本与审计原子提交,并发同版本仅一次成功;审计失败完整回滚,历史对象删除后仍可移除覆盖。 +- SMR-035:1600×1000、1366×768、390×844检查列表、新增、编辑、搜索下拉及恢复确认;保存后回列表保留筛选,单层弹窗Footer可见,表格内部滚动;真实API/PG验收与组件测试分别记录,不发送短信。 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 11ba620..9cae052 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -4657,3 +4657,13 @@ git diff --check - 告警真实API总数6,恢复/关闭筛选各3;三尺寸列表和详情、6点历史曲线通过,console error/pageerror均0,无整页横向溢出。截图复核监控通道窄屏及告警详情清晰可操作。人工造数只证明真实API/数据库展示,不冒充自然产生的监控告警或真实投递验收。动态业务/规则/余额/通道表摘要造数前后一致,短信仍119509;未发送、补发、重投、重新入队短信。 - 最终前端100项、API639项、真实Prisma配置12项、类型/构建/格式/ESLint/结构/安全/部署门禁通过;未修改CSS或Gateway,不重跑无关Go链路测试。线上主JS index-UxUuzIeW.js摘要3da1b28cfa2997d2d93e374fdd5a575ed629814d6ce33a32bd7b1d0f2b68329f,主CSS仍index-Oaoh-Ppy.css,HTTP下载与运行产物一致。队列三个Stream pending/lag均0,last-delivered和entries-read与上一轮基线相同;监控水位complete=true、pendingReceipts0。 - 证据目录:本机%TEMP%/cmpp-monitor-fix-20260906的before.json、after.json、runtime/joined/alerts/alert-detail三尺寸截图、前后端测试/构建/质量日志及受限测试脚本;脚本内无认证秘密,持久管理员正常退出后保留。原规则空态正常不等于配置保存正常,本次以真实服务写入/并发/回滚验证补足覆盖。未执行短信发送、真实高峰压测或预生产验收。 + +## 2026-09-06 整体兜底规则管理改造(发布前) + +- 用户授权按已确认方案实施、提交、推送及测试/预生产部署。起始main/HEAD/实时origin/main及两环境标记均0e3424c,分叉0/0;8份已有修改文档及3份未跟踪草稿保护。本轮文档只提交精确追加段落,不夹带原修改或上轮未提交发布记录。 +- 只读证据:同版本测试页面选择企业后搜索结果仍为“请选择”,已选仅显示内部ID;通用、新增、编辑和继承混在同一表单。风控规则提供独立列表/新增应用覆盖及“企业·应用”选择,真实GET均200,测试规则0。原型和说明见sending-monitor-redesign-plan-20260906.md第13节;截图/请求在本机%TEMP%/cmpp-monitor-rule-review-20260906。 +- 实现:整体兜底独立规则管理,通用/个性化页签、名称及阈值列表、搜索分页、新增三种覆盖、对象联动、重复检测与锁定编辑、继承预填、当前/待生效区分、字段校验、草稿保护和保存回列表。后端新增名称/对象分页/编辑上下文接口与恢复继承入口,沿用管理员权限、乐观锁、整套覆盖及周期版本。历史已删除企业/应用/签名不可新增配置,可恢复继承;签名软删除字段为auditStatus。无数据库迁移、Gateway/Worker计算或队列契约变更。 +- 样式仅在MonitorRuleManager.css,由同目录所有者直接import,登记严格roots;未改公共Select、历史CSS模块或Stylelint例外。门禁首次提示新文件未登记所有者,补充css-ownership.json后通过,不重算历史摘要。 +- 本地前端21套105项、API60套639项、前后端TypeScript/生产构建、增量格式/ESLint、Stylelint、CSS治理及其用例、安全/部署/包体门禁通过。首次辅助函数拆分漏导入及测试定位类型错误经类型/全量检查发现并修复,最终全量重跑通过。 +- 测试机真实Prisma/service隔离schema15项通过:跨企业名称搜索、20+3分页、签名联动、非法查询、三种范围、重复范围版本、当前/待生效、继承优先级、停用保留覆盖、恢复并发409、软删除历史、审计失败回滚。使用全新隔离Tenant/Application/Signature样本,public业务写入0,结束精确删除自己的schema;未发送短信。脚本tools/testing/verify-monitor-rule-management-postgres.mjs可重复执行。 +- 本机证据目录%TEMP%/cmpp-rule-manager-20260906保存原修改备份、前后端测试/构建/门禁日志。真实登录后页面及两环境发布尚待执行,不能用上述单测和隔离集成结论代替;预生产仍需现有有效管理员安全入口,不擅自重建或重置账号。 diff --git a/src/apps/admin/sending-monitor/MonitorConfiguration.tsx b/src/apps/admin/sending-monitor/MonitorConfiguration.tsx index 58dfb9c..3be2ad0 100644 --- a/src/apps/admin/sending-monitor/MonitorConfiguration.tsx +++ b/src/apps/admin/sending-monitor/MonitorConfiguration.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { Button, Input, Modal, Select, Tag } from '@/components/ui'; +import { MonitorRuleManager } from './MonitorRuleManager'; import { monitorApi, names, @@ -26,7 +27,7 @@ export function MonitorScopePicker({ scope, onChange }: { scope: Scope; onChange return; } monitorApi - .options(kind, scope, keyword, page) + .options(kind, { tenantId: scope.tenantId, applicationId: scope.applicationId }, keyword, page) .then((items) => { if (live) { setOptions(items); @@ -109,6 +110,14 @@ export function MonitorScopePicker({ scope, onChange }: { scope: Scope; onChange } export function MonitorRulesModal({ type, onClose }: { type: MonitorType; onClose: () => void }) { + return type === 'overall' ? ( + + ) : ( + + ); +} + +function CommonMonitorRulesModal({ type, onClose }: { type: MonitorType; onClose: () => void }) { const dirtyRef = useRef(false); const [rules, setRules] = useState([]), [error, setError] = useState(''), diff --git a/src/apps/admin/sending-monitor/MonitorObjectSelect.tsx b/src/apps/admin/sending-monitor/MonitorObjectSelect.tsx new file mode 100644 index 0000000..e6eb3ea --- /dev/null +++ b/src/apps/admin/sending-monitor/MonitorObjectSelect.tsx @@ -0,0 +1,176 @@ +import { useEffect, useId, useRef, useState } from 'react'; +import { Button, Input } from '@/components/ui'; +import { ruleManagementApi, type RuleOption } from './monitorApi'; + +export function MonitorObjectSelect({ + label, + kind, + value, + tenantId, + applicationId, + onChange, + disabled = false, +}: { + label: string; + kind: 'tenant' | 'application' | 'signature'; + value?: RuleOption; + tenantId?: string; + applicationId?: string; + disabled?: boolean; + onChange: (value: RuleOption) => void; +}) { + const id = useId(), + root = useRef(null); + const [open, setOpen] = useState(false), + [keyword, setKeyword] = useState(''), + [page, setPage] = useState(1); + const [items, setItems] = useState([]), + [more, setMore] = useState(false), + [loading, setLoading] = useState(false); + const [error, setError] = useState(''), + [retry, setRetry] = useState(0); + useEffect(() => { + if (!open) return; + const controller = new AbortController(); + const timer = setTimeout(() => { + setLoading(true); + setItems([]); + setError(''); + void ruleManagementApi + .options({ kind, keyword, page, tenantId, applicationId }, controller.signal) + .then((result) => { + if (!controller.signal.aborted) { + setItems(result.items); + setMore(result.hasMore); + } + }) + .catch((e: Error) => { + if (!controller.signal.aborted) setError(e.message); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + }, 250); + return () => { + controller.abort(); + clearTimeout(timer); + }; + }, [open, kind, keyword, page, tenantId, applicationId, retry]); + useEffect(() => { + const outside = (e: PointerEvent) => { + if (!root.current?.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('pointerdown', outside); + return () => document.removeEventListener('pointerdown', outside); + }, []); + return ( +
{ + if (e.key === 'Escape' && open) { + e.stopPropagation(); + setOpen(false); + root.current?.querySelector('[aria-haspopup]')?.focus(); + } + if (['ArrowDown', 'ArrowUp'].includes(e.key) && open) { + e.preventDefault(); + const options = [...(root.current?.querySelectorAll('[role="option"]') ?? [])]; + const index = options.indexOf(document.activeElement as HTMLButtonElement); + options[(index + (e.key === 'ArrowDown' ? 1 : -1) + options.length) % options.length]?.focus(); + } + }} + > + {label} + + {open && ( +
+ { + setLoading(true); + setKeyword(e.target.value); + setPage(1); + }} + /> + {loading ? ( +

加载中…

+ ) : error ? ( +
+ {error} + +
+ ) : ( + <> +
+ {items.map((item) => ( + + ))} +
+ {!items.length &&

没有匹配的{label}

} +
+ + 第 {page} 页 + +
+ + )} +
+ )} +
+ ); +} diff --git a/src/apps/admin/sending-monitor/MonitorRuleEditor.tsx b/src/apps/admin/sending-monitor/MonitorRuleEditor.tsx new file mode 100644 index 0000000..fe78a6f --- /dev/null +++ b/src/apps/admin/sending-monitor/MonitorRuleEditor.tsx @@ -0,0 +1,313 @@ +import { useEffect, useRef, useState } from 'react'; +import { Button, Modal, Select, Tag } from '@/components/ui'; +import { MonitorObjectSelect } from './MonitorObjectSelect'; +import { MonitorRuleForm } from './MonitorRuleForm'; +import { draftErrors, ruleDraft } from './monitorRuleDraft'; +import { + monitorApi, + ruleManagementApi, + ruleSource, + scopeTitle, + time, + type ManagedRule, + type RuleContext, + type RuleOption, + type Scope, +} from './monitorApi'; + +export function MonitorRuleEditor({ + initial, + common = false, + onClose, + onBack, + onSaved, +}: { + initial?: ManagedRule; + common?: boolean; + onClose: () => void; + onBack: () => void; + onSaved: (rule: ManagedRule) => void; +}) { + const [scope, setScope] = useState(initial?.scope ?? {}); + const [kind, setKind] = useState( + initial?.scope.signatureId ? (initial.scope.applicationId ? 'combined' : 'signature') : 'application', + ); + const [tenant, setTenant] = useState(), + [application, setApplication] = useState(), + [signature, setSignature] = useState(); + const [editing, setEditing] = useState(Boolean(initial)); + const [context, setContext] = useState(), + [draft, setDraft] = useState(ruleDraft()); + const [error, setError] = useState(''), + [errors, setErrors] = useState>({}); + const [busy, setBusy] = useState(false), + [loading, setLoading] = useState(false), + [dirty, setDirty] = useState(false), + [retry, setRetry] = useState(0); + const saving = useRef(false); + const complete = + common || + Boolean( + scope.tenantId && + (kind === 'application' + ? scope.applicationId + : kind === 'signature' + ? scope.signatureId + : scope.applicationId && scope.signatureId), + ); + const scopeKey = JSON.stringify(scope); + useEffect(() => { + setContext(undefined); + setError(''); + setErrors({}); + if (!complete) { + setLoading(false); + setDraft(ruleDraft()); + return; + } + const controller = new AbortController(); + setLoading(true); + void ruleManagementApi + .editor(JSON.parse(scopeKey) as Scope, controller.signal) + .then((result) => { + if (controller.signal.aborted) return; + setContext(result); + setDraft( + ruleDraft( + result.current && !result.current.config.deleted ? result.current.config : result.inherited[0]?.config, + ), + ); + setDirty(false); + }) + .catch((e: Error) => { + if (!controller.signal.aborted) setError(e.message); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [scopeKey, complete, retry]); + useEffect(() => { + const before = (event: BeforeUnloadEvent) => { + if (dirty) event.preventDefault(); + }; + window.addEventListener('beforeunload', before); + return () => window.removeEventListener('beforeunload', before); + }, [dirty]); + const leave = (action: () => void) => { + if (!saving.current && (!dirty || window.confirm('有未保存的规则,确认放弃修改?'))) action(); + }; + const changeScope = (next: Scope, action: () => void) => + leave(() => { + setScope(next); + setDirty(false); + action(); + }); + const duplicate = !common && !editing && context?.current && !context.current.config.deleted; + const selected = initial ?? context?.current; + const invalidObject = Boolean( + selected && + Object.entries(selected.scope).some(([key]) => { + const prefix = key.replace('Id', ''); + return !selected.names?.[`${prefix}Name`] || selected.names?.[`${prefix}Status`] === 'deleted'; + }), + ); + async function save() { + if (!context || !complete || duplicate || invalidObject || saving.current) return; + const nextErrors = draftErrors(draft); + setErrors(nextErrors); + if (Object.keys(nextErrors).length) return; + saving.current = true; + setBusy(true); + setError(''); + try { + const result = (await monitorApi.saveRule({ + type: 'overall', + scope, + version: context.current?.version ?? 0, + config: { + enabled: draft.enabled, + minSamples: Number(draft.min), + thresholds: draft.thresholds.map((n) => (n.trim() ? Number(n) : null)), + consecutiveBad: Number(draft.bad), + consecutiveGood: Number(draft.good), + deleted: false, + }, + })) as ManagedRule[]; + setDirty(false); + onSaved(result[0]); + } catch (e) { + setError(e instanceof Error ? e.message : '保存失败,请重试'); + } finally { + saving.current = false; + setBusy(false); + } + } + return ( + leave(onClose)} + footer={ + <> + + + + } + > +
+ {common && ( +
+ + +
+ )} +
+

适用范围

+ {common ? ( +

全局默认;没有更具体覆盖时采用此规则。

+ ) : editing ? ( +

+ {selected ? scopeTitle(selected) : '读取范围中…'} {ruleSource(selected ?? null)} +

+ ) : ( + <> + onChange({ ...draft, min: e.target.value })} + /> + {['1分钟', '5分钟', '20分钟'].map((name, i) => ( + + onChange({ ...draft, thresholds: draft.thresholds.map((n, j) => (j === i ? e.target.value : n)) }) + } + /> + ))} +
+

空白代表不启用该指标;0%不会因低到达率触发告警。未配置规则时不告警。

+

触发与恢复

+ +
+ onChange({ ...draft, bad: e.target.value })} + /> + onChange({ ...draft, good: e.target.value })} + /> +
+

停用会保留本层覆盖,在其实际匹配范围内不告警;恢复继承则移除本层覆盖。

+ + ); +} diff --git a/src/apps/admin/sending-monitor/MonitorRuleManager.css b/src/apps/admin/sending-monitor/MonitorRuleManager.css new file mode 100644 index 0000000..87c5750 --- /dev/null +++ b/src/apps/admin/sending-monitor/MonitorRuleManager.css @@ -0,0 +1,159 @@ +.monitor-rules { + display: grid; + gap: 18px; + color: var(--color-text); +} + +.monitor-rules .monitor-rules__tabs, +.monitor-rules .monitor-rules__actions, +.monitor-rules .monitor-rules__toolbar { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.monitor-rules .monitor-rules__tabs { + border-bottom: 1px solid #e5e7eb; +} + +.monitor-rules .monitor-rules__tabs > button { + padding: 10px 16px; + border: 0; + background: transparent; + cursor: pointer; + border-bottom: 2px solid transparent; +} + +.monitor-rules .monitor-rules__tabs > button[aria-selected='true'] { + color: #2563eb; + border-bottom-color: #2563eb; +} + +.monitor-rules .monitor-rules__toolbar > .ui-field { + flex: 1; + min-width: 180px; +} + +.monitor-rules .monitor-rules__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.monitor-rules .monitor-rules__section, +.monitor-rules .monitor-rules__fields { + display: grid; + gap: 14px; + padding: 0; + margin: 0; + min-width: 0; + border: 0; +} + +.monitor-rules .monitor-rules__fields > legend, +.monitor-rules .monitor-rules__section > h3, +.monitor-rules .monitor-rules__fields > h3 { + font-size: 16px; + font-weight: 600; + margin: 0; + padding-bottom: 8px; +} + +.monitor-rules .monitor-rules__notice { + padding: 12px 14px; + border: 1px solid #dbeafe; + border-radius: 8px; + background: #eff6ff; + overflow-wrap: anywhere; +} + +.monitor-rules .monitor-rules__hint { + color: #6b7280; + font-size: 13px; + margin: 0; +} + +.monitor-rules .monitor-rules__error { + color: #dc2626; +} + +.monitor-rules .monitor-rules__check { + display: flex; + gap: 8px; + align-items: center; +} + +.monitor-rules .monitor-rules__picker { + position: relative; + display: grid; + gap: 8px; + min-width: 0; +} + +.monitor-rules .monitor-rules__select { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; + padding: 10px 12px; + min-height: 40px; + border: 1px solid #e5e7eb; + border-radius: 6px; + background: #fff; + text-align: left; + overflow-wrap: anywhere; + cursor: pointer; +} + +.monitor-rules .monitor-rules__select:disabled { + opacity: 0.5; + cursor: default; +} + +.monitor-rules .monitor-rules__dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 5; + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid #d1d5db; + border-radius: 6px; + background: #fff; +} + +.monitor-rules .monitor-rules__options { + max-height: 220px; + overflow: auto; + display: grid; +} + +.monitor-rules .monitor-rules__options > button { + border: 0; + padding: 10px; + background: #fff; + text-align: left; + overflow-wrap: anywhere; + cursor: pointer; +} + +.monitor-rules .monitor-rules__options > button:hover, +.monitor-rules .monitor-rules__options > button:focus-visible, +.monitor-rules .monitor-rules__options > button[aria-selected='true'] { + background: #eff6ff; + color: #2563eb; +} + +@media (width <= 640px) { + .monitor-rules .monitor-rules__grid { + grid-template-columns: minmax(0, 1fr); + } + + .monitor-rules .monitor-rules__toolbar { + align-items: stretch; + flex-direction: column; + } +} diff --git a/src/apps/admin/sending-monitor/MonitorRuleManager.test.tsx b/src/apps/admin/sending-monitor/MonitorRuleManager.test.tsx new file mode 100644 index 0000000..27395a4 --- /dev/null +++ b/src/apps/admin/sending-monitor/MonitorRuleManager.test.tsx @@ -0,0 +1,105 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, expect, it, vi } from 'vitest'; +import { MonitorRuleManager } from './MonitorRuleManager'; +import { MonitorRuleEditor } from './MonitorRuleEditor'; +const api = vi.hoisted(() => ({ + list: vi.fn(), + options: vi.fn(), + editor: vi.fn(), + restore: vi.fn(), + saveRule: vi.fn(), +})); +vi.mock('./monitorApi', async (original) => ({ + ...(await original()), + ruleManagementApi: api, + monitorApi: { saveRule: api.saveRule }, +})); +const config = { enabled: true, minSamples: 100, thresholds: [90, 95, 98], consecutiveBad: 1, consecutiveGood: 2 }; +const rule = { + id: 'r1', + type: 'overall' as const, + scope: { tenantId: 't1', applicationId: 'a1' }, + config, + version: 2, + effectiveAt: '2026-09-06T00:10:00Z', + names: { tenantName: '企业甲', applicationName: '应用甲', tenantStatus: 'active', applicationStatus: 'active' }, +}; +const empty = { current: null, matched: [], inherited: [], serverTime: '2026-09-06T00:00:00Z' }; +beforeEach(() => { + vi.clearAllMocks(); + api.editor.mockResolvedValue(empty); + api.list.mockResolvedValue({ items: [], total: 0, page: 1, pageSize: 20, serverTime: empty.serverTime }); + api.options.mockResolvedValue({ + items: [{ id: 'a1', tenantId: 't1', name: '企业甲 · 应用甲' }], + hasMore: false, + page: 1, + }); +}); +it('keeps unconfigured common values blank and validates inline before writing', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => expect(screen.getByRole('button', { name: '保存规则' })).toBeEnabled()); + expect(screen.getByLabelText('1分钟到达率下限(%)')).toHaveValue(null); + await user.click(screen.getByRole('button', { name: '保存规则' })); + expect(screen.getByText('请至少设置一项到达率下限')).toBeVisible(); + expect(api.saveRule).not.toHaveBeenCalled(); +}); +it('shows named rules and pending status separately from current status', async () => { + api.list.mockResolvedValue({ + items: [{ ...rule, config: { ...config, enabled: false }, active: { ...rule, version: 1 } }], + total: 1, + page: 1, + pageSize: 20, + serverTime: empty.serverTime, + }); + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('tab', { name: '个性化规则' })); + expect(await screen.findByText('企业甲 · 应用甲')).toBeVisible(); + expect(screen.getByText('告警启用')).toBeVisible(); + expect(screen.getByText('待生效 v2 · 停用')).toBeVisible(); +}); +it('detects an existing scope, keeps its name visible, and requires explicit editing', async () => { + api.editor.mockResolvedValue({ ...empty, current: rule }); + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: '企业应用' })); + await user.click(await screen.findByRole('option', { name: '企业甲 · 应用甲' })); + expect(await screen.findByRole('button', { name: '编辑已有规则' })).toBeVisible(); + expect(screen.getByRole('button', { name: '企业应用' })).toHaveTextContent('企业甲 · 应用甲'); + expect(screen.getByRole('button', { name: '保存规则' })).toBeDisabled(); + await user.click(screen.getByRole('button', { name: '编辑已有规则' })); + expect(screen.queryByRole('button', { name: '企业应用' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: '保存规则' })).toBeEnabled(); +}); +it('preserves draft on conflict and does not leave without confirmation', async () => { + api.editor.mockResolvedValue({ ...empty, current: rule }); + api.saveRule.mockRejectedValue(new Error('规则已被修改,请刷新后重试')); + const back = vi.fn(), + user = userEvent.setup(); + render(); + await waitFor(() => expect(screen.getByLabelText('最低成熟样本量')).toHaveValue(100)); + await user.clear(screen.getByLabelText('最低成熟样本量')); + await user.type(screen.getByLabelText('最低成熟样本量'), '250'); + await user.click(screen.getByRole('button', { name: '保存规则' })); + expect(await screen.findByRole('alert')).toHaveTextContent('规则已被修改'); + expect(screen.getByLabelText('最低成熟样本量')).toHaveValue(250); + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); + await user.click(screen.getByRole('button', { name: '返回列表' })); + expect(back).not.toHaveBeenCalled(); + confirm.mockRestore(); +}); +it('restores using the saved rule and displays the no-inheritance outcome', async () => { + api.list.mockResolvedValue({ items: [rule], total: 1, page: 1, pageSize: 20, serverTime: empty.serverTime }); + api.editor.mockResolvedValue({ ...empty, current: rule }); + api.restore.mockResolvedValue([{ ...rule, version: 3 }]); + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('tab', { name: '个性化规则' })); + await user.click(await screen.findByRole('button', { name: '恢复继承' })); + expect(await screen.findByText(/没有可用规则,恢复后不告警/)).toBeVisible(); + await user.click(screen.getByRole('button', { name: '确认恢复继承' })); + expect(api.restore).toHaveBeenCalledWith(rule); + expect(api.saveRule).not.toHaveBeenCalled(); +}); diff --git a/src/apps/admin/sending-monitor/MonitorRuleManager.tsx b/src/apps/admin/sending-monitor/MonitorRuleManager.tsx new file mode 100644 index 0000000..aa55fbd --- /dev/null +++ b/src/apps/admin/sending-monitor/MonitorRuleManager.tsx @@ -0,0 +1,316 @@ +import { useEffect, useState } from 'react'; +import { Button, Input, Modal, Pagination, Select, Table, Tag, type TableColumn } from '@/components/ui'; +import { MonitorRuleEditor } from './MonitorRuleEditor'; +import { ruleManagementApi, ruleSource, scopeTitle, time, type ManagedRule, type RuleContext } from './monitorApi'; +import './MonitorRuleManager.css'; + +function RestoreRule({ + rule, + onBack, + onSaved, +}: { + rule: ManagedRule; + onBack: () => void; + onSaved: (rule: ManagedRule) => void; +}) { + const [context, setContext] = useState(), + [error, setError] = useState(''), + [busy, setBusy] = useState(false), + [retry, setRetry] = useState(0); + useEffect(() => { + const abort = new AbortController(); + setContext(undefined); + setError(''); + void ruleManagementApi + .editor(rule.scope, abort.signal) + .then((value) => { + if (!abort.signal.aborted) setContext(value); + }) + .catch((e: Error) => { + if (!abort.signal.aborted) setError(e.message); + }); + return () => abort.abort(); + }, [rule, retry]); + async function restore() { + if (!context?.current || busy) return; + setBusy(true); + setError(''); + try { + const rows = await ruleManagementApi.restore(context.current); + onSaved(rows[0] as ManagedRule); + } catch (e) { + setError(e instanceof Error ? e.message : '恢复继承失败'); + } finally { + setBusy(false); + } + } + return ( + { + if (!busy) onBack(); + }} + footer={ + <> + + + + } + > +
+

+ 移除“{scopeTitle(rule)}”的{ruleSource(rule)}覆盖,历史版本保留。 +

+ {!context && !error &&

正在查询继承关系…

} + {context && ( + <> +

+ 该范围下层规则: + {context.inherited.length + ? context.inherited + .map((r) => `${ruleSource(r)} · ${scopeTitle(r)}(${r.config.enabled ? '启用' : '停用'})`) + .join(' → ') + : '没有可用规则,恢复后不告警'} + 。 +

+

更具体的规则仍按“应用+签名 > 签名 > 应用 > 通用”匹配。恢复操作下一评估周期生效。

+ + )} + {error && ( +
+ {error} + +
+ )} +
+
+ ); +} + +export function MonitorRuleManager({ onClose }: { onClose: () => void }) { + const [tab, setTab] = useState<'common' | 'custom'>('common'); + const [editor, setEditor] = useState(null), + [restore, setRestore] = useState(null); + const [keyword, setKeyword] = useState(''), + [kind, setKind] = useState(''), + [page, setPage] = useState(1), + [refresh, setRefresh] = useState(0); + const [items, setItems] = useState([]), + [total, setTotal] = useState(0), + [serverTime, setServerTime] = useState(''); + const [loading, setLoading] = useState(false), + [error, setError] = useState(''), + [notice, setNotice] = useState(''); + useEffect(() => { + if (tab !== 'custom' || editor || restore) return; + const abort = new AbortController(); + const timer = setTimeout(() => { + setLoading(true); + setError(''); + void ruleManagementApi + .list({ keyword, kind, page }, abort.signal) + .then((result) => { + if (abort.signal.aborted) return; + if (page > 1 && !result.items.length) { + setPage(Math.max(1, Math.ceil(result.total / 20))); + return; + } + setItems(result.items); + setTotal(result.total); + setServerTime(result.serverTime); + }) + .catch((e: Error) => { + if (!abort.signal.aborted) setError(e.message); + }) + .finally(() => { + if (!abort.signal.aborted) setLoading(false); + }); + }, 250); + return () => { + abort.abort(); + clearTimeout(timer); + }; + }, [tab, editor, restore, keyword, kind, page, refresh]); + function saved(rule: ManagedRule) { + setNotice(`已保存 v${rule.version},${time(rule.effectiveAt)} 生效`); + setEditor(null); + setRestore(null); + setRefresh((n) => n + 1); + } + if (restore) return setRestore(null)} onSaved={saved} />; + if (tab === 'common' || editor) + return ( + { + setTab('custom'); + setEditor(null); + }} + onSaved={(rule) => { + saved(rule); + setTab('custom'); + }} + /> + ); + const columns: TableColumn[] = [ + { + key: 'scope', + title: '适用范围', + width: '260px', + render: (r) => ( +
+ {scopeTitle(r)} +

{ruleSource(r)}

+
+ ), + }, + { + key: 'thresholds', + title: '阈值摘要', + width: '210px', + render: (r) => ( +
+

{r.config.minSamples} 条成熟样本

+

+ {r.config.thresholds + .map((n, i) => `${['1分', '5分', '20分'][i]} ${n === null ? '未启用' : `${n}%`}`) + .join(' / ')} +

+
+ ), + }, + { + key: 'status', + title: '状态与生效时间', + width: '220px', + render: (r) => { + const pending = new Date(r.effectiveAt) > new Date(serverTime), + active = pending ? r.active : r; + return ( + <> + + {!active || active.config.deleted ? '未生效覆盖' : active.config.enabled ? '告警启用' : '告警停用'} + +

+ {pending + ? `待生效 v${r.version} · ${r.config.deleted ? '恢复继承' : r.config.enabled ? '启用' : '停用'}` + : `v${r.version}`} +

+

{time(r.effectiveAt)}

+ + ); + }, + }, + { + key: 'actions', + title: '操作', + width: '165px', + render: (r) => ( +
+ + +
+ ), + }, + ]; + return ( + + 关闭 + + } + > +
+
+ + +
+ {notice && ( +

+ {notice} +

+ )} +
+ { + setKeyword(e.target.value); + setPage(1); + }} + /> +