fix: align reporting fields queries and disk monitoring
This commit is contained in:
@@ -172,4 +172,9 @@ export class DictionariesController {
|
|||||||
deleteCommonReportField(@Param('id') id: string) {
|
deleteCommonReportField(@Param('id') id: string) {
|
||||||
return this.dictionaries.deleteCommonReportField(id);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,24 @@ function createPrismaMock() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('DictionariesService', () => {
|
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 () => {
|
it('builds the enterprise province and city library from distinct real phone segment regions', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
prisma.phoneSegment.findMany.mockResolvedValue([
|
prisma.phoneSegment.findMany.mockResolvedValue([
|
||||||
|
|||||||
@@ -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 { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { PhoneRoutingLookupService } from './phone-routing-lookup.service';
|
import { PhoneRoutingLookupService } from './phone-routing-lookup.service';
|
||||||
@@ -545,6 +545,42 @@ export class DictionariesService {
|
|||||||
return this.prisma.commonReportField.delete({ where: { id } });
|
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<string, unknown>) {
|
private writeOperationLog(userId: string | undefined, action: string, resource: string, resourceId: string, detail: Record<string, unknown>) {
|
||||||
return this.prisma.operationLog.create({
|
return this.prisma.operationLog.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ describe('InfrastructureAlertSettingsService', () => {
|
|||||||
expect(rules).toContain('threshold: "120秒"');
|
expect(rules).toContain('threshold: "120秒"');
|
||||||
expect(rules).toContain('redis_memory_max_bytes > 0');
|
expect(rules).toContain('redis_memory_max_bytes > 0');
|
||||||
expect(rules).toContain('sum(increase(cmpp_api_http_requests_total');
|
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', () => {
|
it('rejects unknown keys and warning thresholds that are not below critical', () => {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const execFileAsync = promisify(execFile);
|
|||||||
export const ALERT_THRESHOLD_DEFINITIONS = [
|
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: '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: '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: '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: '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'] },
|
{ 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 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}`;
|
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`;
|
return `${lines.join('\n')}\n`;
|
||||||
|
|||||||
@@ -41,6 +41,17 @@ export type InfrastructureAlert = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type InfrastructureMonitoringOverview = {
|
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;
|
available: boolean;
|
||||||
range: InfrastructureMonitoringRange;
|
range: InfrastructureMonitoringRange;
|
||||||
collectedAt: string;
|
collectedAt: string;
|
||||||
|
|||||||
@@ -108,6 +108,30 @@ describe('InfrastructureMonitoringService', () => {
|
|||||||
expect(result.trends.cpuUsagePercent).toEqual([]);
|
expect(result.trends.cpuUsagePercent).toEqual([]);
|
||||||
expect(result.serviceMetrics.every((item) => item.available === false)).toBe(true);
|
expect(result.serviceMetrics.every((item) => item.available === false)).toBe(true);
|
||||||
expect(result.error).not.toContain('ECONNREFUSED');
|
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 () => {
|
it('excludes only the current alert occurrence after the current administrator marks it read', async () => {
|
||||||
|
|||||||
@@ -47,9 +47,9 @@ const QUERIES = {
|
|||||||
memoryUsagePercent: '(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100',
|
memoryUsagePercent: '(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100',
|
||||||
memoryTotalBytes: 'node_memory_MemTotal_bytes',
|
memoryTotalBytes: 'node_memory_MemTotal_bytes',
|
||||||
memoryAvailableBytes: 'node_memory_MemAvailable_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',
|
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{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"}',
|
diskTotalBytes: 'node_filesystem_size_bytes{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"}',
|
||||||
diskAvailableBytes: 'node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"}',
|
diskAvailableBytes: 'node_filesystem_avail_bytes{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"}',
|
||||||
networkReceiveBytesPerSecond: 'sum(rate(node_network_receive_bytes_total{device!~"lo"}[5m]))',
|
networkReceiveBytesPerSecond: 'sum(rate(node_network_receive_bytes_total{device!~"lo"}[5m]))',
|
||||||
networkTransmitBytesPerSecond: 'sum(rate(node_network_transmit_bytes_total{device!~"lo"}[5m]))',
|
networkTransmitBytesPerSecond: 'sum(rate(node_network_transmit_bytes_total{device!~"lo"}[5m]))',
|
||||||
load1: 'node_load1',
|
load1: 'node_load1',
|
||||||
@@ -132,6 +132,15 @@ function matrixValues(response: PrometheusQueryResponse): InfrastructureMetricPo
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function diskIdentity(metric: Record<string, string>) {
|
||||||
|
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'] {
|
function emptyMetrics(): InfrastructureMonitoringOverview['metrics'] {
|
||||||
return {
|
return {
|
||||||
cpuUsagePercent: null,
|
cpuUsagePercent: null,
|
||||||
@@ -200,7 +209,8 @@ export class InfrastructureMonitoringService {
|
|||||||
activeAlerts: alerts.length,
|
activeAlerts: alerts.length,
|
||||||
},
|
},
|
||||||
metrics: instant.metrics,
|
metrics: instant.metrics,
|
||||||
trends,
|
trends: trends.metrics,
|
||||||
|
disks: instant.disks.map((disk) => ({ ...disk, trend: trends.disks.get(disk.id) ?? [] })),
|
||||||
services,
|
services,
|
||||||
serviceMetrics,
|
serviceMetrics,
|
||||||
alerts,
|
alerts,
|
||||||
@@ -263,8 +273,21 @@ export class InfrastructureMonitoringService {
|
|||||||
const keys = Object.keys(emptyMetrics()) as Array<keyof InfrastructureMonitoringOverview['metrics']>;
|
const keys = Object.keys(emptyMetrics()) as Array<keyof InfrastructureMonitoringOverview['metrics']>;
|
||||||
const responses = await Promise.all([...keys.map((key) => this.query(QUERIES[key])), this.query(QUERIES.lastSampleAt)]);
|
const responses = await Promise.all([...keys.map((key) => this.query(QUERIES[key])), this.query(QUERIES.lastSampleAt)]);
|
||||||
const metrics = emptyMetrics();
|
const metrics = emptyMetrics();
|
||||||
keys.forEach((key, index) => { metrics[key] = vectorValue(responses[index]); });
|
keys.forEach((key, index) => { metrics[key] = vectorValue(key.startsWith('disk') ? rootSeries(responses[index]) : responses[index]); });
|
||||||
return { metrics, lastSampleAt: vectorValue(responses[responses.length - 1]) };
|
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) {
|
private async loadTrends(range: InfrastructureMonitoringRange) {
|
||||||
@@ -273,7 +296,12 @@ export class InfrastructureMonitoringService {
|
|||||||
const start = end - config.seconds;
|
const start = end - config.seconds;
|
||||||
const keys = Object.keys(emptyTrends()) as Array<keyof InfrastructureMonitoringOverview['trends']>;
|
const keys = Object.keys(emptyTrends()) as Array<keyof InfrastructureMonitoringOverview['trends']>;
|
||||||
const responses = await Promise.all(keys.map((key) => this.queryRange(QUERIES[key], start, end, config.step)));
|
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[] {
|
private parseServices(response: PrometheusQueryResponse): InfrastructureServiceStatus[] {
|
||||||
@@ -356,6 +384,7 @@ export class InfrastructureMonitoringService {
|
|||||||
error: 'Prometheus监控数据当前不可用,请检查采集与服务状态',
|
error: 'Prometheus监控数据当前不可用,请检查采集与服务状态',
|
||||||
summary: { overallStatus: 'unknown', serviceTotal: services.length, serviceHealthy: 0, warningAlerts: 0, criticalAlerts: 0, activeAlerts: 0 },
|
summary: { overallStatus: 'unknown', serviceTotal: services.length, serviceHealthy: 0, warningAlerts: 0, criticalAlerts: 0, activeAlerts: 0 },
|
||||||
metrics: emptyMetrics(),
|
metrics: emptyMetrics(),
|
||||||
|
disks: [],
|
||||||
trends: emptyTrends(),
|
trends: emptyTrends(),
|
||||||
services,
|
services,
|
||||||
serviceMetrics: SERVICE_METRIC_DEFINITIONS.map((group) => ({ key: group.key, name: group.name, available: false, metrics: [] })),
|
serviceMetrics: SERVICE_METRIC_DEFINITIONS.map((group) => ({ key: group.key, name: group.name, available: false, metrics: [] })),
|
||||||
|
|||||||
@@ -167,8 +167,9 @@ export class SmsApplicationConfigService {
|
|||||||
const merged = new Map<string, MergedReportField>();
|
const merged = new Map<string, MergedReportField>();
|
||||||
const routeChannels = new Map<string, { id: string; code: string; name: string; groupId: string; groupName: string }>();
|
const routeChannels = new Map<string, { id: string; code: string; name: string; groupId: string; groupName: string }>();
|
||||||
for (const route of routes) {
|
for (const route of routes) {
|
||||||
if (!route.group) continue;
|
if (!route.group || route.group.status === 'deleted') continue;
|
||||||
for (const item of route.group.items) {
|
for (const item of route.group.items) {
|
||||||
|
if (item.channel.status === 'deleted') continue;
|
||||||
if (!routeChannels.has(item.channel.id)) {
|
if (!routeChannels.has(item.channel.id)) {
|
||||||
routeChannels.set(item.channel.id, {
|
routeChannels.set(item.channel.id, {
|
||||||
id: item.channel.id,
|
id: item.channel.id,
|
||||||
@@ -181,6 +182,17 @@ export class SmsApplicationConfigService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const configured of commonFields) {
|
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, {
|
merged.set(configured.drainageField.id, {
|
||||||
id: configured.drainageField.id,
|
id: configured.drainageField.id,
|
||||||
code: configured.drainageField.code,
|
code: configured.drainageField.code,
|
||||||
@@ -199,8 +211,9 @@ export class SmsApplicationConfigService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const route of routes) {
|
for (const route of routes) {
|
||||||
if (!route.group) continue;
|
if (!route.group || route.group.status === 'deleted') continue;
|
||||||
for (const item of route.group.items) {
|
for (const item of route.group.items) {
|
||||||
|
if (item.channel.status === 'deleted') continue;
|
||||||
for (const configured of item.channel.reportFields) {
|
for (const configured of item.channel.reportFields) {
|
||||||
if (configured.status !== 'active' || !configured.drainageField || configured.drainageField.status !== 'active') continue;
|
if (configured.status !== 'active' || !configured.drainageField || configured.drainageField.status !== 'active') continue;
|
||||||
if (reportType && configured.reportType !== 'both' && configured.reportType !== reportType) continue;
|
if (reportType && configured.reportType !== 'both' && configured.reportType !== reportType) continue;
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ export class SmsSignatureService {
|
|||||||
id: signatureId,
|
id: signatureId,
|
||||||
tenantId,
|
tenantId,
|
||||||
applicationId: query.applicationId,
|
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() ? [
|
OR: query.keyword?.trim() ? [
|
||||||
{ name: { contains: query.keyword.trim() } },
|
{ name: { contains: query.keyword.trim() } },
|
||||||
{ purpose: { contains: query.keyword.trim() } },
|
{ purpose: { contains: query.keyword.trim() } },
|
||||||
@@ -305,7 +305,7 @@ export class SmsSignatureService {
|
|||||||
const filteredWhere: Prisma.SmsSignatureWhereInput = {
|
const filteredWhere: Prisma.SmsSignatureWhereInput = {
|
||||||
tenantId,
|
tenantId,
|
||||||
applicationId: query.applicationId,
|
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() ? [
|
OR: query.keyword?.trim() ? [
|
||||||
{ name: { contains: query.keyword.trim() } },
|
{ name: { contains: query.keyword.trim() } },
|
||||||
{ purpose: { contains: query.keyword.trim() } },
|
{ purpose: { contains: query.keyword.trim() } },
|
||||||
@@ -427,7 +427,7 @@ export class SmsSignatureService {
|
|||||||
|
|
||||||
async submitSignature(signatureId: string, tenantId?: string) {
|
async submitSignature(signatureId: string, tenantId?: string) {
|
||||||
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
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');
|
throw new NotFoundException('Signature not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ function createPrismaMock() {
|
|||||||
create: jest.fn().mockResolvedValue({ id: 'drainage-record-1' }),
|
create: jest.fn().mockResolvedValue({ id: 'drainage-record-1' }),
|
||||||
},
|
},
|
||||||
smsTemplate: {
|
smsTemplate: {
|
||||||
|
count: jest.fn().mockResolvedValue(0),
|
||||||
findMany: jest.fn().mockResolvedValue([{
|
findMany: jest.fn().mockResolvedValue([{
|
||||||
id: 'tpl-1',
|
id: 'tpl-1',
|
||||||
tenantId: 'tenant-1',
|
tenantId: 'tenant-1',
|
||||||
@@ -1024,6 +1025,56 @@ describe('SmsConfigService', () => {
|
|||||||
expect(prisma.channelSignatureReportTask.create).not.toHaveBeenCalled();
|
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 () => {
|
it('accepts a scheme-less drainage URL and synchronizes the compatibility name', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'approved' });
|
prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'approved' });
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ export class SmsTemplateService {
|
|||||||
|
|
||||||
async submitTemplate(templateId: string, tenantId?: string) {
|
async submitTemplate(templateId: string, tenantId?: string) {
|
||||||
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
|
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');
|
throw new NotFoundException('Template not found');
|
||||||
}
|
}
|
||||||
await this.validateTemplateSignature(template.signatureId, template.tenantId, template.applicationId, template.content);
|
await this.validateTemplateSignature(template.signatureId, template.tenantId, template.applicationId, template.content);
|
||||||
|
|||||||
@@ -4965,3 +4965,18 @@ npm run verify:phase8
|
|||||||
| TC-DRAINAGE-UI-005 | 运营端查看单条审核列表、详情、报备任务及报备记录 | 统一显示“引流 URL 或号码”及真实目标值,不再显示独立站点名称或旧“引流地址”标签;审核通过/驳回仍调用原真实 API |
|
| TC-DRAINAGE-UI-005 | 运营端查看单条审核列表、详情、报备任务及报备记录 | 统一显示“引流 URL 或号码”及真实目标值,不再显示独立站点名称或旧“引流地址”标签;审核通过/驳回仍调用原真实 API |
|
||||||
| TC-DRAINAGE-UI-006 | 下载引流官方导入模板并配置导入映射 | 模板和映射仅要求“所属短信签名”“引流 URL 或号码”,不再要求“站点名称”;导入项进入真实审核批次 |
|
| TC-DRAINAGE-UI-006 | 下载引流官方导入模板并配置导入映射 | 模板和映射仅要求“所属短信签名”“引流 URL 或号码”,不再要求“站点名称”;导入项进入真实审核批次 |
|
||||||
| TC-DRAINAGE-UI-007 | 桌面和窄屏查看三网状态组及引流列表 | 状态单元不相互覆盖,文字不截断为不可辨认内容;窄屏沿用受控列表滚动,不产生页面级横向溢出 |
|
| 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、窗口日志与资源哈希;不发/补发/重投短信,不修改客户/余额/通道配置 |
|
||||||
|
|||||||
@@ -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,未触及短信、账务及其他日志。
|
- 修复发布后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均为空。
|
- 最终`.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/控制台验收。全程未发送、补发或重投短信,未修改余额、通道、客户或签名/引流业务配置。
|
- 首页、客户端登录、运营端登录及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前不部署。最终发布标记、恢复点、服务/健康/资源哈希与验收边界在后续发布记录补充。
|
||||||
|
|||||||
@@ -206,4 +206,6 @@ export const adminGovernanceApi = {
|
|||||||
createCommonReportField: (body: { drainageFieldId: string; reportType: 'signature' | 'drainage'; required: boolean; sortOrder?: number }) =>
|
createCommonReportField: (body: { drainageFieldId: string; reportType: 'signature' | 'drainage'; required: boolean; sortOrder?: number }) =>
|
||||||
request<CommonReportField>('/admin/dictionaries/common-report-fields', { method: 'POST', body: JSON.stringify(body) }),
|
request<CommonReportField>('/admin/dictionaries/common-report-fields', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
deleteCommonReportField: (id: string) => request<CommonReportField>(`/admin/dictionaries/common-report-fields/${id}`, { method: 'DELETE' }),
|
deleteCommonReportField: (id: string) => request<CommonReportField>(`/admin/dictionaries/common-report-fields/${id}`, { method: 'DELETE' }),
|
||||||
|
updateCommonReportField: (id: string, body: { drainageFieldId: string; reportType: 'signature' | 'drainage'; required: boolean }) =>
|
||||||
|
request<CommonReportField>(`/admin/dictionaries/common-report-fields/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -41,6 +41,17 @@ export type InfrastructureAlert = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type InfrastructureMonitoringOverview = {
|
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;
|
available: boolean;
|
||||||
range: InfrastructureMonitoringRange;
|
range: InfrastructureMonitoringRange;
|
||||||
collectedAt: string;
|
collectedAt: string;
|
||||||
|
|||||||
@@ -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(<AdminDrainageFieldsPage />);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
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 { Breadcrumb, Button, Input, Modal, Select, Textarea, Tag } from '@/components/ui';
|
||||||
import { adminApi, type CommonReportField, type DictionaryItem } from '@/api/adminApi';
|
import { adminApi, type CommonReportField, type DictionaryItem } from '@/api/adminApi';
|
||||||
|
|
||||||
@@ -39,6 +39,8 @@ export function AdminDrainageFieldsPage() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [deleteTarget, setDeleteTarget] = useState<DrainageField | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<DrainageField | null>(null);
|
||||||
const [configuringCommon, setConfiguringCommon] = useState(false);
|
const [configuringCommon, setConfiguringCommon] = useState(false);
|
||||||
|
const [editingCommonId, setEditingCommonId] = useState<string>();
|
||||||
|
const [commonSaving, setCommonSaving] = useState(false);
|
||||||
const [commonFieldId, setCommonFieldId] = useState('');
|
const [commonFieldId, setCommonFieldId] = useState('');
|
||||||
const [commonReportType, setCommonReportType] = useState<'signature' | 'drainage'>('signature');
|
const [commonReportType, setCommonReportType] = useState<'signature' | 'drainage'>('signature');
|
||||||
const [commonRequired, setCommonRequired] = useState(false);
|
const [commonRequired, setCommonRequired] = useState(false);
|
||||||
@@ -92,8 +94,12 @@ export function AdminDrainageFieldsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createCommonField() {
|
function createCommonField() {
|
||||||
if (!commonFieldId) return;
|
if (!commonFieldId || commonSaving) return;
|
||||||
adminApi.createCommonReportField({ drainageFieldId: commonFieldId, reportType: commonReportType, required: commonRequired })
|
setCommonSaving(true);
|
||||||
|
setError('');
|
||||||
|
const body = { drainageFieldId: commonFieldId, reportType: commonReportType, required: commonRequired };
|
||||||
|
const request = editingCommonId ? adminApi.updateCommonReportField(editingCommonId, body) : adminApi.createCommonReportField(body);
|
||||||
|
request
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setCommonFieldId('');
|
setCommonFieldId('');
|
||||||
setCommonReportType('signature');
|
setCommonReportType('signature');
|
||||||
@@ -101,7 +107,17 @@ export function AdminDrainageFieldsPage() {
|
|||||||
setConfiguringCommon(false);
|
setConfiguringCommon(false);
|
||||||
loadData();
|
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() {
|
function deleteCommonField() {
|
||||||
@@ -151,11 +167,11 @@ export function AdminDrainageFieldsPage() {
|
|||||||
<h2>通用字段配置</h2>
|
<h2>通用字段配置</h2>
|
||||||
<p>企业新增或编辑签名、引流信息时必须按这里的配置填写,通道字段也可以直接引用。</p>
|
<p>企业新增或编辑签名、引流信息时必须按这里的配置填写,通道字段也可以直接引用。</p>
|
||||||
</div>
|
</div>
|
||||||
<Button icon={<Plus size={16} />} onClick={() => setConfiguringCommon(true)} size="sm" variant="secondary">配置通用字段</Button>
|
<Button icon={<Plus size={16} />} onClick={() => openCommonField()} size="sm" variant="secondary">配置通用字段</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-drainage-common-grid">
|
<div className="admin-drainage-common-grid">
|
||||||
<CommonFieldGroup fields={signatureCommon} label="签名报备资料" onDelete={setCommonDeleteTarget} tone="info" />
|
<CommonFieldGroup fields={signatureCommon} label="签名报备资料" onEdit={openCommonField} onDelete={setCommonDeleteTarget} tone="info" />
|
||||||
<CommonFieldGroup fields={drainageCommon} label="引流信息报备资料" onDelete={setCommonDeleteTarget} tone="warning" />
|
<CommonFieldGroup fields={drainageCommon} label="引流信息报备资料" onEdit={openCommonField} onDelete={setCommonDeleteTarget} tone="warning" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -172,15 +188,17 @@ export function AdminDrainageFieldsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
footer={<><Button onClick={() => setConfiguringCommon(false)} variant="ghost">取消</Button><Button disabled={!commonFieldId} onClick={createCommonField}>保存</Button></>}
|
footer={<><Button disabled={commonSaving} onClick={() => setConfiguringCommon(false)} variant="ghost">取消</Button><Button disabled={!commonFieldId || commonSaving} onClick={createCommonField}>{commonSaving ? '保存中...' : '保存'}</Button></>}
|
||||||
onClose={() => setConfiguringCommon(false)}
|
onClose={() => setConfiguringCommon(false)}
|
||||||
open={configuringCommon}
|
open={configuringCommon}
|
||||||
title="配置通用字段"
|
title={editingCommonId ? '修改通用字段配置' : '配置通用字段'}
|
||||||
>
|
>
|
||||||
<div className="admin-system-modal-form">
|
<div className="admin-system-modal-form">
|
||||||
<Select label="报备字段" onChange={(event) => setCommonFieldId(event.target.value)} options={[{ label: '请选择字段', value: '' }, ...fields.filter((field) => field.status !== 'deleted').map((field) => ({ label: `${field.name}(${field.code})`, value: field.id }))]} value={commonFieldId} />
|
<Select label="报备字段" onChange={(event) => setCommonFieldId(event.target.value)} options={[{ label: '请选择字段', value: '' }, ...fields.filter((field) => field.status !== 'deleted').map((field) => ({ label: `${field.name}(${field.code})`, value: field.id }))]} value={commonFieldId} />
|
||||||
<Select label="资料用途" onChange={(event) => setCommonReportType(event.target.value as 'signature' | 'drainage')} options={[{ label: '签名报备资料', value: 'signature' }, { label: '引流信息报备资料', value: 'drainage' }]} value={commonReportType} />
|
<Select label="资料用途" onChange={(event) => setCommonReportType(event.target.value as 'signature' | 'drainage')} options={[{ label: '签名报备资料', value: 'signature' }, { label: '引流信息报备资料', value: 'drainage' }]} value={commonReportType} />
|
||||||
<Select label="是否必填" onChange={(event) => setCommonRequired(event.target.value === 'true')} options={[{ label: '选填', value: 'false' }, { label: '必填', value: 'true' }]} value={String(commonRequired)} />
|
<Select label="是否必填" onChange={(event) => setCommonRequired(event.target.value === 'true')} options={[{ label: '选填', value: 'false' }, { label: '必填', value: 'true' }]} value={String(commonRequired)} />
|
||||||
|
<p>修改后用于后续新增、编辑时的资料要求;历史报备资料和要求快照保持不变。</p>
|
||||||
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
@@ -237,6 +255,6 @@ export function AdminDrainageFieldsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommonFieldGroup({ fields, label, onDelete, tone }: { fields: CommonReportField[]; label: string; onDelete: (field: CommonReportField) => void; tone: 'info' | 'warning' }) {
|
function CommonFieldGroup({ fields, label, onEdit, onDelete, tone }: { fields: CommonReportField[]; label: string; onEdit: (field: CommonReportField) => void; onDelete: (field: CommonReportField) => void; tone: 'info' | 'warning' }) {
|
||||||
return <section className="admin-drainage-common-group"><div className="admin-drainage-common-group__title"><Tag tone={tone}>{label}</Tag><span>{fields.length} 项</span></div>{fields.length ? <div className="admin-drainage-common-list">{fields.map((field) => <div key={field.id}><div><strong>{String(field.drainageField.name ?? field.drainageField.code)}</strong><span>{String(field.drainageField.code)} · {typeLabels[String(field.drainageField.fieldType ?? '')] ?? '-'}</span></div><Tag tone={field.required ? 'warning' : 'neutral'}>{field.required ? '必填' : '选填'}</Tag><Button aria-label="删除通用字段" icon={<Trash2 size={14} />} iconOnly onClick={() => onDelete(field)} size="sm" variant="ghost">删除</Button></div>)}</div> : <p className="admin-drainage-common-empty">暂未配置字段</p>}</section>;
|
return <section className="admin-drainage-common-group"><div className="admin-drainage-common-group__title"><Tag tone={tone}>{label}</Tag><span>{fields.length} 项</span></div>{fields.length ? <div className="admin-drainage-common-list">{fields.map((field) => <div key={field.id}><div><strong>{String(field.drainageField.name ?? field.drainageField.code)}</strong><span>{String(field.drainageField.code)} · {typeLabels[String(field.drainageField.fieldType ?? '')] ?? '-'}</span></div><Tag tone={field.required ? 'warning' : 'neutral'}>{field.required ? '必填' : '选填'}</Tag><Button aria-label={`修改通用字段${field.drainageField.name}`} icon={<Edit3 size={14} />} onClick={() => onEdit(field)} size="sm" variant="ghost">修改</Button><Button aria-label="删除通用字段" icon={<Trash2 size={14} />} iconOnly onClick={() => onDelete(field)} size="sm" variant="ghost">删除</Button></div>)}</div> : <p className="admin-drainage-common-empty">暂未配置字段</p>}</section>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,15 +31,21 @@ export function SignatureFormModal({
|
|||||||
telecom: payload?.carrierStatus.telecom ?? 'filing',
|
telecom: payload?.carrierStatus.telecom ?? 'filing',
|
||||||
reportValues: payload?.signatureReportValues ?? {},
|
reportValues: payload?.signatureReportValues ?? {},
|
||||||
});
|
});
|
||||||
const [reportFields, setReportFields] = useState<ApplicationReportField[]>([]);
|
const [fieldResult, setFieldResult] = useState<{ applicationId: string; fields: ApplicationReportField[]; error: string } | null>(null);
|
||||||
|
const fieldsLoading = fieldResult?.applicationId !== form.applicationId;
|
||||||
|
const reportFields = fieldsLoading ? [] : fieldResult?.fields ?? [];
|
||||||
|
const fieldsError = fieldsLoading ? '' : fieldResult?.error ?? '';
|
||||||
const [nameInputError, setNameInputError] = useState('');
|
const [nameInputError, setNameInputError] = useState('');
|
||||||
const tenantApplications = applications.filter((application) => application.tenantId === form.tenantId && application.status !== 'deleted');
|
const tenantApplications = applications.filter((application) => application.tenantId === form.tenantId && application.status !== 'deleted');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
const request = form.applicationId
|
const request = form.applicationId
|
||||||
? adminApi.listApplicationReportFields(form.applicationId, 'signature')
|
? adminApi.listApplicationReportFields(form.applicationId, 'signature')
|
||||||
: adminApi.listCommonApplicationReportFields('signature');
|
: adminApi.listCommonApplicationReportFields('signature');
|
||||||
request.then(setReportFields).catch(() => setReportFields([]));
|
request.then((items) => { if (active) setFieldResult({ applicationId: form.applicationId, fields: items, error: '' }); })
|
||||||
|
.catch((failure: Error) => { if (active) setFieldResult({ applicationId: form.applicationId, fields: [], error: failure.message || '报备资料要求加载失败' }); });
|
||||||
|
return () => { active = false; };
|
||||||
}, [form.applicationId]);
|
}, [form.applicationId]);
|
||||||
|
|
||||||
function update<Key extends keyof SignatureFormState>(key: Key, value: SignatureFormState[Key]) {
|
function update<Key extends keyof SignatureFormState>(key: Key, value: SignatureFormState[Key]) {
|
||||||
@@ -58,7 +64,7 @@ export function SignatureFormModal({
|
|||||||
footer={(
|
footer={(
|
||||||
<>
|
<>
|
||||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||||
<Button disabled={!form.tenantId || !signatureNameValid || hasMissingRequiredReportValue(reportFields, form.reportValues)} onClick={() => onSubmit(form)}>保存</Button>
|
<Button disabled={!form.tenantId || !signatureNameValid || fieldsLoading || Boolean(fieldsError) || hasMissingRequiredReportValue(reportFields, form.reportValues)} onClick={() => onSubmit(form)}>保存</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
@@ -72,6 +78,7 @@ export function SignatureFormModal({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="signature-form">
|
<div className="signature-form">
|
||||||
|
{fieldsLoading ? <p>正在加载报备资料要求...</p> : fieldsError ? <p className="form-error">{fieldsError}</p> : null}
|
||||||
<section>
|
<section>
|
||||||
<h3>基本信息</h3>
|
<h3>基本信息</h3>
|
||||||
<div className="signature-alert">
|
<div className="signature-alert">
|
||||||
|
|||||||
@@ -59,8 +59,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
grid-template-columns: 96px 96px minmax(360px, 1fr) 96px 112px 36px;
|
grid-template-columns: 96px 96px minmax(360px, 1fr) 112px 36px;
|
||||||
min-width: 960px;
|
min-width: 850px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-sms-record-list__header {
|
.admin-sms-record-list__header {
|
||||||
@@ -110,7 +110,8 @@
|
|||||||
color: #92400e;
|
color: #92400e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-sms-record-content mark {
|
.admin-sms-record-content mark,
|
||||||
|
.admin-sms-detail-content mark {
|
||||||
background: color-mix(in srgb, #f59e0b 32%, transparent);
|
background: color-mix(in srgb, #f59e0b 32%, transparent);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
@@ -122,11 +123,25 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
margin-left: var(--space-2);
|
|
||||||
padding: 1px var(--space-2);
|
padding: 1px var(--space-2);
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-sms-record-status-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sms-record-filter__actions .ui-query-buttons {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sms-record-filter__actions .ui-query-buttons > button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-sms-record-drainage-badge.is-yes {
|
.admin-sms-record-drainage-badge.is-yes {
|
||||||
background: color-mix(in srgb, #f59e0b 18%, transparent);
|
background: color-mix(in srgb, #f59e0b 18%, transparent);
|
||||||
color: #92400e;
|
color: #92400e;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { AlertTriangle, Info, MessageSquare } from 'lucide-react';
|
import { AlertTriangle, Info, MessageSquare } from 'lucide-react';
|
||||||
import type { SmsMessageRecord, SmsMessageSegmentAudit } from '@/api/adminApi';
|
import type { SmsMessageRecord, SmsMessageSegmentAudit } from '@/api/adminApi';
|
||||||
import { Button, CarrierTag, Modal, Tag } from '@/components/ui';
|
import { Button, CarrierTag, Modal, Tag } from '@/components/ui';
|
||||||
|
import { DrainageContent } from './SmsRecordList';
|
||||||
import {
|
import {
|
||||||
buildRouteRows,
|
buildRouteRows,
|
||||||
getReceiptNotice,
|
getReceiptNotice,
|
||||||
@@ -49,6 +50,8 @@ export function SendDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
<div><span>提交状态</span><strong>{record.submitStatus ?? '-'}</strong></div>
|
<div><span>提交状态</span><strong>{record.submitStatus ?? '-'}</strong></div>
|
||||||
<div><span>回执状态</span><strong>{record.receiptStatus ?? '-'}</strong></div>
|
<div><span>回执状态</span><strong>{record.receiptStatus ?? '-'}</strong></div>
|
||||||
|
<div><span>最终回执时间</span><strong>{getTime(record.deliveredAt)}</strong></div>
|
||||||
|
<div><span>引流信息</span><Tag tone={record.hasDrainageContent === true ? 'warning' : 'neutral'}>{record.hasDrainageContent === true ? '含引流' : record.hasDrainageContent === false ? '不含引流' : '未检测'}</Tag></div>
|
||||||
<div><span>提交时间</span><strong>{getTime(record.queuedAt)}</strong></div>
|
<div><span>提交时间</span><strong>{getTime(record.queuedAt)}</strong></div>
|
||||||
<div><span>发送号码</span><strong>{record.phoneNumber || '-'}</strong></div>
|
<div><span>发送号码</span><strong>{record.phoneNumber || '-'}</strong></div>
|
||||||
<div><span>号码归属</span><strong>{record.province ?? '-'} / {record.carrier ? <CarrierTag carrier={record.carrier} /> : '-'}</strong></div>
|
<div><span>号码归属</span><strong>{record.province ?? '-'} / {record.carrier ? <CarrierTag carrier={record.carrier} /> : '-'}</strong></div>
|
||||||
@@ -64,7 +67,7 @@ export function SendDetailModal({
|
|||||||
) : null}
|
) : null}
|
||||||
<section>
|
<section>
|
||||||
<h3><MessageSquare size={18} /> 短信内容</h3>
|
<h3><MessageSquare size={18} /> 短信内容</h3>
|
||||||
<p className="admin-sms-detail-content">{record.content}</p>
|
<p className="admin-sms-detail-content"><DrainageContent record={record} /></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Search, Smartphone } from 'lucide-react';
|
import { Smartphone } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Button,
|
QueryButtons,
|
||||||
DateRangeInput,
|
DateRangeInput,
|
||||||
Input,
|
Input,
|
||||||
Select,
|
Select,
|
||||||
@@ -95,8 +95,7 @@ export function SmsRecordFilter({
|
|||||||
<div className="admin-sms-record-filter__field is-status"><Select label="发送状态" onChange={(event) => onStatusChange(event.target.value)} options={statusOptions} value={status} /></div>
|
<div className="admin-sms-record-filter__field is-status"><Select label="发送状态" onChange={(event) => onStatusChange(event.target.value)} options={statusOptions} value={status} /></div>
|
||||||
<div className="admin-sms-record-filter__field is-drainage"><Select label="是否含引流信息" onChange={(event) => onHasDrainageChange(event.target.value)} options={drainageOptions} value={hasDrainage} /></div>
|
<div className="admin-sms-record-filter__field is-drainage"><Select label="是否含引流信息" onChange={(event) => onHasDrainageChange(event.target.value)} options={drainageOptions} value={hasDrainage} /></div>
|
||||||
<div className="admin-sms-record-filter__actions">
|
<div className="admin-sms-record-filter__actions">
|
||||||
<Button icon={<Search size={16} />} onClick={onQuery}>查询</Button>
|
<QueryButtons onQuery={onQuery} onReset={onReset} />
|
||||||
<Button onClick={onReset} variant="ghost">重置</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function StatusLine({ record }: { record: SmsMessageRecord }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DrainageContent({ record }: { record: SmsMessageRecord }) {
|
export function DrainageContent({ record }: { record: SmsMessageRecord }) {
|
||||||
const ranges = (record.drainageDetection?.matches ?? [])
|
const ranges = (record.drainageDetection?.matches ?? [])
|
||||||
.filter((match) => Number.isInteger(match.start) && Number.isInteger(match.end) && match.start >= 0 && match.end > match.start && match.end <= record.content.length)
|
.filter((match) => Number.isInteger(match.start) && Number.isInteger(match.end) && match.start >= 0 && match.end > match.start && match.end <= record.content.length)
|
||||||
.sort((a, b) => a.start - b.start || a.end - b.end)
|
.sort((a, b) => a.start - b.start || a.end - b.end)
|
||||||
@@ -70,7 +70,7 @@ export function SmsRecordList({
|
|||||||
{loading ? <div className="ui-table__empty">正在加载真实短信记录...</div> : records.length === 0 ? <div className="ui-table__empty">暂无短信记录</div> : (
|
{loading ? <div className="ui-table__empty">正在加载真实短信记录...</div> : records.length === 0 ? <div className="ui-table__empty">暂无短信记录</div> : (
|
||||||
<>
|
<>
|
||||||
<div aria-hidden="true" className="admin-sms-record-list__header">
|
<div aria-hidden="true" className="admin-sms-record-list__header">
|
||||||
<span>状态</span><span>提交时间</span><span>短信内容及业务信息</span><span>回执时间</span><span>计费</span><span>详情</span>
|
<span>状态</span><span>提交时间</span><span>短信内容及业务信息</span><span>计费</span><span>详情</span>
|
||||||
</div>
|
</div>
|
||||||
{records.map((record) => {
|
{records.map((record) => {
|
||||||
const submitDate = getDate(record.queuedAt);
|
const submitDate = getDate(record.queuedAt);
|
||||||
@@ -80,16 +80,16 @@ export function SmsRecordList({
|
|||||||
<div className="admin-sms-record-group" key={record.id}>
|
<div className="admin-sms-record-group" key={record.id}>
|
||||||
{showDateGroup ? <div className="admin-sms-record-group-title">{submitDate}</div> : null}
|
{showDateGroup ? <div className="admin-sms-record-group-title">{submitDate}</div> : null}
|
||||||
<article className="admin-sms-record-card">
|
<article className="admin-sms-record-card">
|
||||||
<StatusLine record={record} />
|
<div className="admin-sms-record-status-stack">
|
||||||
|
<StatusLine record={record} />
|
||||||
|
{record.hasDrainageContent === true ? <span className="admin-sms-record-drainage-badge is-yes">含引流</span> : null}
|
||||||
|
</div>
|
||||||
<time className="admin-sms-record-time" dateTime={record.queuedAt}>
|
<time className="admin-sms-record-time" dateTime={record.queuedAt}>
|
||||||
<span>{submitDate}</span><strong>{getClock(record.queuedAt)}</strong>
|
<span>{submitDate}</span><strong>{getClock(record.queuedAt)}</strong>
|
||||||
</time>
|
</time>
|
||||||
<div className="admin-sms-record-main">
|
<div className="admin-sms-record-main">
|
||||||
<p className={`admin-sms-record-content${record.hasDrainageContent ? ' is-drainage' : ''}`}>
|
<p className={`admin-sms-record-content${record.hasDrainageContent ? ' is-drainage' : ''}`}>
|
||||||
<DrainageContent record={record} />
|
<DrainageContent record={record} />
|
||||||
<span className={`admin-sms-record-drainage-badge is-${record.hasDrainageContent === true ? 'yes' : record.hasDrainageContent === false ? 'no' : 'unknown'}`}>
|
|
||||||
{record.hasDrainageContent === true ? '含引流' : record.hasDrainageContent === false ? '不含引流' : '未检测'}
|
|
||||||
</span>
|
|
||||||
</p>
|
</p>
|
||||||
<div className="admin-sms-record-context">
|
<div className="admin-sms-record-context">
|
||||||
<span>{record.tenant?.name ?? record.tenantId ?? '运营端通道测试'} / {record.application?.name ?? record.applicationId ?? '-'}</span>
|
<span>{record.tenant?.name ?? record.tenantId ?? '运营端通道测试'} / {record.application?.name ?? record.applicationId ?? '-'}</span>
|
||||||
@@ -97,9 +97,6 @@ export function SmsRecordList({
|
|||||||
<span>{record.channel?.name ?? record.channelId ?? '-'}</span>
|
<span>{record.channel?.name ?? record.channelId ?? '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<time className="admin-sms-record-time" dateTime={record.deliveredAt ?? undefined}>
|
|
||||||
{record.deliveredAt ? <><span>{getDate(record.deliveredAt)}</span><strong>{getClock(record.deliveredAt)}</strong></> : <span>暂无回执</span>}
|
|
||||||
</time>
|
|
||||||
<div className="admin-sms-record-billing">
|
<div className="admin-sms-record-billing">
|
||||||
<MoneyText>¥{formatCents(record.amountCents)}</MoneyText>
|
<MoneyText>¥{formatCents(record.amountCents)}</MoneyText>
|
||||||
<span>{record.billingUnits} 分片 · {record.content.length} 字</span>
|
<span>{record.billingUnits} 分片 · {record.content.length} 字</span>
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { SmsMessageRecord } from '@/api/adminApi';
|
||||||
|
import { SmsRecordList } from './SmsRecordList';
|
||||||
|
import { SendDetailModal } from './SendDetailModal';
|
||||||
|
|
||||||
|
const record = {
|
||||||
|
id: 'record-1', messageId: 'message-1', content: '请访问example.com查询', hasDrainageContent: true,
|
||||||
|
drainageDetection: { matches: [{ start: 3, end: 14, value: 'example.com' }] },
|
||||||
|
queuedAt: '2026-08-31T01:00:00Z', deliveredAt: '2026-08-31T01:00:05Z', status: 'delivered',
|
||||||
|
amountCents: 5, billingUnits: 1, phoneNumber: '13800138000', submitRecords: [], receiptRecords: [],
|
||||||
|
} as unknown as SmsMessageRecord;
|
||||||
|
|
||||||
|
describe('SMS drainage and final receipt presentation', () => {
|
||||||
|
it('shows only a positive drainage badge under status and removes receipt time from list', () => {
|
||||||
|
const { container } = render(<SmsRecordList currentPage={1} loading={false} records={[record, { ...record, id: 'record-2', hasDrainageContent: false }]} total={2} totalPages={1} onExport={() => {}} onOpenDetail={() => {}} onPageChange={() => {}} />);
|
||||||
|
expect(screen.getAllByText('含引流')).toHaveLength(1);
|
||||||
|
expect(container.querySelector('.admin-sms-record-status-stack .admin-sms-record-drainage-badge')).toHaveTextContent('含引流');
|
||||||
|
expect(screen.queryByText('不含引流')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('回执时间')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('09:00:05')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('highlights backend detection ranges and displays the final receipt time in details', () => {
|
||||||
|
render(<SendDetailModal record={record} segmentAudits={[]} segmentLoading={false} onClose={() => {}} />);
|
||||||
|
expect(screen.getByText('最终回执时间')).toBeVisible();
|
||||||
|
expect(screen.getAllByText('2026-08-31 09:00:05').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText('含引流')).toBeVisible();
|
||||||
|
expect(document.querySelector('.admin-sms-detail-content mark')).toHaveTextContent('example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows negative drainage only in details and does not mislabel untested historical records', () => {
|
||||||
|
const { rerender } = render(<SendDetailModal record={{ ...record, hasDrainageContent: false }} segmentAudits={[]} segmentLoading={false} onClose={() => {}} />);
|
||||||
|
expect(screen.getByText('不含引流')).toBeVisible();
|
||||||
|
expect(document.querySelector('mark')).toBeNull();
|
||||||
|
rerender(<SendDetailModal record={{ ...record, hasDrainageContent: undefined }} segmentAudits={[]} segmentLoading={false} onClose={() => {}} />);
|
||||||
|
expect(screen.getByText('未检测')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -111,12 +111,12 @@ function makeTrendOption(params: {
|
|||||||
suffix: string;
|
suffix: string;
|
||||||
maximum?: number;
|
maximum?: number;
|
||||||
}): EChartsOption {
|
}): EChartsOption {
|
||||||
const first = params.series[0]?.points ?? [];
|
const timestamps = [...new Set(params.series.flatMap((series) => series.points.map((point) => point.timestamp)))].sort();
|
||||||
return {
|
return {
|
||||||
animationDuration: 280,
|
animationDuration: 280,
|
||||||
color: params.series.map((item) => item.color),
|
color: params.series.map((item) => item.color),
|
||||||
grid: { left: 8, right: 16, top: 34, bottom: 4, containLabel: true },
|
grid: { left: 8, right: 16, top: 34, bottom: 4, containLabel: true },
|
||||||
legend: params.series.length > 1 ? { top: 0, right: 0, textStyle: { color: '#6b7280', fontSize: 12 } } : undefined,
|
legend: params.series.length > 1 ? { type: 'scroll', top: 0, left: 0, right: 0, textStyle: { color: '#6b7280', fontSize: 12 } } : undefined,
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
valueFormatter: (value) => `${Number(value).toFixed(1)}${params.suffix}`,
|
valueFormatter: (value) => `${Number(value).toFixed(1)}${params.suffix}`,
|
||||||
@@ -124,7 +124,7 @@ function makeTrendOption(params: {
|
|||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
boundaryGap: false,
|
boundaryGap: false,
|
||||||
data: timeLabels(first, params.range),
|
data: timeLabels(timestamps.map((timestamp) => ({ timestamp, value: 0 })), params.range),
|
||||||
axisLine: { lineStyle: { color: '#e5e7eb' } },
|
axisLine: { lineStyle: { color: '#e5e7eb' } },
|
||||||
axisTick: { show: false },
|
axisTick: { show: false },
|
||||||
axisLabel: { color: '#9ca3af', hideOverlap: true, margin: 12 },
|
axisLabel: { color: '#9ca3af', hideOverlap: true, margin: 12 },
|
||||||
@@ -134,15 +134,18 @@ function makeTrendOption(params: {
|
|||||||
axisLabel: { color: '#9ca3af', formatter: `{value}${params.suffix}` },
|
axisLabel: { color: '#9ca3af', formatter: `{value}${params.suffix}` },
|
||||||
splitLine: { lineStyle: { color: '#eef0f3' } },
|
splitLine: { lineStyle: { color: '#eef0f3' } },
|
||||||
},
|
},
|
||||||
series: params.series.map((item) => ({
|
series: params.series.map((item) => {
|
||||||
|
const values = new Map(item.points.map((point) => [point.timestamp, point.value]));
|
||||||
|
return {
|
||||||
name: item.name,
|
name: item.name,
|
||||||
data: item.points.map((point) => point.value),
|
data: timestamps.map((timestamp) => values.get(timestamp) ?? null),
|
||||||
type: 'line',
|
type: 'line',
|
||||||
smooth: true,
|
smooth: true,
|
||||||
showSymbol: false,
|
showSymbol: false,
|
||||||
lineStyle: { width: 2.5 },
|
lineStyle: { width: 2.5 },
|
||||||
areaStyle: { opacity: 0.07 },
|
areaStyle: { opacity: 0.07 },
|
||||||
})),
|
};
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,8 +281,12 @@ export function AdminSystemMonitoringPage() {
|
|||||||
range, maximum: 100, suffix: '%', series: [{ name: '内存', points: overview?.trends.memoryUsagePercent ?? [], color: '#7c3aed' }],
|
range, maximum: 100, suffix: '%', series: [{ name: '内存', points: overview?.trends.memoryUsagePercent ?? [], color: '#7c3aed' }],
|
||||||
}), [overview?.trends.memoryUsagePercent, range]);
|
}), [overview?.trends.memoryUsagePercent, range]);
|
||||||
const diskOption = useMemo(() => makeTrendOption({
|
const diskOption = useMemo(() => makeTrendOption({
|
||||||
range, maximum: 100, suffix: '%', series: [{ name: '根磁盘', points: overview?.trends.diskUsagePercent ?? [], color: '#d97706' }],
|
range, maximum: 100, suffix: '%', series: (overview?.disks ?? []).map((disk, index) => ({
|
||||||
}), [overview?.trends.diskUsagePercent, range]);
|
name: `${disk.mountpoint} · ${disk.device} · ${disk.instance}`,
|
||||||
|
points: disk.trend,
|
||||||
|
color: ['#d97706', '#2563eb', '#0f766e', '#7c3aed', '#dc2626', '#0891b2'][index % 6],
|
||||||
|
})),
|
||||||
|
}), [overview?.disks, range]);
|
||||||
const networkOption = useMemo(() => makeTrendOption({
|
const networkOption = useMemo(() => makeTrendOption({
|
||||||
range, suffix: ' B/s', series: [
|
range, suffix: ' B/s', series: [
|
||||||
{ name: '接收', points: overview?.trends.networkReceiveBytesPerSecond ?? [], color: '#0f766e' },
|
{ name: '接收', points: overview?.trends.networkReceiveBytesPerSecond ?? [], color: '#0f766e' },
|
||||||
@@ -345,7 +352,11 @@ export function AdminSystemMonitoringPage() {
|
|||||||
<div className="system-monitoring-metrics">
|
<div className="system-monitoring-metrics">
|
||||||
<article className="surface system-monitoring-metric"><div className="system-monitoring-metric__icon is-blue"><Cpu size={19} /></div><div><span>CPU 使用率</span><strong>{formatPercent(metrics?.cpuUsagePercent ?? null)}</strong><small>5分钟平均</small></div></article>
|
<article className="surface system-monitoring-metric"><div className="system-monitoring-metric__icon is-blue"><Cpu size={19} /></div><div><span>CPU 使用率</span><strong>{formatPercent(metrics?.cpuUsagePercent ?? null)}</strong><small>5分钟平均</small></div></article>
|
||||||
<article className="surface system-monitoring-metric"><div className="system-monitoring-metric__icon is-violet"><MemoryStick size={19} /></div><div><span>内存使用率</span><strong>{formatPercent(metrics?.memoryUsagePercent ?? null)}</strong><small>{formatBytes(metrics?.memoryAvailableBytes ?? null)} 可用 / {formatBytes(metrics?.memoryTotalBytes ?? null)}</small></div></article>
|
<article className="surface system-monitoring-metric"><div className="system-monitoring-metric__icon is-violet"><MemoryStick size={19} /></div><div><span>内存使用率</span><strong>{formatPercent(metrics?.memoryUsagePercent ?? null)}</strong><small>{formatBytes(metrics?.memoryAvailableBytes ?? null)} 可用 / {formatBytes(metrics?.memoryTotalBytes ?? null)}</small></div></article>
|
||||||
<article className="surface system-monitoring-metric"><div className="system-monitoring-metric__icon is-amber"><HardDrive size={19} /></div><div><span>根磁盘使用率</span><strong>{formatPercent(metrics?.diskUsagePercent ?? null)}</strong><small>{formatBytes(metrics?.diskAvailableBytes ?? null)} 可用 / {formatBytes(metrics?.diskTotalBytes ?? null)}</small></div></article>
|
{(overview?.disks ?? []).map((disk) => <article className="surface system-monitoring-metric" key={disk.id}>
|
||||||
|
<div className="system-monitoring-metric__icon is-amber"><HardDrive size={19} /></div>
|
||||||
|
<div><span>{disk.mountpoint === '/' ? '系统盘' : '磁盘'} {disk.mountpoint}</span><strong>{formatPercent(disk.usagePercent)}</strong><small title={`${disk.device} · ${disk.filesystem} · ${disk.instance}`}>{disk.device} · {disk.filesystem}</small><small>{formatBytes(disk.availableBytes)} 可用 / {formatBytes(disk.totalBytes)}</small></div>
|
||||||
|
</article>)}
|
||||||
|
{!overview?.disks?.length ? <article className="surface system-monitoring-metric"><HardDrive size={19} /><div><span>磁盘</span><strong>暂无数据</strong></div></article> : null}
|
||||||
<article className="surface system-monitoring-metric"><div className="system-monitoring-metric__icon is-green"><Network size={19} /></div><div><span>网络吞吐</span><strong>{formatRate(totalNetworkRate(metrics?.networkReceiveBytesPerSecond, metrics?.networkTransmitBytesPerSecond))}</strong><small>接收 {formatRate(metrics?.networkReceiveBytesPerSecond ?? null)} · 发送 {formatRate(metrics?.networkTransmitBytesPerSecond ?? null)}</small></div></article>
|
<article className="surface system-monitoring-metric"><div className="system-monitoring-metric__icon is-green"><Network size={19} /></div><div><span>网络吞吐</span><strong>{formatRate(totalNetworkRate(metrics?.networkReceiveBytesPerSecond, metrics?.networkTransmitBytesPerSecond))}</strong><small>接收 {formatRate(metrics?.networkReceiveBytesPerSecond ?? null)} · 发送 {formatRate(metrics?.networkTransmitBytesPerSecond ?? null)}</small></div></article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -353,7 +364,7 @@ export function AdminSystemMonitoringPage() {
|
|||||||
<div className="system-monitoring-chart-stack">
|
<div className="system-monitoring-chart-stack">
|
||||||
<article className="surface system-monitoring-chart-card"><header><div><Cpu size={17} /><strong>CPU 趋势</strong></div><span>{formatPercent(metrics?.cpuUsagePercent ?? null)}</span></header>{overview?.trends.cpuUsagePercent.length ? <Chart height={230} option={cpuOption} /> : <EmptyChart />}</article>
|
<article className="surface system-monitoring-chart-card"><header><div><Cpu size={17} /><strong>CPU 趋势</strong></div><span>{formatPercent(metrics?.cpuUsagePercent ?? null)}</span></header>{overview?.trends.cpuUsagePercent.length ? <Chart height={230} option={cpuOption} /> : <EmptyChart />}</article>
|
||||||
<article className="surface system-monitoring-chart-card"><header><div><MemoryStick size={17} /><strong>内存趋势</strong></div><span>{formatPercent(metrics?.memoryUsagePercent ?? null)}</span></header>{overview?.trends.memoryUsagePercent.length ? <Chart height={230} option={memoryOption} /> : <EmptyChart />}</article>
|
<article className="surface system-monitoring-chart-card"><header><div><MemoryStick size={17} /><strong>内存趋势</strong></div><span>{formatPercent(metrics?.memoryUsagePercent ?? null)}</span></header>{overview?.trends.memoryUsagePercent.length ? <Chart height={230} option={memoryOption} /> : <EmptyChart />}</article>
|
||||||
<article className="surface system-monitoring-chart-card"><header><div><HardDrive size={17} /><strong>磁盘趋势</strong></div><span>{formatPercent(metrics?.diskUsagePercent ?? null)}</span></header>{overview?.trends.diskUsagePercent.length ? <Chart height={230} option={diskOption} /> : <EmptyChart />}</article>
|
<article className="surface system-monitoring-chart-card"><header><div><HardDrive size={17} /><strong>全部磁盘趋势</strong></div><span>{overview?.disks?.length ?? 0} 个挂载点</span></header>{overview?.disks?.some((disk) => disk.trend.length) ? <Chart height={230} option={diskOption} /> : <EmptyChart />}</article>
|
||||||
<article className="surface system-monitoring-chart-card"><header><div><Activity size={17} /><strong>网络趋势</strong></div><span>{formatRate(totalNetworkRate(metrics?.networkReceiveBytesPerSecond, metrics?.networkTransmitBytesPerSecond))}</span></header>{overview?.trends.networkReceiveBytesPerSecond.length ? <Chart height={230} option={networkOption} /> : <EmptyChart />}</article>
|
<article className="surface system-monitoring-chart-card"><header><div><Activity size={17} /><strong>网络趋势</strong></div><span>{formatRate(totalNetworkRate(metrics?.networkReceiveBytesPerSecond, metrics?.networkTransmitBytesPerSecond))}</span></header>{overview?.trends.networkReceiveBytesPerSecond.length ? <Chart height={230} option={networkOption} /> : <EmptyChart />}</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
Pagination,
|
Pagination,
|
||||||
QueryPanel,
|
QueryPanel,
|
||||||
|
QueryButtons,
|
||||||
Select,
|
Select,
|
||||||
Tag,
|
Tag,
|
||||||
type DateRangeValue,
|
type DateRangeValue,
|
||||||
@@ -97,6 +98,7 @@ export function ClientBatchTasksPage() {
|
|||||||
const [keyword, setKeyword] = useState(() => searchParams.get('taskNo') ?? '');
|
const [keyword, setKeyword] = useState(() => searchParams.get('taskNo') ?? '');
|
||||||
const [application, setApplication] = useState('all');
|
const [application, setApplication] = useState('all');
|
||||||
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>(() => recentBeijingDateRange(7));
|
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>(() => recentBeijingDateRange(7));
|
||||||
|
const [applied, setApplied] = useState(() => ({ keyword, application, submittedDateRange }));
|
||||||
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
|
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [selectedTask, setSelectedTask] = useState<BatchTask | null>(null);
|
const [selectedTask, setSelectedTask] = useState<BatchTask | null>(null);
|
||||||
@@ -106,10 +108,10 @@ export function ClientBatchTasksPage() {
|
|||||||
function loadTasks(targetPage = page) {
|
function loadTasks(targetPage = page) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
clientApi.listBatchTasksPage({
|
clientApi.listBatchTasksPage({
|
||||||
keyword: keyword.trim() || undefined,
|
keyword: applied.keyword.trim() || undefined,
|
||||||
applicationKeyword: application === 'all' ? undefined : application,
|
applicationKeyword: applied.application === 'all' ? undefined : applied.application,
|
||||||
createdAtFrom: submittedDateRange.start,
|
createdAtFrom: applied.submittedDateRange.start,
|
||||||
createdAtTo: submittedDateRange.end,
|
createdAtTo: applied.submittedDateRange.end,
|
||||||
page: targetPage,
|
page: targetPage,
|
||||||
pageSize,
|
pageSize,
|
||||||
})
|
})
|
||||||
@@ -124,7 +126,21 @@ export function ClientBatchTasksPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTasks(page);
|
loadTasks(page);
|
||||||
}, [page]);
|
}, [applied, page]);
|
||||||
|
|
||||||
|
function query() {
|
||||||
|
setPage(1);
|
||||||
|
setApplied({ keyword, application, submittedDateRange });
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
const defaults = { keyword: '', application: 'all', submittedDateRange: recentBeijingDateRange(7) };
|
||||||
|
setKeyword('');
|
||||||
|
setApplication('all');
|
||||||
|
setSubmittedDateRange(defaults.submittedDateRange);
|
||||||
|
setPage(1);
|
||||||
|
setApplied(defaults);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clientApi.listApplicationOptions()
|
clientApi.listApplicationOptions()
|
||||||
@@ -236,8 +252,7 @@ export function ClientBatchTasksPage() {
|
|||||||
onChange={(event) => setKeyword(event.target.value)}
|
onChange={(event) => setKeyword(event.target.value)}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
setPage(1);
|
query();
|
||||||
loadTasks(1);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="输入发送批次号搜索"
|
placeholder="输入发送批次号搜索"
|
||||||
@@ -246,7 +261,7 @@ export function ClientBatchTasksPage() {
|
|||||||
/>
|
/>
|
||||||
<Select label="选择应用" onChange={(event) => setApplication(event.target.value)} options={applicationOptions} value={application} />
|
<Select label="选择应用" onChange={(event) => setApplication(event.target.value)} options={applicationOptions} value={application} />
|
||||||
<DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} />
|
<DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} />
|
||||||
<Button onClick={() => { setPage(1); loadTasks(1); }} variant="primary">查询</Button>
|
<QueryButtons onQuery={query} onReset={reset} />
|
||||||
</QueryPanel>
|
</QueryPanel>
|
||||||
|
|
||||||
<div className="surface batch-table-card">
|
<div className="surface batch-table-card">
|
||||||
|
|||||||
@@ -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(<MemoryRouter><Component /></MemoryRouter>);
|
||||||
|
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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
Pagination,
|
Pagination,
|
||||||
QueryPanel,
|
QueryPanel,
|
||||||
|
QueryButtons,
|
||||||
Select,
|
Select,
|
||||||
Tag,
|
Tag,
|
||||||
type DateRangeValue,
|
type DateRangeValue,
|
||||||
@@ -71,6 +72,7 @@ export function ClientSendDetailPage() {
|
|||||||
const [dateRange, setDateRange] = useState<DateRangeValue>(() => recentBeijingDateRange(7));
|
const [dateRange, setDateRange] = useState<DateRangeValue>(() => recentBeijingDateRange(7));
|
||||||
const [contentKeyword, setContentKeyword] = useState('');
|
const [contentKeyword, setContentKeyword] = useState('');
|
||||||
const [phoneKeyword, setPhoneKeyword] = useState('');
|
const [phoneKeyword, setPhoneKeyword] = useState('');
|
||||||
|
const [applied, setApplied] = useState(() => ({ applicationId, status, dateRange, contentKeyword, phoneKeyword }));
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [applications, setApplications] = useState<Array<{ id: string; name: string }>>([]);
|
const [applications, setApplications] = useState<Array<{ id: string; name: string }>>([]);
|
||||||
@@ -80,12 +82,12 @@ export function ClientSendDetailPage() {
|
|||||||
function loadData(targetPage = page) {
|
function loadData(targetPage = page) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
clientApi.listMessages({
|
clientApi.listMessages({
|
||||||
applicationId: applicationId === 'all' ? undefined : applicationId,
|
applicationId: applied.applicationId === 'all' ? undefined : applied.applicationId,
|
||||||
phoneNumber: phoneKeyword || undefined,
|
phoneNumber: applied.phoneKeyword.trim() || undefined,
|
||||||
status: status === 'all' ? undefined : status,
|
status: applied.status === 'all' ? undefined : applied.status,
|
||||||
contentKeyword: contentKeyword || undefined,
|
contentKeyword: applied.contentKeyword.trim() || undefined,
|
||||||
queuedAtFrom: dateRange.start || undefined,
|
queuedAtFrom: applied.dateRange.start || undefined,
|
||||||
queuedAtTo: dateRange.end || undefined,
|
queuedAtTo: applied.dateRange.end || undefined,
|
||||||
page: targetPage,
|
page: targetPage,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
})
|
})
|
||||||
@@ -100,7 +102,7 @@ export function ClientSendDetailPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData(page);
|
loadData(page);
|
||||||
}, [applicationId, contentKeyword, dateRange.end, dateRange.start, page, phoneKeyword, status]);
|
}, [applied, page]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clientApi.listApplicationOptions()
|
clientApi.listApplicationOptions()
|
||||||
@@ -121,9 +123,21 @@ export function ClientSendDetailPage() {
|
|||||||
const currentPage = Math.min(page, totalPages);
|
const currentPage = Math.min(page, totalPages);
|
||||||
const visibleRows = filteredRows;
|
const visibleRows = filteredRows;
|
||||||
|
|
||||||
useEffect(() => {
|
function query() {
|
||||||
setPage(1);
|
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 (
|
return (
|
||||||
<section className="page-stack">
|
<section className="page-stack">
|
||||||
@@ -165,6 +179,7 @@ export function ClientSendDetailPage() {
|
|||||||
prefix={<Smartphone size={16} />}
|
prefix={<Smartphone size={16} />}
|
||||||
value={phoneKeyword}
|
value={phoneKeyword}
|
||||||
/>
|
/>
|
||||||
|
<QueryButtons onQuery={query} onReset={reset} />
|
||||||
</QueryPanel>
|
</QueryPanel>
|
||||||
|
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
|
|||||||
@@ -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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { ClientSignaturesPage } from './ClientSignaturesPage';
|
import { ClientSignaturesPage } from './ClientSignaturesPage';
|
||||||
|
|
||||||
@@ -57,6 +57,7 @@ describe('ClientSignaturesPage drainage presentation', () => {
|
|||||||
clientApi.listApplicationOptions.mockResolvedValue([{ id: 'app-1', name: '测试应用', status: 'active' }]);
|
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.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.listApplicationReportFields.mockResolvedValue([]);
|
||||||
|
clientApi.listCommonApplicationReportFields.mockResolvedValue([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('groups the three real carrier summaries into a readable status area', async () => {
|
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();
|
||||||
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(<ClientSignaturesPage />);
|
||||||
|
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(<ClientSignaturesPage />);
|
||||||
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { 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 { Button, CarrierTag, DeleteRiskAction, FileActions, Input, Modal, Pagination, Select, Textarea } from '@/components/ui';
|
||||||
import {
|
import {
|
||||||
@@ -72,6 +72,7 @@ function ReviewFields({
|
|||||||
? <Input
|
? <Input
|
||||||
key={field.id}
|
key={field.id}
|
||||||
label={`${field.required ? '* ' : ''}${field.name}`}
|
label={`${field.required ? '* ' : ''}${field.name}`}
|
||||||
|
hint={field.description ?? undefined}
|
||||||
onChange={(event) => onChange(field.code, event.target.value)}
|
onChange={(event) => onChange(field.code, event.target.value)}
|
||||||
value={String(values[field.code] ?? '')}
|
value={String(values[field.code] ?? '')}
|
||||||
/>
|
/>
|
||||||
@@ -103,7 +104,10 @@ function SignatureModal({
|
|||||||
}) {
|
}) {
|
||||||
const [applicationId, setApplicationId] = useState(signature?.applicationId ?? '');
|
const [applicationId, setApplicationId] = useState(signature?.applicationId ?? '');
|
||||||
const [name, setName] = useState(signature?.name ?? '');
|
const [name, setName] = useState(signature?.name ?? '');
|
||||||
const [fields, setFields] = useState<ClientApplicationReportField[]>([]);
|
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<Record<string, unknown>>(signature?.reportValues ?? {});
|
const [values, setValues] = useState<Record<string, unknown>>(signature?.reportValues ?? {});
|
||||||
const [uploadingCode, setUploadingCode] = useState('');
|
const [uploadingCode, setUploadingCode] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -111,10 +115,13 @@ function SignatureModal({
|
|||||||
const [nameInputError, setNameInputError] = useState('');
|
const [nameInputError, setNameInputError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
const request = applicationId
|
const request = applicationId
|
||||||
? clientApi.listApplicationReportFields(applicationId, 'signature')
|
? clientApi.listApplicationReportFields(applicationId, 'signature')
|
||||||
: clientApi.listCommonApplicationReportFields('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]);
|
}, [applicationId]);
|
||||||
|
|
||||||
async function upload(field: ClientApplicationReportField, file?: File) {
|
async function upload(field: ClientApplicationReportField, file?: File) {
|
||||||
@@ -132,6 +139,7 @@ function SignatureModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
|
if (fieldsLoading || fieldsError) return;
|
||||||
if (!isCompleteSmsSignature(name)) {
|
if (!isCompleteSmsSignature(name)) {
|
||||||
setError(getSmsSignatureValidationError(name) ?? '短信签名格式不正确');
|
setError(getSmsSignatureValidationError(name) ?? '短信签名格式不正确');
|
||||||
return;
|
return;
|
||||||
@@ -157,7 +165,7 @@ function SignatureModal({
|
|||||||
const signatureNameValid = !nameInputError && isCompleteSmsSignature(name);
|
const signatureNameValid = !nameInputError && isCompleteSmsSignature(name);
|
||||||
const signatureNameError = nameInputError || (name ? getSmsSignatureValidationError(name) : undefined);
|
const signatureNameError = nameInputError || (name ? getSmsSignatureValidationError(name) : undefined);
|
||||||
return <Modal
|
return <Modal
|
||||||
footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={!signatureNameValid || missingRequired || saving || Boolean(uploadingCode)} onClick={() => void save()}>{saving ? '提交中...' : '提交审核'}</Button></>}
|
footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={!signatureNameValid || missingRequired || saving || fieldsLoading || Boolean(fieldsError) || Boolean(uploadingCode)} onClick={() => void save()}>{saving ? '提交中...' : '提交审核'}</Button></>}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
open
|
open
|
||||||
size="xl"
|
size="xl"
|
||||||
@@ -190,7 +198,7 @@ function SignatureModal({
|
|||||||
/>
|
/>
|
||||||
<section className="client-signature-form-section">
|
<section className="client-signature-form-section">
|
||||||
<div><h3>审核资料</h3><p>请按要求填写或上传,资料仅用于签名审核。</p></div>
|
<div><h3>审核资料</h3><p>请按要求填写或上传,资料仅用于签名审核。</p></div>
|
||||||
<ReviewFields fields={fields} onChange={(code, value) => setValues((current) => ({ ...current, [code]: value }))} onUpload={(field, file) => void upload(field, file)} uploadingCode={uploadingCode} values={values} />
|
{fieldsLoading ? <p>正在加载报备资料要求...</p> : fieldsError ? <p className="form-error">{fieldsError}</p> : <ReviewFields fields={fields} onChange={(code, value) => setValues((current) => ({ ...current, [code]: value }))} onUpload={(field, file) => void upload(field, file)} uploadingCode={uploadingCode} values={values} />}
|
||||||
</section>
|
</section>
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -263,6 +271,7 @@ function DrainageModal({ item, signature, onClose, onSaved }: { item?: ClientDra
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ClientSignaturesPage() {
|
export function ClientSignaturesPage() {
|
||||||
|
const requestSequence = useRef(0);
|
||||||
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
||||||
const [workspace, setWorkspace] = useState<ClientSignatureWorkspace>(EMPTY_WORKSPACE);
|
const [workspace, setWorkspace] = useState<ClientSignatureWorkspace>(EMPTY_WORKSPACE);
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
@@ -279,6 +288,7 @@ export function ClientSignaturesPage() {
|
|||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
|
|
||||||
function loadData(targetPage = page) {
|
function loadData(targetPage = page) {
|
||||||
|
const sequence = ++requestSequence.current;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
Promise.all([
|
Promise.all([
|
||||||
clientApi.listApplicationOptions(),
|
clientApi.listApplicationOptions(),
|
||||||
@@ -290,12 +300,13 @@ export function ClientSignaturesPage() {
|
|||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
.then(([applicationItems, signatureWorkspace]) => {
|
.then(([applicationItems, signatureWorkspace]) => {
|
||||||
|
if (sequence !== requestSequence.current) return;
|
||||||
setApplications(applicationItems.filter((item) => item.status === 'active'));
|
setApplications(applicationItems.filter((item) => item.status === 'active'));
|
||||||
setWorkspace(signatureWorkspace);
|
setWorkspace(signatureWorkspace);
|
||||||
setError('');
|
setError('');
|
||||||
})
|
})
|
||||||
.catch((failure: Error) => setError(failure.message || '签名与引流信息加载失败'))
|
.catch((failure: Error) => { if (sequence === requestSequence.current) setError(failure.message || '签名与引流信息加载失败'); })
|
||||||
.finally(() => setLoading(false));
|
.finally(() => { if (sequence === requestSequence.current) setLoading(false); });
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ function TemplateModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ClientTemplatesPage() {
|
export function ClientTemplatesPage() {
|
||||||
|
const requestSequence = useRef(0);
|
||||||
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
||||||
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
|
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
|
||||||
const [signatures, setSignatures] = useState<ClientSmsSignatureView[]>([]);
|
const [signatures, setSignatures] = useState<ClientSmsSignatureView[]>([]);
|
||||||
@@ -221,17 +222,19 @@ export function ClientTemplatesPage() {
|
|||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
|
|
||||||
function loadData(targetPage = page) {
|
function loadData(targetPage = page) {
|
||||||
|
const sequence = ++requestSequence.current;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
Promise.all([clientApi.listApplicationOptions(), clientApi.listTemplatesPage({ includeHistory: true, keyword: keyword.trim() || undefined, page: targetPage, pageSize }), clientApi.listSignatureOptions()])
|
Promise.all([clientApi.listApplicationOptions(), clientApi.listTemplatesPage({ includeHistory: true, keyword: keyword.trim() || undefined, page: targetPage, pageSize }), clientApi.listSignatureOptions()])
|
||||||
.then(([applicationItems, templateResult, signatureItems]) => {
|
.then(([applicationItems, templateResult, signatureItems]) => {
|
||||||
|
if (sequence !== requestSequence.current) return;
|
||||||
setApplications(applicationItems.filter((item) => item.status === 'active'));
|
setApplications(applicationItems.filter((item) => item.status === 'active'));
|
||||||
setTemplates(templateResult.items.filter((item) => item.auditStatus !== 'deleted' && item.auditStatus !== 'disabled'));
|
setTemplates(templateResult.items.filter((item) => item.auditStatus !== 'deleted' && item.auditStatus !== 'disabled'));
|
||||||
setTotal(templateResult.total);
|
setTotal(templateResult.total);
|
||||||
setSignatures(signatureItems);
|
setSignatures(signatureItems);
|
||||||
setError('');
|
setError('');
|
||||||
})
|
})
|
||||||
.catch((reason: Error) => setError(reason.message || '短信模板加载失败'))
|
.catch((reason: Error) => { if (sequence === requestSequence.current) setError(reason.message || '短信模板加载失败'); })
|
||||||
.finally(() => setLoading(false));
|
.finally(() => { if (sequence === requestSequence.current) setLoading(false); });
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
Pagination,
|
Pagination,
|
||||||
QueryPanel,
|
QueryPanel,
|
||||||
|
QueryButtons,
|
||||||
Table,
|
Table,
|
||||||
type DateRangeValue,
|
type DateRangeValue,
|
||||||
type TableColumn,
|
type TableColumn,
|
||||||
@@ -30,6 +31,7 @@ export function ClientUplinkMessagesPage() {
|
|||||||
const [phoneKeyword, setPhoneKeyword] = useState('');
|
const [phoneKeyword, setPhoneKeyword] = useState('');
|
||||||
const [contentKeyword, setContentKeyword] = useState('');
|
const [contentKeyword, setContentKeyword] = useState('');
|
||||||
const [dateRange, setDateRange] = useState<DateRangeValue>(() => recentBeijingDateRange(7));
|
const [dateRange, setDateRange] = useState<DateRangeValue>(() => recentBeijingDateRange(7));
|
||||||
|
const [applied, setApplied] = useState(() => ({ phoneKeyword, contentKeyword, dateRange }));
|
||||||
const [selectedMessage, setSelectedMessage] = useState<SmsUplinkMessage | null>(null);
|
const [selectedMessage, setSelectedMessage] = useState<SmsUplinkMessage | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [matching, setMatching] = useState(false);
|
const [matching, setMatching] = useState(false);
|
||||||
@@ -41,7 +43,7 @@ export function ClientUplinkMessagesPage() {
|
|||||||
|
|
||||||
function loadData(targetPage = page) {
|
function loadData(targetPage = page) {
|
||||||
setLoading(true);
|
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) => {
|
.then((result) => {
|
||||||
setMessages(result.items);
|
setMessages(result.items);
|
||||||
setTotal(result.total);
|
setTotal(result.total);
|
||||||
@@ -68,14 +70,22 @@ export function ClientUplinkMessagesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1);
|
loadData(page);
|
||||||
const timer = window.setTimeout(() => loadData(1), 300);
|
}, [applied, page]);
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [phoneKeyword, contentKeyword, dateRange.start, dateRange.end]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
function query() {
|
||||||
if (page > 1) loadData(page);
|
setPage(1);
|
||||||
}, [page]);
|
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<Array<TableColumn<SmsUplinkMessage>>>(() => [
|
const columns = useMemo<Array<TableColumn<SmsUplinkMessage>>>(() => [
|
||||||
{ key: 'phoneNumber', title: '手机号码', width: '180px', render: (record) => <strong>{record.phoneNumber}</strong> },
|
{ key: 'phoneNumber', title: '手机号码', width: '180px', render: (record) => <strong>{record.phoneNumber}</strong> },
|
||||||
@@ -119,6 +129,7 @@ export function ClientUplinkMessagesPage() {
|
|||||||
prefix={<Search size={16} />}
|
prefix={<Search size={16} />}
|
||||||
value={contentKeyword}
|
value={contentKeyword}
|
||||||
/>
|
/>
|
||||||
|
<QueryButtons onQuery={query} onReset={reset} />
|
||||||
</QueryPanel>
|
</QueryPanel>
|
||||||
|
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
|
|||||||
@@ -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 <div className="ui-query-buttons" style={{ display: 'flex', gap: 'var(--space-3)', alignItems: 'end' }}>
|
||||||
|
<Button icon={<Search size={16} />} onClick={onQuery}>查询</Button>
|
||||||
|
<Button onClick={onReset} variant="ghost">重置</Button>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ export { Modal } from './Modal';
|
|||||||
export { MoneyText } from './MoneyText';
|
export { MoneyText } from './MoneyText';
|
||||||
export { ClientLoginCanvas } from './ClientLoginCanvas';
|
export { ClientLoginCanvas } from './ClientLoginCanvas';
|
||||||
export { InlineTextPreview, Pagination, QueryPanel } from './PagePrimitives';
|
export { InlineTextPreview, Pagination, QueryPanel } from './PagePrimitives';
|
||||||
|
export { QueryButtons } from './QueryButtons';
|
||||||
export { Select } from './Select';
|
export { Select } from './Select';
|
||||||
export { Table } from './Table';
|
export { Table } from './Table';
|
||||||
export { Tabs } from './Tabs';
|
export { Tabs } from './Tabs';
|
||||||
|
|||||||
@@ -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 { 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-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 { 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 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-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)); }
|
.admin-drainage-field-grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
|
||||||
|
|||||||
@@ -108,50 +108,50 @@ groups:
|
|||||||
threshold: "95%"
|
threshold: "95%"
|
||||||
|
|
||||||
- alert: HostRootDiskUsageWarning
|
- 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
|
for: 15m
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
service: host
|
service: host
|
||||||
annotations:
|
annotations:
|
||||||
summary: 根文件系统空间不足
|
summary: 磁盘文件系统空间不足
|
||||||
description: 根文件系统使用率连续15分钟高于80%。
|
description: "磁盘文件系统使用率连续15分钟高于80%。 挂载点:{{ $labels.mountpoint }};设备:{{ $labels.device }}。"
|
||||||
currentValue: "{{ printf \"%.1f\" $value }}%"
|
currentValue: "{{ printf \"%.1f\" $value }}%"
|
||||||
threshold: "80%"
|
threshold: "80%"
|
||||||
|
|
||||||
- alert: HostRootDiskUsageCritical
|
- 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
|
for: 5m
|
||||||
labels:
|
labels:
|
||||||
severity: critical
|
severity: critical
|
||||||
service: host
|
service: host
|
||||||
annotations:
|
annotations:
|
||||||
summary: 根文件系统空间严重不足
|
summary: 磁盘文件系统空间严重不足
|
||||||
description: 根文件系统使用率连续5分钟高于90%。
|
description: "磁盘文件系统使用率连续5分钟高于90%。 挂载点:{{ $labels.mountpoint }};设备:{{ $labels.device }}。"
|
||||||
currentValue: "{{ printf \"%.1f\" $value }}%"
|
currentValue: "{{ printf \"%.1f\" $value }}%"
|
||||||
threshold: "90%"
|
threshold: "90%"
|
||||||
|
|
||||||
- alert: HostRootInodeUsageWarning
|
- 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
|
for: 15m
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
service: host
|
service: host
|
||||||
annotations:
|
annotations:
|
||||||
summary: 根文件系统inode余量偏低
|
summary: 磁盘文件系统inode余量偏低
|
||||||
description: 根文件系统inode使用率连续15分钟高于80%。
|
description: "磁盘文件系统inode使用率连续15分钟高于80%。 挂载点:{{ $labels.mountpoint }};设备:{{ $labels.device }}。"
|
||||||
currentValue: "{{ printf \"%.1f\" $value }}%"
|
currentValue: "{{ printf \"%.1f\" $value }}%"
|
||||||
threshold: "80%"
|
threshold: "80%"
|
||||||
|
|
||||||
- alert: HostRootInodeUsageCritical
|
- 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
|
for: 5m
|
||||||
labels:
|
labels:
|
||||||
severity: critical
|
severity: critical
|
||||||
service: host
|
service: host
|
||||||
annotations:
|
annotations:
|
||||||
summary: 根文件系统inode严重不足
|
summary: 磁盘文件系统inode严重不足
|
||||||
description: 根文件系统inode使用率连续5分钟高于90%。
|
description: "磁盘文件系统inode使用率连续5分钟高于90%。 挂载点:{{ $labels.mountpoint }};设备:{{ $labels.device }}。"
|
||||||
currentValue: "{{ printf \"%.1f\" $value }}%"
|
currentValue: "{{ printf \"%.1f\" $value }}%"
|
||||||
threshold: "90%"
|
threshold: "90%"
|
||||||
|
|
||||||
|
|||||||
@@ -22,15 +22,15 @@ groups:
|
|||||||
labels: { severity: critical, service: host }
|
labels: { severity: critical, service: host }
|
||||||
annotations: { summary: "主机内存使用率达到严重阈值", description: "主机内存使用率持续超过95%。", currentValue: "{{ $value }}", threshold: "95%" }
|
annotations: { summary: "主机内存使用率达到严重阈值", description: "主机内存使用率持续超过95%。", currentValue: "{{ $value }}", threshold: "95%" }
|
||||||
- alert: HostRootDiskUsageWarning
|
- 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
|
for: 15m
|
||||||
labels: { severity: warning, service: host }
|
labels: { severity: warning, service: host }
|
||||||
annotations: { summary: "根磁盘使用率达到警告阈值", description: "根磁盘使用率持续超过80%。", currentValue: "{{ $value }}", threshold: "80%" }
|
annotations: { summary: "磁盘(所有挂载点)使用率达到警告阈值", description: "磁盘(所有挂载点)使用率持续超过80%。", currentValue: "{{ $value }}", threshold: "80%" }
|
||||||
- alert: HostRootDiskUsageCritical
|
- 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
|
for: 5m
|
||||||
labels: { severity: critical, service: host }
|
labels: { severity: critical, service: host }
|
||||||
annotations: { summary: "根磁盘使用率达到严重阈值", description: "根磁盘使用率持续超过90%。", currentValue: "{{ $value }}", threshold: "90%" }
|
annotations: { summary: "磁盘(所有挂载点)使用率达到严重阈值", description: "磁盘(所有挂载点)使用率持续超过90%。", currentValue: "{{ $value }}", threshold: "90%" }
|
||||||
- alert: CmppApiHttpErrorRateWarning
|
- 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)
|
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
|
for: 5m
|
||||||
|
|||||||
Reference in New Issue
Block a user