From b0deef5e6ebe6adbfe85591c53dedf11866880a0 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Mon, 31 Aug 2026 12:19:39 +0800 Subject: [PATCH] fix: align reporting fields queries and disk monitoring --- .../dictionaries/dictionaries.controller.ts | 5 ++ .../dictionaries/dictionaries.service.spec.ts | 18 +++++++ api/src/dictionaries/dictionaries.service.ts | 38 +++++++++++++- ...rastructure-alert-settings.service.spec.ts | 4 ++ .../infrastructure-alert-settings.service.ts | 5 +- .../infrastructure-monitoring.contracts.ts | 11 ++++ .../infrastructure-monitoring.service.spec.ts | 24 +++++++++ .../infrastructure-monitoring.service.ts | 43 +++++++++++++--- .../sms-config/application-config.service.ts | 17 ++++++- api/src/sms-config/signature.service.ts | 6 +-- api/src/sms-config/sms-config.service.spec.ts | 51 +++++++++++++++++++ api/src/sms-config/template.service.ts | 2 +- docs/system-functional-test-cases.md | 15 ++++++ docs/testing-progress.md | 12 +++++ src/api/admin/governance.api.ts | 2 + src/api/types/infrastructure-monitoring.ts | 11 ++++ .../admin/AdminDrainageFieldsPage.test.tsx | 27 ++++++++++ src/apps/admin/AdminDrainageFieldsPage.tsx | 40 +++++++++++---- .../SignatureFormModal.tsx | 13 +++-- .../admin/sms-records/AdminSmsRecordsPage.css | 23 +++++++-- .../admin/sms-records/SendDetailModal.tsx | 5 +- .../admin/sms-records/SmsRecordFilter.tsx | 7 ++- src/apps/admin/sms-records/SmsRecordList.tsx | 15 +++--- .../SmsRecordPresentation.test.tsx | 39 ++++++++++++++ .../AdminSystemMonitoringPage.tsx | 31 +++++++---- src/apps/client/ClientBatchTasksPage.tsx | 31 ++++++++--- src/apps/client/ClientQueryPages.test.tsx | 43 ++++++++++++++++ src/apps/client/ClientSendDetailPage.tsx | 33 ++++++++---- src/apps/client/ClientSignaturesPage.test.tsx | 31 ++++++++++- src/apps/client/ClientSignaturesPage.tsx | 25 ++++++--- src/apps/client/ClientTemplatesPage.tsx | 7 ++- src/apps/client/ClientUplinkMessagesPage.tsx | 27 +++++++--- src/components/ui/QueryButtons.tsx | 10 ++++ src/components/ui/index.ts | 1 + src/styles/global.css | 2 +- tools/monitoring/cmpp-alerts.yml | 24 ++++----- tools/monitoring/cmpp-managed-alerts.yml | 8 +-- 37 files changed, 596 insertions(+), 110 deletions(-) create mode 100644 src/apps/admin/AdminDrainageFieldsPage.test.tsx create mode 100644 src/apps/admin/sms-records/SmsRecordPresentation.test.tsx create mode 100644 src/apps/client/ClientQueryPages.test.tsx create mode 100644 src/components/ui/QueryButtons.tsx diff --git a/api/src/dictionaries/dictionaries.controller.ts b/api/src/dictionaries/dictionaries.controller.ts index 522ca3f..cc0db3f 100644 --- a/api/src/dictionaries/dictionaries.controller.ts +++ b/api/src/dictionaries/dictionaries.controller.ts @@ -172,4 +172,9 @@ export class DictionariesController { deleteCommonReportField(@Param('id') id: string) { return this.dictionaries.deleteCommonReportField(id); } + + @Put('common-report-fields/:id') + updateCommonReportField(@Param('id') id: string, @Body() body: CreateCommonReportFieldDto, @CurrentSessionUserId() operatorId?: string) { + return this.dictionaries.updateCommonReportField(id, body, operatorId); + } } diff --git a/api/src/dictionaries/dictionaries.service.spec.ts b/api/src/dictionaries/dictionaries.service.spec.ts index 7624663..e1e0bd2 100644 --- a/api/src/dictionaries/dictionaries.service.spec.ts +++ b/api/src/dictionaries/dictionaries.service.spec.ts @@ -58,6 +58,24 @@ function createPrismaMock() { } describe('DictionariesService', () => { + it('edits common configuration in place with an audit trail and rejects duplicate or inactive fields', async () => { + const prisma = createPrismaMock(); + const existing = { id: 'common-1', drainageFieldId: 'field-1', reportType: 'signature', required: false }; + prisma.commonReportField.findUnique.mockImplementation(({ where }: { where: { id?: string } }) => Promise.resolve(where.id ? existing : null) as never); + prisma.drainageField.findUnique.mockResolvedValue({ id: 'field-2', status: 'active' } as never); + const tx = { commonReportField: { update: jest.fn().mockResolvedValue({ ...existing, required: true }) }, operationLog: { create: jest.fn() } }; + prisma.$transaction.mockImplementation((callback) => callback(tx)); + const service = new DictionariesService(prisma as never); + const body = { drainageFieldId: 'field-2', reportType: 'drainage' as const, required: true }; + await service.updateCommonReportField('common-1', body, 'admin-1'); + expect(tx.commonReportField.update).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'common-1' }, data: { ...body, sortOrder: undefined } })); + expect(tx.operationLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: 'common_report_field.update', userId: 'admin-1' }) })); + prisma.commonReportField.findUnique.mockResolvedValue({ id: 'other' } as never); + await expect(service.updateCommonReportField('common-1', body)).rejects.toThrow('已配置'); + prisma.drainageField.findUnique.mockResolvedValue({ id: 'field-2', status: 'inactive' } as never); + await expect(service.updateCommonReportField('common-1', body)).rejects.toThrow('已停用'); + await expect(service.updateCommonReportField('common-1', { ...body, required: 'false' as never })).rejects.toThrow('无效'); + }); it('builds the enterprise province and city library from distinct real phone segment regions', async () => { const prisma = createPrismaMock(); prisma.phoneSegment.findMany.mockResolvedValue([ diff --git a/api/src/dictionaries/dictionaries.service.ts b/api/src/dictionaries/dictionaries.service.ts index c9fad85..203b84b 100644 --- a/api/src/dictionaries/dictionaries.service.ts +++ b/api/src/dictionaries/dictionaries.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, ConflictException, Injectable, Optional } from '@nestjs/common'; +import { BadRequestException, ConflictException, Injectable, NotFoundException, Optional } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { PhoneRoutingLookupService } from './phone-routing-lookup.service'; @@ -545,6 +545,42 @@ export class DictionariesService { return this.prisma.commonReportField.delete({ where: { id } }); } + async updateCommonReportField(id: string, data: CreateCommonReportFieldDto, operatorId?: string) { + if (!['signature', 'drainage'].includes(data.reportType) || typeof data.required !== 'boolean') { + throw new BadRequestException('资料用途或是否必填无效'); + } + if (data.sortOrder !== undefined && !Number.isInteger(data.sortOrder)) { + throw new BadRequestException('排序值必须为整数'); + } + const existing = await this.prisma.commonReportField.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException('通用字段配置不存在'); + const field = await this.prisma.drainageField.findUnique({ where: { id: data.drainageFieldId } }); + if (!field || field.status !== 'active') throw new BadRequestException('报备字段库字段不存在或已停用'); + const duplicate = await this.prisma.commonReportField.findUnique({ + where: { drainageFieldId_reportType: { drainageFieldId: field.id, reportType: data.reportType } }, + }); + if (duplicate && duplicate.id !== id) throw new ConflictException('该字段已配置为对应类型的通用字段'); + try { + return await this.prisma.$transaction(async (tx) => { + const updated = await tx.commonReportField.update({ + where: { id }, + data: { drainageFieldId: field.id, reportType: data.reportType, required: data.required, sortOrder: data.sortOrder }, + include: { drainageField: true }, + }); + await tx.operationLog.create({ data: { + userId: operatorId, action: 'common_report_field.update', resource: 'common_report_field', resourceId: id, + detail: { before: { drainageFieldId: existing.drainageFieldId, reportType: existing.reportType, required: existing.required }, after: { drainageFieldId: field.id, reportType: data.reportType, required: data.required } }, + } }); + return updated; + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { + throw new ConflictException('该字段已配置为对应类型的通用字段'); + } + throw error; + } + } + private writeOperationLog(userId: string | undefined, action: string, resource: string, resourceId: string, detail: Record) { return this.prisma.operationLog.create({ data: { diff --git a/api/src/infrastructure-monitoring/infrastructure-alert-settings.service.spec.ts b/api/src/infrastructure-monitoring/infrastructure-alert-settings.service.spec.ts index bf70dd1..c027b84 100644 --- a/api/src/infrastructure-monitoring/infrastructure-alert-settings.service.spec.ts +++ b/api/src/infrastructure-monitoring/infrastructure-alert-settings.service.spec.ts @@ -12,6 +12,10 @@ describe('InfrastructureAlertSettingsService', () => { expect(rules).toContain('threshold: "120秒"'); expect(rules).toContain('redis_memory_max_bytes > 0'); expect(rules).toContain('sum(increase(cmpp_api_http_requests_total'); + expect(rules).not.toContain('mountpoint="/"'); + expect(rules).toContain('device=~"/dev/.+"'); + expect(rules).toContain('{{ $labels.mountpoint }}'); + expect(rules).toContain('{{ $labels.device }}'); }); it('rejects unknown keys and warning thresholds that are not below critical', () => { diff --git a/api/src/infrastructure-monitoring/infrastructure-alert-settings.service.ts b/api/src/infrastructure-monitoring/infrastructure-alert-settings.service.ts index 4601666..f3ff23a 100644 --- a/api/src/infrastructure-monitoring/infrastructure-alert-settings.service.ts +++ b/api/src/infrastructure-monitoring/infrastructure-alert-settings.service.ts @@ -13,7 +13,7 @@ const execFileAsync = promisify(execFile); export const ALERT_THRESHOLD_DEFINITIONS = [ { key: 'hostCpu', label: '主机 CPU 使用率', unit: '%', min: 1, max: 100, step: 1, warning: 80, critical: 90, expr: '100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)', names: ['HostCpuUsageWarning', 'HostCpuUsageCritical'], service: 'host', durations: ['10m', '5m'] }, { key: 'hostMemory', label: '主机内存使用率', unit: '%', min: 1, max: 100, step: 1, warning: 85, critical: 95, expr: '(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100', names: ['HostMemoryUsageWarning', 'HostMemoryUsageCritical'], service: 'host', durations: ['10m', '5m'] }, - { key: 'hostDisk', label: '根磁盘使用率', unit: '%', min: 1, max: 100, step: 1, warning: 80, critical: 90, expr: '(1 - node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"} / node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"}) * 100', names: ['HostRootDiskUsageWarning', 'HostRootDiskUsageCritical'], service: 'host', durations: ['15m', '5m'] }, + { key: 'hostDisk', label: '磁盘(所有挂载点)使用率', unit: '%', min: 1, max: 100, step: 1, warning: 80, critical: 90, expr: '(1 - node_filesystem_avail_bytes{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"} / node_filesystem_size_bytes{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"}) * 100', names: ['HostRootDiskUsageWarning', 'HostRootDiskUsageCritical'], service: 'host', durations: ['15m', '5m'] }, { key: 'apiError', label: 'API 5xx 错误率', unit: '%', min: 0.1, max: 100, step: 0.1, warning: 1, critical: 5, expr: '100 * sum(rate(cmpp_api_http_requests_total{status=~"5.."}[5m])) / clamp_min(sum(rate(cmpp_api_http_requests_total[5m])), 0.001)', guard: 'sum(increase(cmpp_api_http_requests_total{status=~"5.."}[5m])) >= 5', names: ['CmppApiHttpErrorRateWarning', 'CmppApiHttpErrorRateCritical'], service: 'api', durations: ['5m', '5m'] }, { key: 'apiLatency', label: 'API P95 响应时间', unit: '秒', min: 0.1, max: 60, step: 0.1, warning: 1, critical: 3, expr: 'histogram_quantile(0.95, sum by (le) (rate(cmpp_api_http_request_duration_seconds_bucket[10m])))', names: ['CmppApiLatencyWarning', 'CmppApiLatencyCritical'], service: 'api', durations: ['10m', '5m'] }, { key: 'apiEventLoop', label: 'API 事件循环 P99', unit: '秒', min: 0.01, max: 10, step: 0.01, warning: 0.2, critical: 1, expr: 'cmpp_api_nodejs_event_loop_lag_p99_seconds', names: ['CmppApiEventLoopLagWarning', 'CmppApiEventLoopLagCritical'], service: 'api', durations: ['10m', '5m'] }, @@ -113,7 +113,8 @@ export class InfrastructureAlertSettingsService { // 低样本量与“未配置容量上限”必须继续作为固定保护条件,避免单次错误或除零结果触发伪告警。 const guard = 'guard' in definition ? ` and (${definition.guard})` : ''; const expr = isWarning ? `(${definition.expr} > ${values[0]}) and (${definition.expr} <= ${values[1]})${guard}` : `(${definition.expr} > ${values[1]})${guard}`; - lines.push(` - alert: ${definition.names[index]}`, ` expr: ${expr}`, ` for: ${definition.durations[index]}`, ' labels:', ` severity: ${isWarning ? 'warning' : 'critical'}`, ` service: ${definition.service}`, ' annotations:', ` summary: "${definition.label}${isWarning ? '达到警告阈值' : '达到严重阈值'}"`, ` description: "${definition.label}持续超过${values[index]}${definition.unit}。"`, ' currentValue: "{{ $value }}"', ` threshold: "${values[index]}${definition.unit}"`); + const diskLocation = definition.key === 'hostDisk' ? ' 挂载点:{{ $labels.mountpoint }};设备:{{ $labels.device }}。' : ''; + lines.push(` - alert: ${definition.names[index]}`, ` expr: ${expr}`, ` for: ${definition.durations[index]}`, ' labels:', ` severity: ${isWarning ? 'warning' : 'critical'}`, ` service: ${definition.service}`, ' annotations:', ` summary: "${definition.label}${isWarning ? '达到警告阈值' : '达到严重阈值'}"`, ` description: "${definition.label}持续超过${values[index]}${definition.unit}。${diskLocation}"`, ' currentValue: "{{ $value }}"', ` threshold: "${values[index]}${definition.unit}"`); } } return `${lines.join('\n')}\n`; diff --git a/api/src/infrastructure-monitoring/infrastructure-monitoring.contracts.ts b/api/src/infrastructure-monitoring/infrastructure-monitoring.contracts.ts index 5be3287..56a15d3 100644 --- a/api/src/infrastructure-monitoring/infrastructure-monitoring.contracts.ts +++ b/api/src/infrastructure-monitoring/infrastructure-monitoring.contracts.ts @@ -41,6 +41,17 @@ export type InfrastructureAlert = { }; export type InfrastructureMonitoringOverview = { + disks: Array<{ + id: string; + instance: string; + device: string; + mountpoint: string; + filesystem: string; + usagePercent: number | null; + totalBytes: number | null; + availableBytes: number | null; + trend: InfrastructureMetricPoint[]; + }>; available: boolean; range: InfrastructureMonitoringRange; collectedAt: string; diff --git a/api/src/infrastructure-monitoring/infrastructure-monitoring.service.spec.ts b/api/src/infrastructure-monitoring/infrastructure-monitoring.service.spec.ts index bbd4508..7932095 100644 --- a/api/src/infrastructure-monitoring/infrastructure-monitoring.service.spec.ts +++ b/api/src/infrastructure-monitoring/infrastructure-monitoring.service.spec.ts @@ -108,6 +108,30 @@ describe('InfrastructureMonitoringService', () => { expect(result.trends.cpuUsagePercent).toEqual([]); expect(result.serviceMetrics.every((item) => item.available === false)).toBe(true); expect(result.error).not.toContain('ECONNREFUSED'); + expect(result.disks).toEqual([]); + }); + + it('keeps system, data and additional disks distinct regardless of Prometheus series order', async () => { + const metrics = [ + { instance: 'host:9100', device: '/dev/sdb1', mountpoint: '/data', fstype: 'ext4' }, + { instance: 'host:9100', device: '/dev/sda2', mountpoint: '/', fstype: 'ext4' }, + { instance: 'host:9100', device: '/dev/nvme1n1p1', mountpoint: '/archive', fstype: 'xfs' }, + ]; + jest.spyOn(global, 'fetch').mockImplementation(async (input) => { + const url = new URL(String(input)); + const query = url.searchParams.get('query') ?? ''; + if (url.pathname.endsWith('/alerts')) return success({ alerts: [] }); + if (!query.includes('node_filesystem_')) return success({ result: [] }); + expect(query).not.toContain('mountpoint="/"'); + if (url.pathname.endsWith('/query_range')) return success({ result: [...metrics].reverse().map((metric) => ({ metric, values: [[1765000060, metric.mountpoint === '/' ? '91' : '12']] })) }); + return success({ result: metrics.map((metric) => ({ metric, value: [1765000060, query.startsWith('(1') ? (metric.mountpoint === '/' ? '91' : '12') : query.includes('avail') ? '9' : '100'] })) }); + }); + const result = await new InfrastructureMonitoringService(new ConfigService(), prisma as never).overview('1h'); + expect(result.disks.map((disk) => disk.mountpoint)).toEqual(['/', '/archive', '/data']); + expect(result.disks[0]).toMatchObject({ usagePercent: 91, totalBytes: 100, availableBytes: 9, trend: [{ timestamp: new Date(1765000060000).toISOString(), value: 91 }] }); + expect(result.disks[2].trend[0].value).toBe(12); + expect(result.metrics.diskUsagePercent).toBe(91); + expect(result.trends.diskUsagePercent[0].value).toBe(91); }); it('excludes only the current alert occurrence after the current administrator marks it read', async () => { diff --git a/api/src/infrastructure-monitoring/infrastructure-monitoring.service.ts b/api/src/infrastructure-monitoring/infrastructure-monitoring.service.ts index 02daabe..d82e1ed 100644 --- a/api/src/infrastructure-monitoring/infrastructure-monitoring.service.ts +++ b/api/src/infrastructure-monitoring/infrastructure-monitoring.service.ts @@ -47,9 +47,9 @@ const QUERIES = { memoryUsagePercent: '(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100', memoryTotalBytes: 'node_memory_MemTotal_bytes', memoryAvailableBytes: 'node_memory_MemAvailable_bytes', - diskUsagePercent: '(1 - (node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"} / node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"})) * 100', - diskTotalBytes: 'node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"}', - diskAvailableBytes: 'node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"}', + diskUsagePercent: '(1 - (node_filesystem_avail_bytes{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"} / node_filesystem_size_bytes{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"})) * 100', + diskTotalBytes: 'node_filesystem_size_bytes{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"}', + diskAvailableBytes: 'node_filesystem_avail_bytes{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"}', networkReceiveBytesPerSecond: 'sum(rate(node_network_receive_bytes_total{device!~"lo"}[5m]))', networkTransmitBytesPerSecond: 'sum(rate(node_network_transmit_bytes_total{device!~"lo"}[5m]))', load1: 'node_load1', @@ -132,6 +132,15 @@ function matrixValues(response: PrometheusQueryResponse): InfrastructureMetricPo }); } +function diskIdentity(metric: Record) { + return JSON.stringify([metric.instance ?? '', metric.device ?? '', metric.mountpoint ?? '', metric.fstype ?? '']); +} + +// Preserve the legacy scalar fields as root-only; never silently pick the first disk. +function rootSeries(response: PrometheusQueryResponse): PrometheusQueryResponse { + return { ...response, data: { result: (response.data?.result ?? []).filter((item) => item.metric.mountpoint === '/') } }; +} + function emptyMetrics(): InfrastructureMonitoringOverview['metrics'] { return { cpuUsagePercent: null, @@ -200,7 +209,8 @@ export class InfrastructureMonitoringService { activeAlerts: alerts.length, }, metrics: instant.metrics, - trends, + trends: trends.metrics, + disks: instant.disks.map((disk) => ({ ...disk, trend: trends.disks.get(disk.id) ?? [] })), services, serviceMetrics, alerts, @@ -263,8 +273,21 @@ export class InfrastructureMonitoringService { const keys = Object.keys(emptyMetrics()) as Array; const responses = await Promise.all([...keys.map((key) => this.query(QUERIES[key])), this.query(QUERIES.lastSampleAt)]); const metrics = emptyMetrics(); - keys.forEach((key, index) => { metrics[key] = vectorValue(responses[index]); }); - return { metrics, lastSampleAt: vectorValue(responses[responses.length - 1]) }; + keys.forEach((key, index) => { metrics[key] = vectorValue(key.startsWith('disk') ? rootSeries(responses[index]) : responses[index]); }); + const diskSamples = (key: keyof typeof metrics) => responses[keys.indexOf(key)].data?.result ?? []; + const usage = new Map(diskSamples('diskUsagePercent').map((item) => [diskIdentity(item.metric), finiteNumber(item.value?.[1])])); + const available = new Map(diskSamples('diskAvailableBytes').map((item) => [diskIdentity(item.metric), finiteNumber(item.value?.[1])])); + const disks = diskSamples('diskTotalBytes') + .filter((item) => item.metric.mountpoint && (finiteNumber(item.value?.[1]) ?? 0) > 0) + .map((item) => ({ + id: diskIdentity(item.metric), instance: item.metric.instance ?? '', device: item.metric.device ?? '', + mountpoint: item.metric.mountpoint, filesystem: item.metric.fstype ?? '', + totalBytes: finiteNumber(item.value?.[1]), + availableBytes: available.get(diskIdentity(item.metric)) ?? null, + usagePercent: usage.get(diskIdentity(item.metric)) ?? null, + })) + .sort((left, right) => left.instance.localeCompare(right.instance) || (left.mountpoint === '/' ? -1 : right.mountpoint === '/' ? 1 : left.mountpoint.localeCompare(right.mountpoint))); + return { metrics, disks, lastSampleAt: vectorValue(responses[responses.length - 1]) }; } private async loadTrends(range: InfrastructureMonitoringRange) { @@ -273,7 +296,12 @@ export class InfrastructureMonitoringService { const start = end - config.seconds; const keys = Object.keys(emptyTrends()) as Array; const responses = await Promise.all(keys.map((key) => this.queryRange(QUERIES[key], start, end, config.step))); - return Object.fromEntries(keys.map((key, index) => [key, matrixValues(responses[index])])) as InfrastructureMonitoringOverview['trends']; + return { + metrics: Object.fromEntries(keys.map((key, index) => [key, matrixValues(key === 'diskUsagePercent' ? rootSeries(responses[index]) : responses[index])])) as InfrastructureMonitoringOverview['trends'], + disks: new Map((responses[keys.indexOf('diskUsagePercent')].data?.result ?? []).map((item) => [ + diskIdentity(item.metric), matrixValues({ status: 'success', data: { result: [item] } }), + ])), + }; } private parseServices(response: PrometheusQueryResponse): InfrastructureServiceStatus[] { @@ -356,6 +384,7 @@ export class InfrastructureMonitoringService { error: 'Prometheus监控数据当前不可用,请检查采集与服务状态', summary: { overallStatus: 'unknown', serviceTotal: services.length, serviceHealthy: 0, warningAlerts: 0, criticalAlerts: 0, activeAlerts: 0 }, metrics: emptyMetrics(), + disks: [], trends: emptyTrends(), services, serviceMetrics: SERVICE_METRIC_DEFINITIONS.map((group) => ({ key: group.key, name: group.name, available: false, metrics: [] })), diff --git a/api/src/sms-config/application-config.service.ts b/api/src/sms-config/application-config.service.ts index 7c788fd..557c498 100644 --- a/api/src/sms-config/application-config.service.ts +++ b/api/src/sms-config/application-config.service.ts @@ -167,8 +167,9 @@ export class SmsApplicationConfigService { const merged = new Map(); const routeChannels = new Map(); for (const route of routes) { - if (!route.group) continue; + if (!route.group || route.group.status === 'deleted') continue; for (const item of route.group.items) { + if (item.channel.status === 'deleted') continue; if (!routeChannels.has(item.channel.id)) { routeChannels.set(item.channel.id, { id: item.channel.id, @@ -181,6 +182,17 @@ export class SmsApplicationConfigService { } } for (const configured of commonFields) { + const existing = merged.get(configured.drainageField.id); + if (existing) { + existing.required ||= configured.required; + if (!existing.reportTypes.includes(configured.reportType)) existing.reportTypes.push(configured.reportType); + if (!existing.commonReportTypes.includes(configured.reportType)) existing.commonReportTypes.push(configured.reportType); + for (const channel of existing.channels) { + channel.required ||= configured.required; + if (channel.reportType !== configured.reportType) channel.reportType = 'both'; + } + continue; + } merged.set(configured.drainageField.id, { id: configured.drainageField.id, code: configured.drainageField.code, @@ -199,8 +211,9 @@ export class SmsApplicationConfigService { }); } for (const route of routes) { - if (!route.group) continue; + if (!route.group || route.group.status === 'deleted') continue; for (const item of route.group.items) { + if (item.channel.status === 'deleted') continue; for (const configured of item.channel.reportFields) { if (configured.status !== 'active' || !configured.drainageField || configured.drainageField.status !== 'active') continue; if (reportType && configured.reportType !== 'both' && configured.reportType !== reportType) continue; diff --git a/api/src/sms-config/signature.service.ts b/api/src/sms-config/signature.service.ts index 14b6e48..c8a498d 100644 --- a/api/src/sms-config/signature.service.ts +++ b/api/src/sms-config/signature.service.ts @@ -181,7 +181,7 @@ export class SmsSignatureService { id: signatureId, tenantId, applicationId: query.applicationId, - auditStatus: query.status || { notIn: ['deleted', 'disabled'] }, + auditStatus: { notIn: ['deleted', 'disabled'], ...(query.status && query.status !== 'all' ? { equals: query.status } : {}) }, OR: query.keyword?.trim() ? [ { name: { contains: query.keyword.trim() } }, { purpose: { contains: query.keyword.trim() } }, @@ -305,7 +305,7 @@ export class SmsSignatureService { const filteredWhere: Prisma.SmsSignatureWhereInput = { tenantId, applicationId: query.applicationId, - auditStatus: query.status || { notIn: ['deleted', 'disabled'] }, + auditStatus: { notIn: ['deleted', 'disabled'], ...(query.status && query.status !== 'all' ? { equals: query.status } : {}) }, OR: query.keyword?.trim() ? [ { name: { contains: query.keyword.trim() } }, { purpose: { contains: query.keyword.trim() } }, @@ -427,7 +427,7 @@ export class SmsSignatureService { async submitSignature(signatureId: string, tenantId?: string) { const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); - if (!signature || (tenantId && signature.tenantId !== tenantId)) { + if (!signature || signature.auditStatus === 'deleted' || (tenantId && signature.tenantId !== tenantId)) { throw new NotFoundException('Signature not found'); } diff --git a/api/src/sms-config/sms-config.service.spec.ts b/api/src/sms-config/sms-config.service.spec.ts index 542fe1d..6e8dd9e 100644 --- a/api/src/sms-config/sms-config.service.spec.ts +++ b/api/src/sms-config/sms-config.service.spec.ts @@ -114,6 +114,7 @@ function createPrismaMock() { create: jest.fn().mockResolvedValue({ id: 'drainage-record-1' }), }, smsTemplate: { + count: jest.fn().mockResolvedValue(0), findMany: jest.fn().mockResolvedValue([{ id: 'tpl-1', tenantId: 'tenant-1', @@ -1024,6 +1025,56 @@ describe('SmsConfigService', () => { expect(prisma.channelSignatureReportTask.create).not.toHaveBeenCalled(); }); + it('never lets client status filters bypass deleted signature exclusion and keeps template history filtered', async () => { + const prisma = createPrismaMock(); + prisma.smsSignature.findMany.mockResolvedValue([]); + prisma.smsSignature.groupBy.mockResolvedValue([] as never); + prisma.smsTemplate.findMany.mockResolvedValue([]); + const service = new SmsConfigService(prisma as never); + for (const status of ['deleted', 'all', 'approved']) { + await service.getClientSignatureWorkspace('tenant-1', { status }); + const filter = { notIn: ['deleted', 'disabled'], ...(status !== 'all' ? { equals: status } : {}) }; + expect(prisma.smsSignature.findMany).toHaveBeenLastCalledWith(expect.objectContaining({ where: expect.objectContaining({ tenantId: 'tenant-1', auditStatus: filter }) })); + expect(prisma.smsSignature.count).toHaveBeenLastCalledWith(expect.objectContaining({ where: expect.objectContaining({ auditStatus: filter }) })); + } + await service.listClientTemplates('tenant-1', true); + expect(prisma.smsTemplate.findMany).toHaveBeenLastCalledWith(expect.objectContaining({ where: expect.objectContaining({ tenantId: 'tenant-1', auditStatus: { not: 'deleted' } }) })); + await service.listTemplatesPage({ tenantId: 'tenant-1', status: 'all', page: 1, pageSize: 10 }); + expect(prisma.smsTemplate.count).toHaveBeenLastCalledWith(expect.objectContaining({ where: expect.objectContaining({ auditStatus: { not: 'deleted' } }) })); + prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-deleted', tenantId: 'tenant-1', auditStatus: 'deleted' } as never); + prisma.smsTemplate.findUnique.mockResolvedValue({ id: 'tpl-deleted', tenantId: 'tenant-1', auditStatus: 'deleted' } as never); + await expect(service.submitSignature('sig-deleted', 'tenant-1')).rejects.toThrow('not found'); + await expect(service.submitTemplate('tpl-deleted', 'tenant-1')).rejects.toThrow('not found'); + }); + + it('uses exactly the same editable common fields in client and admin signature forms', async () => { + const prisma = createPrismaMock(); + const field = { id: 'field-common', code: 'license', name: '主体证明', fieldType: 'file', description: '最新配置', status: 'active' }; + prisma.commonReportField.findMany.mockResolvedValue([{ id: 'common-1', reportType: 'signature', required: true, drainageField: field }]); + const service = new SmsConfigService(prisma as never); + for (const applicationId of [undefined, 'app-1']) { + const admin = await service.getApplicationReportFields(applicationId, 'signature'); + const client = await service.getClientApplicationReportFields(applicationId, 'signature'); + expect(client).toEqual(admin.map((item) => Object.fromEntries(Object.entries(item).filter(([key]) => !['channels', 'commonReportTypes'].includes(key))))); + expect(client[0]).toMatchObject({ code: 'license', name: '主体证明', fieldType: 'file', required: true, description: '最新配置' }); + } + prisma.commonReportField.findMany.mockResolvedValue([{ id: 'common-1', reportType: 'signature', required: false, drainageField: field }]); + expect((await service.getClientApplicationReportFields(undefined, 'signature'))[0].required).toBe(false); + }); + + it('keeps both common field purposes in material snapshots and ignores deleted channel requirements', async () => { + const prisma = createPrismaMock(); + const field = { id: 'field-1', code: 'license', name: '主体证明', fieldType: 'file', status: 'active' }; + prisma.commonReportField.findMany.mockResolvedValue([ + { reportType: 'signature', required: true, drainageField: field }, + { reportType: 'drainage', required: false, drainageField: field }, + ]); + prisma.channelRouteRule.findMany.mockResolvedValue([{ group: { id: 'group-1', items: [{ channel: { id: 'deleted-channel', status: 'deleted', reportFields: [{ status: 'active', reportType: 'signature', required: true, drainageField: { ...field, id: 'old', code: 'old' } }] } }] } }] as never); + const result = await new SmsConfigService(prisma as never).getApplicationReportFields('app-1'); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ required: true, reportTypes: ['signature', 'drainage'], commonReportTypes: ['signature', 'drainage'], channels: [] }); + }); + it('accepts a scheme-less drainage URL and synchronizes the compatibility name', async () => { const prisma = createPrismaMock(); prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'approved' }); diff --git a/api/src/sms-config/template.service.ts b/api/src/sms-config/template.service.ts index 55249cc..783e281 100644 --- a/api/src/sms-config/template.service.ts +++ b/api/src/sms-config/template.service.ts @@ -176,7 +176,7 @@ export class SmsTemplateService { async submitTemplate(templateId: string, tenantId?: string) { const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); - if (!template || (tenantId && template.tenantId !== tenantId)) { + if (!template || template.auditStatus === 'deleted' || (tenantId && template.tenantId !== tenantId)) { throw new NotFoundException('Template not found'); } await this.validateTemplateSignature(template.signatureId, template.tenantId, template.applicationId, template.content); diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index dedbcb4..c75bc07 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -4965,3 +4965,18 @@ npm run verify:phase8 | TC-DRAINAGE-UI-005 | 运营端查看单条审核列表、详情、报备任务及报备记录 | 统一显示“引流 URL 或号码”及真实目标值,不再显示独立站点名称或旧“引流地址”标签;审核通过/驳回仍调用原真实 API | | TC-DRAINAGE-UI-006 | 下载引流官方导入模板并配置导入映射 | 模板和映射仅要求“所属短信签名”“引流 URL 或号码”,不再要求“站点名称”;导入项进入真实审核批次 | | TC-DRAINAGE-UI-007 | 桌面和窄屏查看三网状态组及引流列表 | 状态单元不相互覆盖,文字不截断为不可辨认内容;窄屏沿用受控列表滚动,不产生页面级横向溢出 | + +## TC-PORTAL-20260831 六项运营/客户端修复 + +| 用例ID | 场景 | 预期 | +| --- | --- | --- | +| TC-PORTAL-001 | 修改通用字段引用、资料用途、必填属性并刷新 | PUT保存真实配置,ID不变,新增操作日志;重复组合、已停用字段及非法必填值被拒绝;历史报备快照不变 | +| TC-PORTAL-002 | 比较同应用运营与客户端新增签名资料,无应用时比较通用资料 | 字段代码/名称/类型/必填/说明一致;通用+通道要求去重合并;已删除通道不提供字段;同字段双用途不覆盖快照 | +| TC-PORTAL-003 | 快速切换应用,旧字段请求最后返回;字段请求失败 | 仅展示当前应用资料;加载中或失败禁止提交,不把失败当作无需报备 | +| TC-PORTAL-004 | 删除签名/模板后刷新列表和选择器,构造status=deleted及includeHistory=true | 客户端列表/统计排除已删除对象;旧列表响应不能覆盖删除后刷新;已删除对象不可再次提交审核;历史发送记录不受影响 | +| TC-PORTAL-005 | 含引流、不含引流、未检测三类历史记录 | 列表仅含引流记录在发送状态下显示标签,无负向标签;详情明确三种状态,按真实后端位置高亮URL/号码 | +| TC-PORTAL-006 | 查看最终已回执及未回执短信 | 列表无回执时间列;详情“最终回执时间”来自消息最终结果,无值显示“-”;各通道回执时间仍保留 | +| TC-PORTAL-007 | 三个客户端查询页修改输入、日期、下拉后等待并翻页;查询/重置 | 输入不发搜索请求;翻页保持已应用条件;查询/重置回到第一页,只发一次新查询;上行返回第一页重新加载 | +| TC-PORTAL-008 | Prometheus同时返回系统盘、数据盘、第三块磁盘,顺序打乱或有缺失点 | 全部挂载点各有容量卡片及独立趋势;按设备/挂载点匹配,不串盘,不以0填缺失点;采集失败清空指标 | +| TC-PORTAL-009 | 系统盘或任一数据盘分别超过容量阈值 | 使用原有效阈值逐盘告警,信息含挂载点与设备;基础和托管规则无同名重复;tmpfs/overlay等虚拟盘不参与 | +| TC-PORTAL-010 | 测试发布与安全边界 | 新独立恢复资产的custom dump、运行tar、配置tar、原标记和SHA全部验证后才能发布;查服务、health、Stream、窗口日志与资源哈希;不发/补发/重投短信,不修改客户/余额/通道配置 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index d7c7ff1..1f6dfc5 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -4181,3 +4181,15 @@ git diff --check - 修复发布后9670条Stream积压一次性完成持久化和ACK/XDEL,`gateway.protocol.logs`最终`长度=0/pending=0/lag=0`。失败窗口数据库中24340行对应9670个唯一eventId,精确识别14670条重复行;在第二份数据库/Redis恢复点保护下,只删除`2026-08-30 13:37:00`后同一eventId的第2条及以后记录,保留最早一条,最终为9670行/9670个唯一eventId,未触及短信、账务及其他日志。 - 最终`.deployed-commit=1a5063a7c635280b912021c42c33a07c99f4c5dd`,95/95项migration;API、Send Worker、Submit Outbox、Gateway Callback、Protocol Log Worker、Gateway、Security Agent、MinIO、Nginx、PostgreSQL和Redis共11项均active,三项拆分服务均enabled。API、Callback、Gateway健康,9464/9465/9467/9468指标端点可用,Callback池`max=12,total=1,idle=1,waiting=0`,供应商连接9/9,数据库活动连接15/100,Submit Outbox为0;命令、结果、协议日志三条Stream均`pending=0/lag=0`,修复发布窗口8项服务error级journal均为空。 - 首页、客户端登录、运营端登录及API健康从工作站直连均HTTP 200;实际资源仍为`index-C_Eutz1B.js`和`index-BB9q6lcg.css`,工作站与服务器SHA-256一致。Chrome只读导航该私网HTTP页面再次超时,因此不声称完成浏览器DOM/控制台验收。全程未发送、补发或重投短信,未修改余额、通道、客户或签名/引流业务配置。 + +## 2026-08-31 六项运营/客户端修复(测试环境发布准备) + +- 本轮仅授权测试环境 `100.93.204.60`,不访问、不部署预生产。开始时核验 main/HEAD `119d577` 与测试部署标记 `5328bb09bf89170b4368407e896dbce9527a7b5c`;原有4份已修改文档及3项未跟踪文件完整保留,不纳入本轮提交。 +- 通用字段增加原位修改入口和 PUT API,可修改引用字段、资料用途和必填要求;服务端检查有效字段/重复组合,事务保存配置与操作日志,不改历史资料快照。两端签名表单使用同一后端字段合并结果,切换应用丢弃旧请求,加载失败禁止提交;修复同字段多资料用途快照覆盖及已删除通道残留资料要求。 +- 客户端签名列表与统计的删除排除条件不再被 status 参数覆盖;签名/模板已删除对象禁止再次提交。模板列表原有后台删除过滤核验并补回归;两页刷新拒绝旧请求覆盖,防止删除后被旧响应重新显示。测试机基线真实 PostgreSQL:2租户、26签名、11模板,当前无 deleted 样本;18应用加通用配置共19组签名字段与运营端投影完全一致,未用此基线冒充有删除样本的实测。 +- 发送记录列表只在发送状态下显示正向“含引流”标签,去掉回执时间列;详情显示含/不含引流(未检测历史记录明确标识),按真实检测位置高亮内容,新增“最终回执时间”,保留各通道路由回执信息。 +- 客户端批量任务、发送详情、上行短信复用运营端查询/重置组件;输入条件与已应用条件分离,输入不发请求,查询/重置回第一页,分页仅使用上次确认条件,修复上行从第二页返回第一页不加载的问题。 +- 监控磁盘查询从根目录限制改为全部实际块设备文件系统,按 instance/device/mountpoint/fstype 对齐容量和趋势;系统盘、数据盘及其他挂载点独立卡片/趋势,缺点不填假值。容量和inode规则覆盖所有磁盘并注明设备/挂载点,保留原告警名及阈值兼容性。 +- 验证:API全量51套/581项、前端8文件/45项通过;前后端 TypeScript、Vite构建、依赖安全、部署契约与包体积检查通过。新增独立页面/接口/监控文件定向 ESLint 通过;修改范围整体 ESLint 仍有既存领域拆分未用导入及页面Hook规则问题,不宣称全仓lint通过。自动化测试中的隔离stub仅用于回归,产品仍调用真实API。 +- 发布前观察:三条 Redis Stream pending/lag 均0;测试机仅有 `/dev/sda2` 挂载 `/`,Prometheus采集容量105086115840字节;Security Agent既有 `/run/cmpp-security-agent` 缺失导致226/NAMESPACE重启,已记录。Tailscale链路曾短暂中断,恢复后HTTP健康200。浏览器自动化多次读取/导航超时,尚未完成真实页面和控制台验收,不以DOM单测或构建替代。 +- 此节为发布准备记录:独立恢复资产正在建立;未通过 pg_restore --list、tar可读性及SHA-256前不部署。最终发布标记、恢复点、服务/健康/资源哈希与验收边界在后续发布记录补充。 diff --git a/src/api/admin/governance.api.ts b/src/api/admin/governance.api.ts index 37cfc83..846a669 100644 --- a/src/api/admin/governance.api.ts +++ b/src/api/admin/governance.api.ts @@ -206,4 +206,6 @@ export const adminGovernanceApi = { createCommonReportField: (body: { drainageFieldId: string; reportType: 'signature' | 'drainage'; required: boolean; sortOrder?: number }) => request('/admin/dictionaries/common-report-fields', { method: 'POST', body: JSON.stringify(body) }), deleteCommonReportField: (id: string) => request(`/admin/dictionaries/common-report-fields/${id}`, { method: 'DELETE' }), + updateCommonReportField: (id: string, body: { drainageFieldId: string; reportType: 'signature' | 'drainage'; required: boolean }) => + request(`/admin/dictionaries/common-report-fields/${id}`, { method: 'PUT', body: JSON.stringify(body) }), }; diff --git a/src/api/types/infrastructure-monitoring.ts b/src/api/types/infrastructure-monitoring.ts index 5be3287..56a15d3 100644 --- a/src/api/types/infrastructure-monitoring.ts +++ b/src/api/types/infrastructure-monitoring.ts @@ -41,6 +41,17 @@ export type InfrastructureAlert = { }; export type InfrastructureMonitoringOverview = { + disks: Array<{ + id: string; + instance: string; + device: string; + mountpoint: string; + filesystem: string; + usagePercent: number | null; + totalBytes: number | null; + availableBytes: number | null; + trend: InfrastructureMetricPoint[]; + }>; available: boolean; range: InfrastructureMonitoringRange; collectedAt: string; diff --git a/src/apps/admin/AdminDrainageFieldsPage.test.tsx b/src/apps/admin/AdminDrainageFieldsPage.test.tsx new file mode 100644 index 0000000..569c80b --- /dev/null +++ b/src/apps/admin/AdminDrainageFieldsPage.test.tsx @@ -0,0 +1,27 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AdminDrainageFieldsPage } from './AdminDrainageFieldsPage'; + +const { adminApi } = vi.hoisted(() => ({ adminApi: { listDrainageFields: vi.fn(), listCommonReportFields: vi.fn(), updateCommonReportField: vi.fn() } })); +vi.mock('@/api/adminApi', () => ({ adminApi })); + +describe('common reporting configuration', () => { + beforeEach(() => { + Object.values(adminApi).forEach((method) => method.mockReset()); + const field = { id: 'field-1', code: 'license', name: '主体证明', fieldType: 'file', status: 'active' }; + adminApi.listDrainageFields.mockResolvedValue([field]); + adminApi.listCommonReportFields.mockResolvedValue([{ id: 'common-1', drainageFieldId: 'field-1', reportType: 'signature', required: false, drainageField: field }]); + adminApi.updateCommonReportField.mockResolvedValue({}); + }); + it('opens existing values and saves the edited requirement with PUT API', async () => { + render(); + fireEvent.click(await screen.findByRole('button', { name: '修改通用字段主体证明' })); + expect(screen.getByRole('dialog')).toHaveTextContent('修改通用字段配置'); + fireEvent.click(screen.getByRole('button', { name: '是否必填' })); + fireEvent.click(screen.getByRole('option', { name: '必填' })); + fireEvent.click(screen.getByRole('button', { name: '保存' })); + await waitFor(() => expect(adminApi.updateCommonReportField).toHaveBeenCalledWith('common-1', { drainageFieldId: 'field-1', reportType: 'signature', required: true })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(adminApi.listCommonReportFields).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/apps/admin/AdminDrainageFieldsPage.tsx b/src/apps/admin/AdminDrainageFieldsPage.tsx index 43504ef..cc45aa8 100644 --- a/src/apps/admin/AdminDrainageFieldsPage.tsx +++ b/src/apps/admin/AdminDrainageFieldsPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { Database, FileCheck2, Link2, Plus, Search, Trash2 } from 'lucide-react'; +import { Database, Edit3, FileCheck2, Link2, Plus, Search, Trash2 } from 'lucide-react'; import { Breadcrumb, Button, Input, Modal, Select, Textarea, Tag } from '@/components/ui'; import { adminApi, type CommonReportField, type DictionaryItem } from '@/api/adminApi'; @@ -39,6 +39,8 @@ export function AdminDrainageFieldsPage() { const [error, setError] = useState(''); const [deleteTarget, setDeleteTarget] = useState(null); const [configuringCommon, setConfiguringCommon] = useState(false); + const [editingCommonId, setEditingCommonId] = useState(); + const [commonSaving, setCommonSaving] = useState(false); const [commonFieldId, setCommonFieldId] = useState(''); const [commonReportType, setCommonReportType] = useState<'signature' | 'drainage'>('signature'); const [commonRequired, setCommonRequired] = useState(false); @@ -92,8 +94,12 @@ export function AdminDrainageFieldsPage() { } function createCommonField() { - if (!commonFieldId) return; - adminApi.createCommonReportField({ drainageFieldId: commonFieldId, reportType: commonReportType, required: commonRequired }) + if (!commonFieldId || commonSaving) return; + setCommonSaving(true); + setError(''); + const body = { drainageFieldId: commonFieldId, reportType: commonReportType, required: commonRequired }; + const request = editingCommonId ? adminApi.updateCommonReportField(editingCommonId, body) : adminApi.createCommonReportField(body); + request .then(() => { setCommonFieldId(''); setCommonReportType('signature'); @@ -101,7 +107,17 @@ export function AdminDrainageFieldsPage() { setConfiguringCommon(false); loadData(); }) - .catch((failure: Error) => setError(failure.message || '通用字段配置失败')); + .catch((failure: Error) => setError(failure.message || '通用字段配置失败')) + .finally(() => setCommonSaving(false)); + } + + function openCommonField(field?: CommonReportField) { + setEditingCommonId(field?.id); + setCommonFieldId(field?.drainageFieldId ?? ''); + setCommonReportType(field?.reportType ?? 'signature'); + setCommonRequired(field?.required ?? false); + setError(''); + setConfiguringCommon(true); } function deleteCommonField() { @@ -151,11 +167,11 @@ export function AdminDrainageFieldsPage() {

通用字段配置

企业新增或编辑签名、引流信息时必须按这里的配置填写,通道字段也可以直接引用。

- +
- - + +
@@ -172,15 +188,17 @@ export function AdminDrainageFieldsPage() { } + footer={<>} onClose={() => setConfiguringCommon(false)} open={configuringCommon} - title="配置通用字段" + title={editingCommonId ? '修改通用字段配置' : '配置通用字段'} >
setCommonReportType(event.target.value as 'signature' | 'drainage')} options={[{ label: '签名报备资料', value: 'signature' }, { label: '引流信息报备资料', value: 'drainage' }]} value={commonReportType} /> onStatusChange(event.target.value)} options={statusOptions} value={status} />
setApplication(event.target.value)} options={applicationOptions} value={application} /> - +
diff --git a/src/apps/client/ClientQueryPages.test.tsx b/src/apps/client/ClientQueryPages.test.tsx new file mode 100644 index 0000000..03ea15d --- /dev/null +++ b/src/apps/client/ClientQueryPages.test.tsx @@ -0,0 +1,43 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ClientBatchTasksPage } from './ClientBatchTasksPage'; +import { ClientSendDetailPage } from './ClientSendDetailPage'; +import { ClientUplinkMessagesPage } from './ClientUplinkMessagesPage'; + +const { clientApi } = vi.hoisted(() => ({ clientApi: { + listApplicationOptions: vi.fn(), listBatchTasksPage: vi.fn(), listMessages: vi.fn(), listUplinkMessagesPage: vi.fn(), +} })); +vi.mock('@/api/adminApi', () => ({ clientApi })); + +describe('explicit client queries', () => { + beforeEach(() => { + Object.values(clientApi).forEach((mock) => mock.mockReset()); + clientApi.listApplicationOptions.mockResolvedValue([]); + for (const method of [clientApi.listBatchTasksPage, clientApi.listMessages, clientApi.listUplinkMessagesPage]) method.mockResolvedValue({ items: [], total: 25, page: 1, pageSize: 10 }); + }); + + it.each([ + { Component: ClientBatchTasksPage, method: clientApi.listBatchTasksPage, label: '发送批次号', key: 'keyword' }, + { Component: ClientSendDetailPage, method: clientApi.listMessages, label: '短信内容', key: 'contentKeyword' }, + { Component: ClientUplinkMessagesPage, method: clientApi.listUplinkMessagesPage, label: '上行内容', key: 'keyword' }, + ])('$label only applies filters on Query or Reset, including pagination back to page one', async ({ Component, method, label, key }) => { + render(); + await waitFor(() => expect(method).toHaveBeenCalledTimes(1)); + fireEvent.change(screen.getByLabelText(label), { target: { value: '待查询' } }); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 350)); }); + expect(method).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole('button', { name: '下一页' })); + await waitFor(() => expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, [key]: undefined }))); + fireEvent.click(screen.getByRole('button', { name: '查询' })); + await waitFor(() => expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, [key]: '待查询' }))); + fireEvent.change(screen.getByLabelText(label), { target: { value: '未提交' } }); + fireEvent.click(screen.getByRole('button', { name: '下一页' })); + await waitFor(() => expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, [key]: '待查询' }))); + fireEvent.click(screen.getByRole('button', { name: '上一页' })); + await waitFor(() => expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, [key]: '待查询' }))); + fireEvent.click(screen.getByRole('button', { name: '重置' })); + await waitFor(() => expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, [key]: undefined }))); + expect(screen.getByLabelText(label)).toHaveValue(''); + }); +}); diff --git a/src/apps/client/ClientSendDetailPage.tsx b/src/apps/client/ClientSendDetailPage.tsx index 91e6703..a4bee3c 100644 --- a/src/apps/client/ClientSendDetailPage.tsx +++ b/src/apps/client/ClientSendDetailPage.tsx @@ -7,6 +7,7 @@ import { Input, Pagination, QueryPanel, + QueryButtons, Select, Tag, type DateRangeValue, @@ -71,6 +72,7 @@ export function ClientSendDetailPage() { const [dateRange, setDateRange] = useState(() => recentBeijingDateRange(7)); const [contentKeyword, setContentKeyword] = useState(''); const [phoneKeyword, setPhoneKeyword] = useState(''); + const [applied, setApplied] = useState(() => ({ applicationId, status, dateRange, contentKeyword, phoneKeyword })); const [page, setPage] = useState(1); const [total, setTotal] = useState(0); const [applications, setApplications] = useState>([]); @@ -80,12 +82,12 @@ export function ClientSendDetailPage() { function loadData(targetPage = page) { setLoading(true); clientApi.listMessages({ - applicationId: applicationId === 'all' ? undefined : applicationId, - phoneNumber: phoneKeyword || undefined, - status: status === 'all' ? undefined : status, - contentKeyword: contentKeyword || undefined, - queuedAtFrom: dateRange.start || undefined, - queuedAtTo: dateRange.end || undefined, + applicationId: applied.applicationId === 'all' ? undefined : applied.applicationId, + phoneNumber: applied.phoneKeyword.trim() || undefined, + status: applied.status === 'all' ? undefined : applied.status, + contentKeyword: applied.contentKeyword.trim() || undefined, + queuedAtFrom: applied.dateRange.start || undefined, + queuedAtTo: applied.dateRange.end || undefined, page: targetPage, pageSize: 10, }) @@ -100,7 +102,7 @@ export function ClientSendDetailPage() { useEffect(() => { loadData(page); - }, [applicationId, contentKeyword, dateRange.end, dateRange.start, page, phoneKeyword, status]); + }, [applied, page]); useEffect(() => { clientApi.listApplicationOptions() @@ -121,9 +123,21 @@ export function ClientSendDetailPage() { const currentPage = Math.min(page, totalPages); const visibleRows = filteredRows; - useEffect(() => { + function query() { setPage(1); - }, [applicationId, contentKeyword, dateRange.end, dateRange.start, phoneKeyword, status]); + setApplied({ applicationId, status, dateRange, contentKeyword, phoneKeyword }); + } + + function reset() { + const defaults = { applicationId: 'all', status: 'all', dateRange: recentBeijingDateRange(7), contentKeyword: '', phoneKeyword: '' }; + setApplicationId(defaults.applicationId); + setStatus(defaults.status); + setDateRange(defaults.dateRange); + setContentKeyword(''); + setPhoneKeyword(''); + setPage(1); + setApplied(defaults); + } return (
@@ -165,6 +179,7 @@ export function ClientSendDetailPage() { prefix={} value={phoneKeyword} /> + {error ?

{error}

: null} diff --git a/src/apps/client/ClientSignaturesPage.test.tsx b/src/apps/client/ClientSignaturesPage.test.tsx index 41b0b53..b2d9a70 100644 --- a/src/apps/client/ClientSignaturesPage.test.tsx +++ b/src/apps/client/ClientSignaturesPage.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, within } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ClientSignaturesPage } from './ClientSignaturesPage'; @@ -57,6 +57,7 @@ describe('ClientSignaturesPage drainage presentation', () => { clientApi.listApplicationOptions.mockResolvedValue([{ id: 'app-1', name: '测试应用', status: 'active' }]); clientApi.getSignatureWorkspace.mockResolvedValue({ items: [signature], summary: { total: 1, pending: 0, approved: 1, rejected: 0, draft: 0 }, total: 1, page: 1, pageSize: 10 }); clientApi.listApplicationReportFields.mockResolvedValue([]); + clientApi.listCommonApplicationReportFields.mockResolvedValue([]); }); it('groups the three real carrier summaries into a readable status area', async () => { @@ -82,4 +83,32 @@ describe('ClientSignaturesPage drainage presentation', () => { expect(within(dialog).queryByLabelText('名称')).not.toBeInTheDocument(); expect(within(dialog).queryByLabelText('访问地址')).not.toBeInTheDocument(); }); + + it('keeps the newest application field requirements when older requests finish late', async () => { + let resolveOld!: (value: unknown[]) => void; + clientApi.listCommonApplicationReportFields.mockReturnValue(new Promise((resolve) => { resolveOld = resolve; })); + clientApi.listApplicationReportFields.mockResolvedValue([{ id: 'current', code: 'owner', name: '应用最新主体', fieldType: 'string', required: true }]); + render(); + await screen.findByText('【测试签名】'); + fireEvent.click(screen.getByRole('button', { name: '新增签名' })); + const dialog = screen.getByRole('dialog', { name: '新增签名' }); + expect(within(dialog).getByRole('button', { name: '提交审核' })).toBeDisabled(); + fireEvent.change(within(dialog).getByLabelText('所属应用'), { target: { value: 'app-1' } }); + await within(dialog).findByLabelText('* 应用最新主体'); + await act(async () => resolveOld([{ id: 'old', code: 'old', name: '过期字段', fieldType: 'string', required: false }])); + expect(within(dialog).queryByLabelText('过期字段')).not.toBeInTheDocument(); + expect(within(dialog).getByLabelText('* 应用最新主体')).toBeVisible(); + }); + + it('blocks signature submission when current reporting requirements cannot be loaded', async () => { + clientApi.listCommonApplicationReportFields.mockRejectedValue(new Error('字段加载失败')); + render(); + await screen.findByText('【测试签名】'); + fireEvent.click(screen.getByRole('button', { name: '新增签名' })); + const dialog = screen.getByRole('dialog', { name: '新增签名' }); + fireEvent.change(within(dialog).getByLabelText('短信签名'), { target: { value: '【新签名】' } }); + await waitFor(() => expect(within(dialog).getByText('字段加载失败')).toBeVisible()); + expect(within(dialog).getByRole('button', { name: '提交审核' })).toBeDisabled(); + expect(clientApi.createSignature).not.toHaveBeenCalled(); + }); }); diff --git a/src/apps/client/ClientSignaturesPage.tsx b/src/apps/client/ClientSignaturesPage.tsx index 0554b06..98b5ae5 100644 --- a/src/apps/client/ClientSignaturesPage.tsx +++ b/src/apps/client/ClientSignaturesPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { ChevronDown, ChevronRight, Edit3, FileCheck2, Globe2, Plus, RotateCcw, Search, Trash2, Upload } from 'lucide-react'; import { Button, CarrierTag, DeleteRiskAction, FileActions, Input, Modal, Pagination, Select, Textarea } from '@/components/ui'; import { @@ -72,6 +72,7 @@ function ReviewFields({ ? onChange(field.code, event.target.value)} value={String(values[field.code] ?? '')} /> @@ -103,7 +104,10 @@ function SignatureModal({ }) { const [applicationId, setApplicationId] = useState(signature?.applicationId ?? ''); const [name, setName] = useState(signature?.name ?? ''); - const [fields, setFields] = useState([]); + const [fieldResult, setFieldResult] = useState<{ applicationId: string; fields: ClientApplicationReportField[]; error: string } | null>(null); + const fieldsLoading = fieldResult?.applicationId !== applicationId; + const fields = fieldsLoading ? [] : fieldResult?.fields ?? []; + const fieldsError = fieldsLoading ? '' : fieldResult?.error ?? ''; const [values, setValues] = useState>(signature?.reportValues ?? {}); const [uploadingCode, setUploadingCode] = useState(''); const [saving, setSaving] = useState(false); @@ -111,10 +115,13 @@ function SignatureModal({ const [nameInputError, setNameInputError] = useState(''); useEffect(() => { + let active = true; const request = applicationId ? clientApi.listApplicationReportFields(applicationId, 'signature') : clientApi.listCommonApplicationReportFields('signature'); - request.then(setFields).catch((failure: Error) => setError(failure.message || '审核资料加载失败')); + request.then((items) => { if (active) setFieldResult({ applicationId, fields: items, error: '' }); }) + .catch((failure: Error) => { if (active) setFieldResult({ applicationId, fields: [], error: failure.message || '审核资料加载失败' }); }); + return () => { active = false; }; }, [applicationId]); async function upload(field: ClientApplicationReportField, file?: File) { @@ -132,6 +139,7 @@ function SignatureModal({ } async function save() { + if (fieldsLoading || fieldsError) return; if (!isCompleteSmsSignature(name)) { setError(getSmsSignatureValidationError(name) ?? '短信签名格式不正确'); return; @@ -157,7 +165,7 @@ function SignatureModal({ const signatureNameValid = !nameInputError && isCompleteSmsSignature(name); const signatureNameError = nameInputError || (name ? getSmsSignatureValidationError(name) : undefined); return } + footer={<>} onClose={onClose} open size="xl" @@ -190,7 +198,7 @@ function SignatureModal({ />

审核资料

请按要求填写或上传,资料仅用于签名审核。

- setValues((current) => ({ ...current, [code]: value }))} onUpload={(field, file) => void upload(field, file)} uploadingCode={uploadingCode} values={values} /> + {fieldsLoading ?

正在加载报备资料要求...

: fieldsError ?

{fieldsError}

: setValues((current) => ({ ...current, [code]: value }))} onUpload={(field, file) => void upload(field, file)} uploadingCode={uploadingCode} values={values} />}
{error ?

{error}

: null}
@@ -263,6 +271,7 @@ function DrainageModal({ item, signature, onClose, onSaved }: { item?: ClientDra } export function ClientSignaturesPage() { + const requestSequence = useRef(0); const [applications, setApplications] = useState([]); const [workspace, setWorkspace] = useState(EMPTY_WORKSPACE); const [keyword, setKeyword] = useState(''); @@ -279,6 +288,7 @@ export function ClientSignaturesPage() { const pageSize = 10; function loadData(targetPage = page) { + const sequence = ++requestSequence.current; setLoading(true); Promise.all([ clientApi.listApplicationOptions(), @@ -290,12 +300,13 @@ export function ClientSignaturesPage() { }), ]) .then(([applicationItems, signatureWorkspace]) => { + if (sequence !== requestSequence.current) return; setApplications(applicationItems.filter((item) => item.status === 'active')); setWorkspace(signatureWorkspace); setError(''); }) - .catch((failure: Error) => setError(failure.message || '签名与引流信息加载失败')) - .finally(() => setLoading(false)); + .catch((failure: Error) => { if (sequence === requestSequence.current) setError(failure.message || '签名与引流信息加载失败'); }) + .finally(() => { if (sequence === requestSequence.current) setLoading(false); }); } useEffect(() => { diff --git a/src/apps/client/ClientTemplatesPage.tsx b/src/apps/client/ClientTemplatesPage.tsx index 0b0660d..d0a9a58 100644 --- a/src/apps/client/ClientTemplatesPage.tsx +++ b/src/apps/client/ClientTemplatesPage.tsx @@ -209,6 +209,7 @@ function TemplateModal({ } export function ClientTemplatesPage() { + const requestSequence = useRef(0); const [applications, setApplications] = useState([]); const [templates, setTemplates] = useState([]); const [signatures, setSignatures] = useState([]); @@ -221,17 +222,19 @@ export function ClientTemplatesPage() { const pageSize = 10; function loadData(targetPage = page) { + const sequence = ++requestSequence.current; setLoading(true); Promise.all([clientApi.listApplicationOptions(), clientApi.listTemplatesPage({ includeHistory: true, keyword: keyword.trim() || undefined, page: targetPage, pageSize }), clientApi.listSignatureOptions()]) .then(([applicationItems, templateResult, signatureItems]) => { + if (sequence !== requestSequence.current) return; setApplications(applicationItems.filter((item) => item.status === 'active')); setTemplates(templateResult.items.filter((item) => item.auditStatus !== 'deleted' && item.auditStatus !== 'disabled')); setTotal(templateResult.total); setSignatures(signatureItems); setError(''); }) - .catch((reason: Error) => setError(reason.message || '短信模板加载失败')) - .finally(() => setLoading(false)); + .catch((reason: Error) => { if (sequence === requestSequence.current) setError(reason.message || '短信模板加载失败'); }) + .finally(() => { if (sequence === requestSequence.current) setLoading(false); }); } useEffect(() => { diff --git a/src/apps/client/ClientUplinkMessagesPage.tsx b/src/apps/client/ClientUplinkMessagesPage.tsx index 22688cc..c76af26 100644 --- a/src/apps/client/ClientUplinkMessagesPage.tsx +++ b/src/apps/client/ClientUplinkMessagesPage.tsx @@ -10,6 +10,7 @@ import { Modal, Pagination, QueryPanel, + QueryButtons, Table, type DateRangeValue, type TableColumn, @@ -30,6 +31,7 @@ export function ClientUplinkMessagesPage() { const [phoneKeyword, setPhoneKeyword] = useState(''); const [contentKeyword, setContentKeyword] = useState(''); const [dateRange, setDateRange] = useState(() => recentBeijingDateRange(7)); + const [applied, setApplied] = useState(() => ({ phoneKeyword, contentKeyword, dateRange })); const [selectedMessage, setSelectedMessage] = useState(null); const [loading, setLoading] = useState(true); const [matching, setMatching] = useState(false); @@ -41,7 +43,7 @@ export function ClientUplinkMessagesPage() { function loadData(targetPage = page) { setLoading(true); - clientApi.listUplinkMessagesPage({ phoneNumber: phoneKeyword || undefined, keyword: contentKeyword || undefined, startTime: dateRange.start ? `${dateRange.start}T00:00:00+08:00` : undefined, endTime: dateRange.end ? `${dateRange.end}T23:59:59+08:00` : undefined, page: targetPage, pageSize }) + clientApi.listUplinkMessagesPage({ phoneNumber: applied.phoneKeyword.trim() || undefined, keyword: applied.contentKeyword.trim() || undefined, startTime: applied.dateRange.start ? `${applied.dateRange.start}T00:00:00+08:00` : undefined, endTime: applied.dateRange.end ? `${applied.dateRange.end}T23:59:59+08:00` : undefined, page: targetPage, pageSize }) .then((result) => { setMessages(result.items); setTotal(result.total); @@ -68,14 +70,22 @@ export function ClientUplinkMessagesPage() { } useEffect(() => { - setPage(1); - const timer = window.setTimeout(() => loadData(1), 300); - return () => window.clearTimeout(timer); - }, [phoneKeyword, contentKeyword, dateRange.start, dateRange.end]); + loadData(page); + }, [applied, page]); - useEffect(() => { - if (page > 1) loadData(page); - }, [page]); + function query() { + setPage(1); + setApplied({ phoneKeyword, contentKeyword, dateRange }); + } + + function reset() { + const defaults = { phoneKeyword: '', contentKeyword: '', dateRange: recentBeijingDateRange(7) }; + setPhoneKeyword(''); + setContentKeyword(''); + setDateRange(defaults.dateRange); + setPage(1); + setApplied(defaults); + } const columns = useMemo>>(() => [ { key: 'phoneNumber', title: '手机号码', width: '180px', render: (record) => {record.phoneNumber} }, @@ -119,6 +129,7 @@ export function ClientUplinkMessagesPage() { prefix={} value={contentKeyword} /> + {error ?

{error}

: null} diff --git a/src/components/ui/QueryButtons.tsx b/src/components/ui/QueryButtons.tsx new file mode 100644 index 0000000..c4ad501 --- /dev/null +++ b/src/components/ui/QueryButtons.tsx @@ -0,0 +1,10 @@ +import { Search } from 'lucide-react'; +import { Button } from './Button'; + +/** Shared explicit-search actions for both portals. Editing filters never submits them. */ +export function QueryButtons({ onQuery, onReset }: { onQuery: () => void; onReset: () => void }) { + return
+ + +
; +} diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 1ce5b3c..b0bfa18 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -18,6 +18,7 @@ export { Modal } from './Modal'; export { MoneyText } from './MoneyText'; export { ClientLoginCanvas } from './ClientLoginCanvas'; export { InlineTextPreview, Pagination, QueryPanel } from './PagePrimitives'; +export { QueryButtons } from './QueryButtons'; export { Select } from './Select'; export { Table } from './Table'; export { Tabs } from './Tabs'; diff --git a/src/styles/global.css b/src/styles/global.css index b0994be..8c8f16a 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -8546,7 +8546,7 @@ .admin-drainage-common-group__title { align-items: center; display: flex; justify-content: space-between; margin-bottom: 12px; } .admin-drainage-common-group__title > span, .admin-drainage-common-empty { color: var(--color-text-muted); font-size: var(--font-size-sm); } .admin-drainage-common-list { display: grid; gap: 8px; } -.admin-drainage-common-list > div { align-items: center; background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-sm); display: grid; gap: 10px; grid-template-columns: minmax(0, 1fr) auto auto; padding: 11px 12px; } +.admin-drainage-common-list > div { align-items: center; background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-sm); display: grid; gap: 10px; grid-template-columns: minmax(0, 1fr) auto auto auto; padding: 11px 12px; } .admin-drainage-common-list strong, .admin-drainage-common-list span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .admin-drainage-common-list span { color: var(--color-text-muted); font-size: 12px; margin-top: 3px; } .admin-drainage-field-grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); } diff --git a/tools/monitoring/cmpp-alerts.yml b/tools/monitoring/cmpp-alerts.yml index f3370f6..ea4e930 100644 --- a/tools/monitoring/cmpp-alerts.yml +++ b/tools/monitoring/cmpp-alerts.yml @@ -108,50 +108,50 @@ groups: threshold: "95%" - alert: HostRootDiskUsageWarning - expr: ((1 - node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"} / node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"}) * 100 > 80) and ((1 - node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"} / node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"}) * 100 <= 90) + expr: ((1 - node_filesystem_avail_bytes{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"} / node_filesystem_size_bytes{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"}) * 100 > 80) and ((1 - node_filesystem_avail_bytes{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"} / node_filesystem_size_bytes{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"}) * 100 <= 90) for: 15m labels: severity: warning service: host annotations: - summary: 根文件系统空间不足 - description: 根文件系统使用率连续15分钟高于80%。 + summary: 磁盘文件系统空间不足 + description: "磁盘文件系统使用率连续15分钟高于80%。 挂载点:{{ $labels.mountpoint }};设备:{{ $labels.device }}。" currentValue: "{{ printf \"%.1f\" $value }}%" threshold: "80%" - alert: HostRootDiskUsageCritical - expr: (1 - node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"} / node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"}) * 100 > 90 + expr: (1 - node_filesystem_avail_bytes{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"} / node_filesystem_size_bytes{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"}) * 100 > 90 for: 5m labels: severity: critical service: host annotations: - summary: 根文件系统空间严重不足 - description: 根文件系统使用率连续5分钟高于90%。 + summary: 磁盘文件系统空间严重不足 + description: "磁盘文件系统使用率连续5分钟高于90%。 挂载点:{{ $labels.mountpoint }};设备:{{ $labels.device }}。" currentValue: "{{ printf \"%.1f\" $value }}%" threshold: "90%" - alert: HostRootInodeUsageWarning - expr: ((1 - node_filesystem_files_free{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"} / node_filesystem_files{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"}) * 100 > 80) and ((1 - node_filesystem_files_free{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"} / node_filesystem_files{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"}) * 100 <= 90) + expr: ((1 - node_filesystem_files_free{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"} / node_filesystem_files{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"}) * 100 > 80) and ((1 - node_filesystem_files_free{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"} / node_filesystem_files{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"}) * 100 <= 90) for: 15m labels: severity: warning service: host annotations: - summary: 根文件系统inode余量偏低 - description: 根文件系统inode使用率连续15分钟高于80%。 + summary: 磁盘文件系统inode余量偏低 + description: "磁盘文件系统inode使用率连续15分钟高于80%。 挂载点:{{ $labels.mountpoint }};设备:{{ $labels.device }}。" currentValue: "{{ printf \"%.1f\" $value }}%" threshold: "80%" - alert: HostRootInodeUsageCritical - expr: (1 - node_filesystem_files_free{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"} / node_filesystem_files{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"}) * 100 > 90 + expr: (1 - node_filesystem_files_free{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"} / node_filesystem_files{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"}) * 100 > 90 for: 5m labels: severity: critical service: host annotations: - summary: 根文件系统inode严重不足 - description: 根文件系统inode使用率连续5分钟高于90%。 + summary: 磁盘文件系统inode严重不足 + description: "磁盘文件系统inode使用率连续5分钟高于90%。 挂载点:{{ $labels.mountpoint }};设备:{{ $labels.device }}。" currentValue: "{{ printf \"%.1f\" $value }}%" threshold: "90%" diff --git a/tools/monitoring/cmpp-managed-alerts.yml b/tools/monitoring/cmpp-managed-alerts.yml index 3a206ba..fb90169 100644 --- a/tools/monitoring/cmpp-managed-alerts.yml +++ b/tools/monitoring/cmpp-managed-alerts.yml @@ -22,15 +22,15 @@ groups: labels: { severity: critical, service: host } annotations: { summary: "主机内存使用率达到严重阈值", description: "主机内存使用率持续超过95%。", currentValue: "{{ $value }}", threshold: "95%" } - alert: HostRootDiskUsageWarning - expr: ((1 - node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"} / node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"}) * 100 > 80) and ((1 - node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"} / node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"}) * 100 <= 90) + expr: ((1 - node_filesystem_avail_bytes{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"} / node_filesystem_size_bytes{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"}) * 100 > 80) and ((1 - node_filesystem_avail_bytes{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"} / node_filesystem_size_bytes{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"}) * 100 <= 90) for: 15m labels: { severity: warning, service: host } - annotations: { summary: "根磁盘使用率达到警告阈值", description: "根磁盘使用率持续超过80%。", currentValue: "{{ $value }}", threshold: "80%" } + annotations: { summary: "磁盘(所有挂载点)使用率达到警告阈值", description: "磁盘(所有挂载点)使用率持续超过80%。", currentValue: "{{ $value }}", threshold: "80%" } - alert: HostRootDiskUsageCritical - expr: (1 - node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"} / node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"}) * 100 > 90 + expr: (1 - node_filesystem_avail_bytes{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"} / node_filesystem_size_bytes{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"}) * 100 > 90 for: 5m labels: { severity: critical, service: host } - annotations: { summary: "根磁盘使用率达到严重阈值", description: "根磁盘使用率持续超过90%。", currentValue: "{{ $value }}", threshold: "90%" } + annotations: { summary: "磁盘(所有挂载点)使用率达到严重阈值", description: "磁盘(所有挂载点)使用率持续超过90%。", currentValue: "{{ $value }}", threshold: "90%" } - alert: CmppApiHttpErrorRateWarning expr: (100 * sum(rate(cmpp_api_http_requests_total{status=~"5.."}[5m])) / clamp_min(sum(rate(cmpp_api_http_requests_total[5m])), 0.001) > 1) and (100 * sum(rate(cmpp_api_http_requests_total{status=~"5.."}[5m])) / clamp_min(sum(rate(cmpp_api_http_requests_total[5m])), 0.001) <= 5) and (sum(increase(cmpp_api_http_requests_total{status=~"5.."}[5m])) >= 5) for: 5m