fix: correct operational statistics and form interactions
This commit is contained in:
@@ -194,6 +194,7 @@ export class ChannelReportingService {
|
|||||||
SELECT
|
SELECT
|
||||||
submit."channelId" AS channel_id,
|
submit."channelId" AS channel_id,
|
||||||
message."signatureId" AS signature_id,
|
message."signatureId" AS signature_id,
|
||||||
|
message.carrier AS carrier,
|
||||||
message."drainageInfoId" AS drainage_info_id,
|
message."drainageInfoId" AS drainage_info_id,
|
||||||
submit."submitStatus" AS submit_status,
|
submit."submitStatus" AS submit_status,
|
||||||
COALESCE(submit."submittedAt", submit."createdAt") AS attempted_at,
|
COALESCE(submit."submittedAt", submit."createdAt") AS attempted_at,
|
||||||
@@ -244,6 +245,7 @@ export class ChannelReportingService {
|
|||||||
SELECT
|
SELECT
|
||||||
channel_id AS "channelId",
|
channel_id AS "channelId",
|
||||||
signature_id AS "signatureId",
|
signature_id AS "signatureId",
|
||||||
|
carrier,
|
||||||
drainage_info_id AS "drainageInfoId",
|
drainage_info_id AS "drainageInfoId",
|
||||||
COUNT(*) FILTER (
|
COUNT(*) FILTER (
|
||||||
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
|
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
|
||||||
@@ -270,7 +272,7 @@ export class ChannelReportingService {
|
|||||||
)::integer AS "failureCount",
|
)::integer AS "failureCount",
|
||||||
MAX(successful_at) FILTER (WHERE delivery_status = 'success') AS "lastSuccessfulSentAt"
|
MAX(successful_at) FILTER (WHERE delivery_status = 'success') AS "lastSuccessfulSentAt"
|
||||||
FROM base
|
FROM base
|
||||||
GROUP BY channel_id, signature_id, drainage_info_id
|
GROUP BY channel_id, signature_id, drainage_info_id, carrier
|
||||||
`);
|
`);
|
||||||
|
|
||||||
return tasks.map((task) => {
|
return tasks.map((task) => {
|
||||||
@@ -278,6 +280,7 @@ export class ChannelReportingService {
|
|||||||
(row) =>
|
(row) =>
|
||||||
row.channelId === task.channelId &&
|
row.channelId === task.channelId &&
|
||||||
row.signatureId === task.signatureId &&
|
row.signatureId === task.signatureId &&
|
||||||
|
(!task.carrier || row.carrier === task.carrier) &&
|
||||||
((task.reportType ?? 'signature') === 'signature' || row.drainageInfoId === task.drainageItemId),
|
((task.reportType ?? 'signature') === 'signature' || row.drainageInfoId === task.drainageItemId),
|
||||||
);
|
);
|
||||||
const deliveryStats = summarizeChannelReportDelivery(taskRows);
|
const deliveryStats = summarizeChannelReportDelivery(taskRows);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { summarizeReportStatuses as summarizeCommonReportStatuses } from '../common/report-status';
|
import { summarizeReportStatuses as summarizeCommonReportStatuses } from '../common/report-status';
|
||||||
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
|
import type { CreateChannelGroupItemDto, TestChannelDto } from './channels.contracts';
|
||||||
|
|
||||||
export function summarizeReportStatuses(statuses: string[]) {
|
export function summarizeReportStatuses(statuses: string[]) {
|
||||||
return summarizeCommonReportStatuses(statuses);
|
return summarizeCommonReportStatuses(statuses);
|
||||||
@@ -141,7 +141,11 @@ export function buildChannelTestSubmitCommand({
|
|||||||
account: channel.account,
|
account: channel.account,
|
||||||
passwordCipher: channel.passwordCipher,
|
passwordCipher: channel.passwordCipher,
|
||||||
cmppVersion: channel.cmppVersion,
|
cmppVersion: channel.cmppVersion,
|
||||||
desiredConnections: getPositiveRuntimeInteger(getConfigValue(channel.config, 'desiredConnections'), 1, 'desiredConnections'),
|
desiredConnections: getPositiveRuntimeInteger(
|
||||||
|
getConfigValue(channel.config, 'desiredConnections'),
|
||||||
|
1,
|
||||||
|
'desiredConnections',
|
||||||
|
),
|
||||||
windowSize: getPositiveRuntimeInteger(getConfigValue(channel.config, 'windowSize'), 16, 'windowSize'),
|
windowSize: getPositiveRuntimeInteger(getConfigValue(channel.config, 'windowSize'), 16, 'windowSize'),
|
||||||
heartbeatIntervalSeconds: getPositiveRuntimeInteger(
|
heartbeatIntervalSeconds: getPositiveRuntimeInteger(
|
||||||
getConfigValue(channel.config, 'heartbeatIntervalSeconds'),
|
getConfigValue(channel.config, 'heartbeatIntervalSeconds'),
|
||||||
@@ -254,23 +258,22 @@ export function getRuntimeConfigInteger(
|
|||||||
return Number.isInteger(value) && value > 0 ? value : fallback;
|
return Number.isInteger(value) && value > 0 ? value : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function channelConnectionSettingsChanged(
|
export function channelConnectionSettingsChanged(before: ChannelConnectionSettings, after: ChannelConnectionSettings) {
|
||||||
before: ChannelConnectionSettings,
|
return (
|
||||||
after: ChannelConnectionSettings,
|
before.gatewayHost !== after.gatewayHost ||
|
||||||
) {
|
before.gatewayPort !== after.gatewayPort ||
|
||||||
return before.gatewayHost !== after.gatewayHost
|
before.account !== after.account ||
|
||||||
|| before.gatewayPort !== after.gatewayPort
|
before.passwordCipher !== after.passwordCipher ||
|
||||||
|| before.account !== after.account
|
before.cmppVersion !== after.cmppVersion ||
|
||||||
|| before.passwordCipher !== after.passwordCipher
|
getRuntimeConfigInteger(before.config, 'desiredConnections', 1) !==
|
||||||
|| before.cmppVersion !== after.cmppVersion
|
getRuntimeConfigInteger(after.config, 'desiredConnections', 1) ||
|
||||||
|| getRuntimeConfigInteger(before.config, 'desiredConnections', 1)
|
getRuntimeConfigInteger(before.config, 'windowSize', 16) !==
|
||||||
!== getRuntimeConfigInteger(after.config, 'desiredConnections', 1)
|
getRuntimeConfigInteger(after.config, 'windowSize', 16) ||
|
||||||
|| getRuntimeConfigInteger(before.config, 'windowSize', 16)
|
getRuntimeConfigInteger(before.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS) !==
|
||||||
!== getRuntimeConfigInteger(after.config, 'windowSize', 16)
|
getRuntimeConfigInteger(after.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS) ||
|
||||||
|| getRuntimeConfigInteger(before.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS)
|
getRuntimeConfigInteger(before.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD) !==
|
||||||
!== getRuntimeConfigInteger(after.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS)
|
getRuntimeConfigInteger(after.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD)
|
||||||
|| getRuntimeConfigInteger(before.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD)
|
);
|
||||||
!== getRuntimeConfigInteger(after.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function channelGroupAuditSnapshot(group: {
|
export function channelGroupAuditSnapshot(group: {
|
||||||
@@ -320,19 +323,49 @@ export function normalizeChannelRuntimeConfig(
|
|||||||
heartbeatIntervalSeconds?: number,
|
heartbeatIntervalSeconds?: number,
|
||||||
heartbeatMissThreshold?: number,
|
heartbeatMissThreshold?: number,
|
||||||
) {
|
) {
|
||||||
const existing = existingConfig && typeof existingConfig === 'object' && !Array.isArray(existingConfig)
|
const existing =
|
||||||
? existingConfig as Record<string, unknown>
|
existingConfig && typeof existingConfig === 'object' && !Array.isArray(existingConfig)
|
||||||
: {};
|
? (existingConfig as Record<string, unknown>)
|
||||||
const incoming = incomingConfig && typeof incomingConfig === 'object' && !Array.isArray(incomingConfig)
|
|
||||||
? incomingConfig
|
|
||||||
: {};
|
: {};
|
||||||
|
const incoming =
|
||||||
|
incomingConfig && typeof incomingConfig === 'object' && !Array.isArray(incomingConfig) ? incomingConfig : {};
|
||||||
const base = { ...existing, ...incoming };
|
const base = { ...existing, ...incoming };
|
||||||
base.desiredConnections = boundedRuntimeInteger(desiredConnections ?? base.desiredConnections, 1, 8, 1, 'desiredConnections');
|
base.desiredConnections = boundedRuntimeInteger(
|
||||||
|
desiredConnections ?? base.desiredConnections,
|
||||||
|
1,
|
||||||
|
8,
|
||||||
|
1,
|
||||||
|
'desiredConnections',
|
||||||
|
);
|
||||||
base.windowSize = boundedRuntimeInteger(windowSize ?? base.windowSize, 1, 64, 16, 'windowSize');
|
base.windowSize = boundedRuntimeInteger(windowSize ?? base.windowSize, 1, 64, 16, 'windowSize');
|
||||||
base.connectionWarmupSeconds = boundedRuntimeInteger(base.connectionWarmupSeconds, 0, 300, 30, 'connectionWarmupSeconds');
|
base.connectionWarmupSeconds = boundedRuntimeInteger(
|
||||||
base.connectionDrainTimeoutSeconds = boundedRuntimeInteger(base.connectionDrainTimeoutSeconds, 1, 600, 60, 'connectionDrainTimeoutSeconds');
|
base.connectionWarmupSeconds,
|
||||||
base.submitResponseTimeoutSeconds = boundedRuntimeInteger(base.submitResponseTimeoutSeconds, 1, 300, 60, 'submitResponseTimeoutSeconds');
|
0,
|
||||||
base.connectionFailureCooldownSeconds = boundedRuntimeInteger(base.connectionFailureCooldownSeconds, 1, 300, 30, 'connectionFailureCooldownSeconds');
|
300,
|
||||||
|
30,
|
||||||
|
'connectionWarmupSeconds',
|
||||||
|
);
|
||||||
|
base.connectionDrainTimeoutSeconds = boundedRuntimeInteger(
|
||||||
|
base.connectionDrainTimeoutSeconds,
|
||||||
|
1,
|
||||||
|
600,
|
||||||
|
60,
|
||||||
|
'connectionDrainTimeoutSeconds',
|
||||||
|
);
|
||||||
|
base.submitResponseTimeoutSeconds = boundedRuntimeInteger(
|
||||||
|
base.submitResponseTimeoutSeconds,
|
||||||
|
1,
|
||||||
|
300,
|
||||||
|
60,
|
||||||
|
'submitResponseTimeoutSeconds',
|
||||||
|
);
|
||||||
|
base.connectionFailureCooldownSeconds = boundedRuntimeInteger(
|
||||||
|
base.connectionFailureCooldownSeconds,
|
||||||
|
1,
|
||||||
|
300,
|
||||||
|
30,
|
||||||
|
'connectionFailureCooldownSeconds',
|
||||||
|
);
|
||||||
base.heartbeatIntervalSeconds = getPositiveRuntimeInteger(
|
base.heartbeatIntervalSeconds = getPositiveRuntimeInteger(
|
||||||
heartbeatIntervalSeconds ?? base.heartbeatIntervalSeconds,
|
heartbeatIntervalSeconds ?? base.heartbeatIntervalSeconds,
|
||||||
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
||||||
@@ -423,13 +456,19 @@ export function getPositiveIntegerEnv(name: string, fallback: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseReceiptContent(content: string, delimiter?: ',' | '\t') {
|
export function parseReceiptContent(content: string, delimiter?: ',' | '\t') {
|
||||||
const lines = content.replace(/^\uFEFF/, '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
const lines = content
|
||||||
|
.replace(/^\uFEFF/, '')
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
if (lines.length === 0) {
|
if (lines.length === 0) {
|
||||||
throw new BadRequestException('Receipt file is empty');
|
throw new BadRequestException('Receipt file is empty');
|
||||||
}
|
}
|
||||||
const separator = delimiter ?? (lines[0].includes('\t') ? '\t' : ',');
|
const separator = delimiter ?? (lines[0].includes('\t') ? '\t' : ',');
|
||||||
const firstCells = splitReceiptLine(lines[0], separator);
|
const firstCells = splitReceiptLine(lines[0], separator);
|
||||||
const hasHeader = firstCells.some((cell) => ['phone', 'mobile', 'status', 'result', '手机号', '号码', '状态', '结果'].includes(cell.toLowerCase()));
|
const hasHeader = firstCells.some((cell) =>
|
||||||
|
['phone', 'mobile', 'status', 'result', '手机号', '号码', '状态', '结果'].includes(cell.toLowerCase()),
|
||||||
|
);
|
||||||
const header = hasHeader ? firstCells : [];
|
const header = hasHeader ? firstCells : [];
|
||||||
const rows = hasHeader ? lines.slice(1) : lines;
|
const rows = hasHeader ? lines.slice(1) : lines;
|
||||||
const statusIndex = findReceiptStatusIndex(header);
|
const statusIndex = findReceiptStatusIndex(header);
|
||||||
@@ -445,7 +484,7 @@ export function parseReceiptContent(content: string, delimiter?: ',' | '\t') {
|
|||||||
failedCount += 1;
|
failedCount += 1;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
rowNumber: (hasHeader ? index + 2 : index + 1),
|
rowNumber: hasHeader ? index + 2 : index + 1,
|
||||||
phone: cells[0] ?? '',
|
phone: cells[0] ?? '',
|
||||||
status: normalizedStatus,
|
status: normalizedStatus,
|
||||||
rawStatus,
|
rawStatus,
|
||||||
@@ -504,10 +543,39 @@ export function findReceiptStatusIndex(header: string[]) {
|
|||||||
|
|
||||||
export function normalizeReceiptStatus(value: string) {
|
export function normalizeReceiptStatus(value: string) {
|
||||||
const normalized = value.trim().toLowerCase();
|
const normalized = value.trim().toLowerCase();
|
||||||
if (['success', 'succeeded', 'approved', 'completed', 'ok', 'pass', 'passed', '通过', '成功', '已完成', '报备成功'].includes(normalized)) {
|
if (
|
||||||
|
[
|
||||||
|
'success',
|
||||||
|
'succeeded',
|
||||||
|
'approved',
|
||||||
|
'completed',
|
||||||
|
'ok',
|
||||||
|
'pass',
|
||||||
|
'passed',
|
||||||
|
'通过',
|
||||||
|
'成功',
|
||||||
|
'已完成',
|
||||||
|
'报备成功',
|
||||||
|
].includes(normalized)
|
||||||
|
) {
|
||||||
return 'success';
|
return 'success';
|
||||||
}
|
}
|
||||||
if (['failed', 'fail', 'rejected', 'reject', 'error', 'no', 'denied', '驳回', '失败', '不通过', '拒绝', '报备失败'].includes(normalized)) {
|
if (
|
||||||
|
[
|
||||||
|
'failed',
|
||||||
|
'fail',
|
||||||
|
'rejected',
|
||||||
|
'reject',
|
||||||
|
'error',
|
||||||
|
'no',
|
||||||
|
'denied',
|
||||||
|
'驳回',
|
||||||
|
'失败',
|
||||||
|
'不通过',
|
||||||
|
'拒绝',
|
||||||
|
'报备失败',
|
||||||
|
].includes(normalized)
|
||||||
|
) {
|
||||||
return 'failed';
|
return 'failed';
|
||||||
}
|
}
|
||||||
return 'failed';
|
return 'failed';
|
||||||
@@ -524,6 +592,7 @@ export function deriveReceiptStatus(rowCount: number, successCount: number, fail
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ChannelReportDeliveryRow = {
|
export type ChannelReportDeliveryRow = {
|
||||||
|
carrier: string | null;
|
||||||
channelId: string;
|
channelId: string;
|
||||||
signatureId: string;
|
signatureId: string;
|
||||||
drainageInfoId: string | null;
|
drainageInfoId: string | null;
|
||||||
@@ -557,10 +626,13 @@ export function summarizeChannelReportDelivery(rows: ChannelReportDeliveryRow[])
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sumReportDelivery(rows: ChannelReportDeliveryRow[], key: keyof Pick<
|
export function sumReportDelivery(
|
||||||
|
rows: ChannelReportDeliveryRow[],
|
||||||
|
key: keyof Pick<
|
||||||
ChannelReportDeliveryRow,
|
ChannelReportDeliveryRow,
|
||||||
'total' | 'acceptedCount' | 'submitFailureCount' | 'successCount' | 'unknownCount' | 'failureCount'
|
'total' | 'acceptedCount' | 'submitFailureCount' | 'successCount' | 'unknownCount' | 'failureCount'
|
||||||
>) {
|
>,
|
||||||
|
) {
|
||||||
return rows.reduce((total, row) => total + Number(row[key] ?? 0), 0);
|
return rows.reduce((total, row) => total + Number(row[key] ?? 0), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,7 +652,11 @@ export function currentShanghaiDayRange(now = new Date()) {
|
|||||||
return { startAt, endAt: new Date(startAt.getTime() + 24 * 60 * 60 * 1_000) };
|
return { startAt, endAt: new Date(startAt.getTime() + 24 * 60 * 60 * 1_000) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeRetryTimeLimitMinutes(minutes: number | undefined, hours: number | undefined, fallbackMinutes: number) {
|
export function normalizeRetryTimeLimitMinutes(
|
||||||
|
minutes: number | undefined,
|
||||||
|
hours: number | undefined,
|
||||||
|
fallbackMinutes: number,
|
||||||
|
) {
|
||||||
const value = minutes ?? (hours === undefined ? fallbackMinutes : hours * 60);
|
const value = minutes ?? (hours === undefined ? fallbackMinutes : hours * 60);
|
||||||
if (!Number.isInteger(value) || value <= 0 || value > 72 * 60) {
|
if (!Number.isInteger(value) || value <= 0 || value > 72 * 60) {
|
||||||
throw new BadRequestException('retryTimeLimitMinutes must be an integer between 1 and 4320');
|
throw new BadRequestException('retryTimeLimitMinutes must be an integer between 1 and 4320');
|
||||||
@@ -588,7 +664,12 @@ export function normalizeRetryTimeLimitMinutes(minutes: number | undefined, hour
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeSpreadsheetSize(value: number | undefined, fallback: number, minimum: number, maximum: number) {
|
export function normalizeSpreadsheetSize(
|
||||||
|
value: number | undefined,
|
||||||
|
fallback: number,
|
||||||
|
minimum: number,
|
||||||
|
maximum: number,
|
||||||
|
) {
|
||||||
if (value === undefined || !Number.isFinite(value)) return fallback;
|
if (value === undefined || !Number.isFinite(value)) return fallback;
|
||||||
return Math.min(maximum, Math.max(minimum, Math.round(value)));
|
return Math.min(maximum, Math.max(minimum, Math.round(value)));
|
||||||
}
|
}
|
||||||
@@ -602,7 +683,9 @@ export function normalizeBusinessCarrier(carrier?: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeChannelCarrier(carrier?: string | null) {
|
export function normalizeChannelCarrier(carrier?: string | null) {
|
||||||
const value = String(carrier ?? '').trim().toLowerCase();
|
const value = String(carrier ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
if (['mobile', 'cmcc', '移动', '中国移动'].includes(value)) return 'mobile';
|
if (['mobile', 'cmcc', '移动', '中国移动'].includes(value)) return 'mobile';
|
||||||
if (['unicom', 'cucc', '联通', '中国联通'].includes(value)) return 'unicom';
|
if (['unicom', 'cucc', '联通', '中国联通'].includes(value)) return 'unicom';
|
||||||
if (['telecom', 'ctcc', '电信', '中国电信'].includes(value)) return 'telecom';
|
if (['telecom', 'ctcc', '电信', '中国电信'].includes(value)) return 'telecom';
|
||||||
@@ -631,12 +714,18 @@ export function legacyCarrierFromCapabilities(carriers: string[]) {
|
|||||||
return 'multi';
|
return 'multi';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isChannelCarrierCompatible(channelCarrier: string | null | undefined, groupCarrier: string, carriers?: string[] | null) {
|
export function isChannelCarrierCompatible(
|
||||||
|
channelCarrier: string | null | undefined,
|
||||||
|
groupCarrier: string,
|
||||||
|
carriers?: string[] | null,
|
||||||
|
) {
|
||||||
return normalizeChannelCarriers(carriers, channelCarrier).includes(normalizeBusinessCarrier(groupCarrier));
|
return normalizeChannelCarriers(carriers, channelCarrier).includes(normalizeBusinessCarrier(groupCarrier));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeRegion(region?: string | null) {
|
export function normalizeRegion(region?: string | null) {
|
||||||
return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim();
|
return String(region ?? '')
|
||||||
|
.replace(/省|市|自治区|壮族|回族|维吾尔/g, '')
|
||||||
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isRegionCompatible(channelRegion: string | null | undefined, itemProvince: string) {
|
export function isRegionCompatible(channelRegion: string | null | undefined, itemProvince: string) {
|
||||||
@@ -646,7 +735,10 @@ export function isRegionCompatible(channelRegion: string | null | undefined, ite
|
|||||||
export function validateGroupItems(
|
export function validateGroupItems(
|
||||||
groupCarrier: string,
|
groupCarrier: string,
|
||||||
items: Array<Omit<CreateChannelGroupItemDto, 'groupId'>>,
|
items: Array<Omit<CreateChannelGroupItemDto, 'groupId'>>,
|
||||||
channels: Map<string, { id: string; carrier?: string | null; carriers?: string[] | null; sendRegion?: string | null }>,
|
channels: Map<
|
||||||
|
string,
|
||||||
|
{ id: string; carrier?: string | null; carriers?: string[] | null; sendRegion?: string | null }
|
||||||
|
>,
|
||||||
) {
|
) {
|
||||||
const channelIds = new Set<string>();
|
const channelIds = new Set<string>();
|
||||||
const provinces = new Set<string>();
|
const provinces = new Set<string>();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
|||||||
|
import { alertHistoryRange, mergeAlertHistory } from './alert-history';
|
||||||
|
|
||||||
|
describe('historical alert observation cycles', () => {
|
||||||
|
it('uses seven Shanghai calendar days and rejects invalid or excessive dates', () => {
|
||||||
|
expect(alertHistoryRange(undefined, undefined, new Date('2026-09-09T16:30:00Z'))).toMatchObject({
|
||||||
|
startDate: '2026-09-04',
|
||||||
|
endDate: '2026-09-10',
|
||||||
|
});
|
||||||
|
for (const [from, to] of [
|
||||||
|
['2026-02-30', '2026-03-01'],
|
||||||
|
['2026-09-09', '2026-09-08'],
|
||||||
|
['2026-07-01', '2026-09-09'],
|
||||||
|
]) {
|
||||||
|
expect(() => alertHistoryRange(from, to)).toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it('retains distinct cycles, merges daily boundaries and excludes stale/nonpositive samples', () => {
|
||||||
|
const result = new Map();
|
||||||
|
const metric = { __name__: 'ALERTS_FOR_STATE', alertname: 'CPUHigh', instance: 'host', severity: 'warning' };
|
||||||
|
mergeAlertHistory(
|
||||||
|
result,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
metric,
|
||||||
|
values: [
|
||||||
|
[110, '100'],
|
||||||
|
[120, '100'],
|
||||||
|
[130, '0'],
|
||||||
|
[140, 'NaN'],
|
||||||
|
[150, '145'],
|
||||||
|
[200, '145'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
110,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
mergeAlertHistory(result, [{ metric, values: [[160, '145']] }], 110, 200);
|
||||||
|
expect(result.size).toBe(2);
|
||||||
|
expect([...result.values()].map((item) => item.lastObservedAt)).toEqual([
|
||||||
|
new Date(120000).toISOString(),
|
||||||
|
new Date(160000).toISOString(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
|
export function alertHistoryRange(from?: string, to?: string, now = new Date()) {
|
||||||
|
const dateKey = (date: Date) => new Date(date.getTime() + 8 * 3600_000).toISOString().slice(0, 10);
|
||||||
|
const endDate = to || dateKey(now);
|
||||||
|
const startDate = from || dateKey(new Date(now.getTime() - 6 * 86400_000));
|
||||||
|
const parse = (value: string) => {
|
||||||
|
const result = new Date(`${value}T00:00:00+08:00`);
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || !Number.isFinite(result.getTime()) || dateKey(result) !== value) {
|
||||||
|
throw new BadRequestException('告警日期无效');
|
||||||
|
}
|
||||||
|
return result.getTime() / 1000;
|
||||||
|
};
|
||||||
|
const start = parse(startDate);
|
||||||
|
const end = parse(endDate) + 86400;
|
||||||
|
if (end <= start || end - start > 31 * 86400) throw new BadRequestException('告警日期范围须为1至31天');
|
||||||
|
return { startDate, endDate, start, end: Math.min(end, now.getTime() / 1000) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AlertHistoryItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
severity: string;
|
||||||
|
service: string;
|
||||||
|
instance: string;
|
||||||
|
startedAt: string;
|
||||||
|
firstObservedAt: string;
|
||||||
|
lastObservedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ALERTS_FOR_STATE stores activeAt as the sample value, separating repeated trigger cycles.
|
||||||
|
// Observation boundaries are not claimed as exact recovery times.
|
||||||
|
export function mergeAlertHistory(
|
||||||
|
target: Map<string, AlertHistoryItem>,
|
||||||
|
series: Array<{ metric: Record<string, string>; values?: [number, string][] }>,
|
||||||
|
start: number,
|
||||||
|
end: number,
|
||||||
|
) {
|
||||||
|
for (const { metric, values } of series) {
|
||||||
|
const labels = Object.entries(metric)
|
||||||
|
.filter(([key]) => key !== '__name__')
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
const fingerprint = createHash('sha256').update(JSON.stringify(labels)).digest('hex');
|
||||||
|
for (const [time, rawActiveAt] of values ?? []) {
|
||||||
|
const activeAt = Number(rawActiveAt);
|
||||||
|
if (time < start || time >= end || !Number.isFinite(activeAt) || activeAt <= 0 || activeAt > time) continue;
|
||||||
|
const id = `${fingerprint}:${activeAt}`;
|
||||||
|
const observed = new Date(time * 1000).toISOString();
|
||||||
|
const item = target.get(id);
|
||||||
|
if (item) {
|
||||||
|
if (observed < item.firstObservedAt) item.firstObservedAt = observed;
|
||||||
|
if (observed > item.lastObservedAt) item.lastObservedAt = observed;
|
||||||
|
} else {
|
||||||
|
target.set(id, {
|
||||||
|
id,
|
||||||
|
name: metric.alertname || '未命名告警',
|
||||||
|
severity: metric.severity || 'info',
|
||||||
|
service: metric.service || '',
|
||||||
|
instance: metric.instance || '',
|
||||||
|
startedAt: new Date(activeAt * 1000).toISOString(),
|
||||||
|
firstObservedAt: observed,
|
||||||
|
lastObservedAt: observed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,10 @@ import { InfrastructureMonitoringService } from './infrastructure-monitoring.ser
|
|||||||
@ApiTags('infrastructure-monitoring')
|
@ApiTags('infrastructure-monitoring')
|
||||||
@Controller('admin/infrastructure-monitoring')
|
@Controller('admin/infrastructure-monitoring')
|
||||||
export class InfrastructureMonitoringController {
|
export class InfrastructureMonitoringController {
|
||||||
constructor(private readonly monitoring: InfrastructureMonitoringService, private readonly settings: InfrastructureAlertSettingsService) {}
|
constructor(
|
||||||
|
private readonly monitoring: InfrastructureMonitoringService,
|
||||||
|
private readonly settings: InfrastructureAlertSettingsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get('overview')
|
@Get('overview')
|
||||||
overview(@Query('range') range?: string, @CurrentSessionUserId() userId?: string) {
|
overview(@Query('range') range?: string, @CurrentSessionUserId() userId?: string) {
|
||||||
@@ -16,19 +19,35 @@ export class InfrastructureMonitoringController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('notification-summary')
|
@Get('notification-summary')
|
||||||
notificationSummary(@CurrentSessionUserId() userId?: string) { return this.monitoring.notificationSummary(userId); }
|
notificationSummary(@CurrentSessionUserId() userId?: string) {
|
||||||
|
return this.monitoring.notificationSummary(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('alert-history')
|
||||||
|
alertHistory(@Query('from') from?: string, @Query('to') to?: string, @Query('page') page?: string) {
|
||||||
|
return this.monitoring.alertHistory(from, to, page);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('alerts/:fingerprint/read')
|
@Post('alerts/:fingerprint/read')
|
||||||
markAlertRead(@Param('fingerprint') fingerprint: string, @Body('activeAt') activeAt: unknown, @CurrentSessionUserId() userId: string) {
|
markAlertRead(
|
||||||
|
@Param('fingerprint') fingerprint: string,
|
||||||
|
@Body('activeAt') activeAt: unknown,
|
||||||
|
@CurrentSessionUserId() userId: string,
|
||||||
|
) {
|
||||||
return this.monitoring.markAlertRead(fingerprint, activeAt, userId);
|
return this.monitoring.markAlertRead(fingerprint, activeAt, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('alert-thresholds')
|
@Get('alert-thresholds')
|
||||||
alertThresholds() { return this.settings.get(); }
|
alertThresholds() {
|
||||||
|
return this.settings.get();
|
||||||
|
}
|
||||||
|
|
||||||
@Put('alert-thresholds')
|
@Put('alert-thresholds')
|
||||||
@RequireRecentAuthentication()
|
@RequireRecentAuthentication()
|
||||||
updateAlertThresholds(@Body() body: { configVersion?: number; thresholds?: unknown }, @CurrentSessionUserId() operatorId?: string) {
|
updateAlertThresholds(
|
||||||
|
@Body() body: { configVersion?: number; thresholds?: unknown },
|
||||||
|
@CurrentSessionUserId() operatorId?: string,
|
||||||
|
) {
|
||||||
return this.settings.update(body, operatorId);
|
return this.settings.update(body, operatorId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,22 @@
|
|||||||
import { BadRequestException, Injectable, Logger, NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { compareMountpoints, FILESYSTEM_LABELS, FILESYSTEM_SELECTOR, FILESYSTEM_USAGE_PERCENT, filesystemIdentity } from './filesystem-metrics';
|
import { alertHistoryRange, mergeAlertHistory, type AlertHistoryItem } from './alert-history';
|
||||||
|
import {
|
||||||
|
compareMountpoints,
|
||||||
|
FILESYSTEM_LABELS,
|
||||||
|
FILESYSTEM_SELECTOR,
|
||||||
|
FILESYSTEM_USAGE_PERCENT,
|
||||||
|
filesystemIdentity,
|
||||||
|
} from './filesystem-metrics';
|
||||||
import type {
|
import type {
|
||||||
InfrastructureAlert,
|
InfrastructureAlert,
|
||||||
InfrastructureMetricPoint,
|
InfrastructureMetricPoint,
|
||||||
@@ -57,7 +70,8 @@ const QUERIES = {
|
|||||||
uptimeSeconds: 'time() - node_boot_time_seconds',
|
uptimeSeconds: 'time() - node_boot_time_seconds',
|
||||||
lastSampleAt: 'max(timestamp(node_uname_info))',
|
lastSampleAt: 'max(timestamp(node_uname_info))',
|
||||||
// PromQL字符串本身需要两个反斜杠才能把正则的“\.”传给RE2;TypeScript字面量因此需要写四个。
|
// PromQL字符串本身需要两个反斜杠才能把正则的“\.”传给RE2;TypeScript字面量因此需要写四个。
|
||||||
services: 'max by (name) (node_systemd_unit_state{name=~"cmpp-api\\\\.service|cmpp-gateway\\\\.service|postgresql\\\\.service|redis(-server)?\\\\.service|cmpp-minio\\\\.service|nginx\\\\.service",state="active"})',
|
services:
|
||||||
|
'max by (name) (node_systemd_unit_state{name=~"cmpp-api\\\\.service|cmpp-gateway\\\\.service|postgresql\\\\.service|redis(-server)?\\\\.service|cmpp-minio\\\\.service|nginx\\\\.service",state="active"})',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const SERVICE_DEFINITIONS = [
|
const SERVICE_DEFINITIONS = [
|
||||||
@@ -70,38 +84,62 @@ const SERVICE_DEFINITIONS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const SERVICE_METRIC_DEFINITIONS = [
|
const SERVICE_METRIC_DEFINITIONS = [
|
||||||
{ key: 'api', name: 'API服务', metrics: [
|
{
|
||||||
|
key: 'api',
|
||||||
|
name: 'API服务',
|
||||||
|
metrics: [
|
||||||
['requestsPerSecond', '请求速率', 'cmpp:service_api:requests_per_second', 'per_second'],
|
['requestsPerSecond', '请求速率', 'cmpp:service_api:requests_per_second', 'per_second'],
|
||||||
['errorPercent', '5xx错误率', 'cmpp:service_api:error_percent', 'percent'],
|
['errorPercent', '5xx错误率', 'cmpp:service_api:error_percent', 'percent'],
|
||||||
['latencyP95', 'P95响应', 'cmpp:service_api:latency_p95_seconds', 'seconds'],
|
['latencyP95', 'P95响应', 'cmpp:service_api:latency_p95_seconds', 'seconds'],
|
||||||
['eventLoopP99', '事件循环P99', 'cmpp:service_api:event_loop_p99_seconds', 'seconds'],
|
['eventLoopP99', '事件循环P99', 'cmpp:service_api:event_loop_p99_seconds', 'seconds'],
|
||||||
] },
|
],
|
||||||
{ key: 'gateway', name: 'Gateway服务', metrics: [
|
},
|
||||||
|
{
|
||||||
|
key: 'gateway',
|
||||||
|
name: 'Gateway服务',
|
||||||
|
metrics: [
|
||||||
['submitsPerSecond', '提交速率', 'cmpp:service_gateway:submits_per_second', 'per_second'],
|
['submitsPerSecond', '提交速率', 'cmpp:service_gateway:submits_per_second', 'per_second'],
|
||||||
['failurePercent', '提交失败率', 'cmpp:service_gateway:failure_percent', 'percent'],
|
['failurePercent', '提交失败率', 'cmpp:service_gateway:failure_percent', 'percent'],
|
||||||
['queuePending', 'Stream pending', 'cmpp:service_gateway:queue_pending', 'count'],
|
['queuePending', 'Stream pending', 'cmpp:service_gateway:queue_pending', 'count'],
|
||||||
['queueOldestSeconds', '最旧pending', 'cmpp:service_gateway:queue_oldest_seconds', 'seconds'],
|
['queueOldestSeconds', '最旧pending', 'cmpp:service_gateway:queue_oldest_seconds', 'seconds'],
|
||||||
] },
|
],
|
||||||
{ key: 'postgresql', name: 'PostgreSQL', metrics: [
|
},
|
||||||
|
{
|
||||||
|
key: 'postgresql',
|
||||||
|
name: 'PostgreSQL',
|
||||||
|
metrics: [
|
||||||
['connectionPercent', '连接使用率', 'cmpp:service_postgresql:connection_percent', 'percent'],
|
['connectionPercent', '连接使用率', 'cmpp:service_postgresql:connection_percent', 'percent'],
|
||||||
['deadlocks15m', '15分钟死锁', 'cmpp:service_postgresql:deadlocks_15m', 'count'],
|
['deadlocks15m', '15分钟死锁', 'cmpp:service_postgresql:deadlocks_15m', 'count'],
|
||||||
] },
|
],
|
||||||
{ key: 'redis', name: 'Redis', metrics: [
|
},
|
||||||
|
{
|
||||||
|
key: 'redis',
|
||||||
|
name: 'Redis',
|
||||||
|
metrics: [
|
||||||
['memoryPercent', '内存使用率', 'cmpp:service_redis:memory_percent', 'percent'],
|
['memoryPercent', '内存使用率', 'cmpp:service_redis:memory_percent', 'percent'],
|
||||||
['memoryUsedBytes', '已用内存', 'cmpp:service_redis:memory_used_bytes', 'bytes'],
|
['memoryUsedBytes', '已用内存', 'cmpp:service_redis:memory_used_bytes', 'bytes'],
|
||||||
['connectedClients', '客户端连接', 'cmpp:service_redis:connected_clients', 'count'],
|
['connectedClients', '客户端连接', 'cmpp:service_redis:connected_clients', 'count'],
|
||||||
['evictions5m', '5分钟淘汰', 'cmpp:service_redis:evictions_5m', 'count'],
|
['evictions5m', '5分钟淘汰', 'cmpp:service_redis:evictions_5m', 'count'],
|
||||||
] },
|
],
|
||||||
{ key: 'minio', name: 'MinIO', metrics: [
|
},
|
||||||
|
{
|
||||||
|
key: 'minio',
|
||||||
|
name: 'MinIO',
|
||||||
|
metrics: [
|
||||||
['capacityPercent', '存储容量使用率', 'cmpp:service_minio:capacity_percent', 'percent'],
|
['capacityPercent', '存储容量使用率', 'cmpp:service_minio:capacity_percent', 'percent'],
|
||||||
['usageBytes', '对象数据量', 'cmpp:service_minio:usage_bytes', 'bytes'],
|
['usageBytes', '对象数据量', 'cmpp:service_minio:usage_bytes', 'bytes'],
|
||||||
['objects', '对象数', 'cmpp:service_minio:objects', 'count'],
|
['objects', '对象数', 'cmpp:service_minio:objects', 'count'],
|
||||||
['drivesOffline', '离线存储盘', 'cmpp:service_minio:drives_offline', 'count'],
|
['drivesOffline', '离线存储盘', 'cmpp:service_minio:drives_offline', 'count'],
|
||||||
] },
|
],
|
||||||
{ key: 'nginx', name: 'Nginx', metrics: [
|
},
|
||||||
|
{
|
||||||
|
key: 'nginx',
|
||||||
|
name: 'Nginx',
|
||||||
|
metrics: [
|
||||||
['connectionsActive', '活跃连接', 'cmpp:service_nginx:connections_active', 'count'],
|
['connectionsActive', '活跃连接', 'cmpp:service_nginx:connections_active', 'count'],
|
||||||
['requestsPerSecond', '请求速率', 'cmpp:service_nginx:requests_per_second', 'per_second'],
|
['requestsPerSecond', '请求速率', 'cmpp:service_nginx:requests_per_second', 'per_second'],
|
||||||
] },
|
],
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const SERVICE_METRICS_QUERY = '{__name__=~"cmpp:service_.*"}';
|
const SERVICE_METRICS_QUERY = '{__name__=~"cmpp:service_.*"}';
|
||||||
@@ -165,7 +203,10 @@ export class InfrastructureMonitoringService {
|
|||||||
private readonly prometheusUrl: string;
|
private readonly prometheusUrl: string;
|
||||||
private readonly queryTimeoutMs: number;
|
private readonly queryTimeoutMs: number;
|
||||||
|
|
||||||
constructor(config: ConfigService, private readonly prisma: PrismaService) {
|
constructor(
|
||||||
|
config: ConfigService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {
|
||||||
this.prometheusUrl = normalizePrometheusUrl(config.get('PROMETHEUS_URL'));
|
this.prometheusUrl = normalizePrometheusUrl(config.get('PROMETHEUS_URL'));
|
||||||
this.queryTimeoutMs = Math.min(15_000, Math.max(1_000, Number(config.get('PROMETHEUS_QUERY_TIMEOUT_MS') ?? 5_000)));
|
this.queryTimeoutMs = Math.min(15_000, Math.max(1_000, Number(config.get('PROMETHEUS_QUERY_TIMEOUT_MS') ?? 5_000)));
|
||||||
}
|
}
|
||||||
@@ -202,7 +243,7 @@ export class InfrastructureMonitoringService {
|
|||||||
activeAlerts: alerts.length,
|
activeAlerts: alerts.length,
|
||||||
},
|
},
|
||||||
metrics: instant.metrics,
|
metrics: instant.metrics,
|
||||||
trends: { ...trends.metrics, diskUsagePercent: rootDisk ? trends.disks.get(rootDisk.id) ?? [] : [] },
|
trends: { ...trends.metrics, diskUsagePercent: rootDisk ? (trends.disks.get(rootDisk.id) ?? []) : [] },
|
||||||
disks: instant.disks.map((disk) => ({ ...disk, trend: trends.disks.get(disk.id) ?? [] })),
|
disks: instant.disks.map((disk) => ({ ...disk, trend: trends.disks.get(disk.id) ?? [] })),
|
||||||
services,
|
services,
|
||||||
serviceMetrics,
|
serviceMetrics,
|
||||||
@@ -210,18 +251,28 @@ export class InfrastructureMonitoringService {
|
|||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 页面必须整体清空陈旧指标,但服务端仍需留下不含PromQL/地址/凭据的根因摘要便于运维诊断。
|
// 页面必须整体清空陈旧指标,但服务端仍需留下不含PromQL/地址/凭据的根因摘要便于运维诊断。
|
||||||
this.logger.warn(`Prometheus monitoring overview unavailable: ${error instanceof Error ? error.message : 'unknown error'}`);
|
this.logger.warn(
|
||||||
|
`Prometheus monitoring overview unavailable: ${error instanceof Error ? error.message : 'unknown error'}`,
|
||||||
|
);
|
||||||
return this.unavailable(range, collectedAt);
|
return this.unavailable(range, collectedAt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async notificationSummary(userId?: string) {
|
async notificationSummary(userId?: string) {
|
||||||
try {
|
try {
|
||||||
const alerts = await this.attachReadState(this.parseAlerts(await this.getJson<PrometheusAlertResponse>('/api/v1/alerts')), userId);
|
const alerts = await this.attachReadState(
|
||||||
|
this.parseAlerts(await this.getJson<PrometheusAlertResponse>('/api/v1/alerts')),
|
||||||
|
userId,
|
||||||
|
);
|
||||||
const unreadAlerts = alerts.filter((item) => !item.acknowledged);
|
const unreadAlerts = alerts.filter((item) => !item.acknowledged);
|
||||||
return { count: unreadAlerts.length, criticalCount: unreadAlerts.filter((item) => item.severity === 'critical').length };
|
return {
|
||||||
|
count: unreadAlerts.length,
|
||||||
|
criticalCount: unreadAlerts.filter((item) => item.severity === 'critical').length,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn(`Prometheus notification summary unavailable: ${error instanceof Error ? error.message : 'unknown error'}`);
|
this.logger.warn(
|
||||||
|
`Prometheus notification summary unavailable: ${error instanceof Error ? error.message : 'unknown error'}`,
|
||||||
|
);
|
||||||
throw new ServiceUnavailableException('Prometheus活动告警当前不可用');
|
throw new ServiceUnavailableException('Prometheus活动告警当前不可用');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,11 +282,20 @@ export class InfrastructureMonitoringService {
|
|||||||
const activeAt = new Date(String(rawActiveAt ?? ''));
|
const activeAt = new Date(String(rawActiveAt ?? ''));
|
||||||
if (!Number.isFinite(activeAt.getTime())) throw new BadRequestException('告警开始时间无效');
|
if (!Number.isFinite(activeAt.getTime())) throw new BadRequestException('告警开始时间无效');
|
||||||
const activeAlerts = this.parseAlerts(await this.getJson<PrometheusAlertResponse>('/api/v1/alerts'));
|
const activeAlerts = this.parseAlerts(await this.getJson<PrometheusAlertResponse>('/api/v1/alerts'));
|
||||||
const current = activeAlerts.find((item) => item.fingerprint === fingerprint && Date.parse(item.startedAt) === activeAt.getTime());
|
const current = activeAlerts.find(
|
||||||
|
(item) => item.fingerprint === fingerprint && Date.parse(item.startedAt) === activeAt.getTime(),
|
||||||
|
);
|
||||||
if (!current) throw new NotFoundException('该次活动告警已结束或已重新触发,请刷新后重试');
|
if (!current) throw new NotFoundException('该次活动告警已结束或已重新触发,请刷新后重试');
|
||||||
const readAt = new Date();
|
const readAt = new Date();
|
||||||
const log = () => this.prisma.operationLog.create({
|
const log = () =>
|
||||||
data: { userId, action: 'monitoring.alert_marked_read', resource: 'infrastructure_alert', resourceId: fingerprint, detail: { activeAt: activeAt.toISOString(), alertName: current.name, severity: current.severity } },
|
this.prisma.operationLog.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
action: 'monitoring.alert_marked_read',
|
||||||
|
resource: 'infrastructure_alert',
|
||||||
|
resourceId: fingerprint,
|
||||||
|
detail: { activeAt: activeAt.toISOString(), alertName: current.name, severity: current.severity },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
let read;
|
let read;
|
||||||
try {
|
try {
|
||||||
@@ -245,15 +305,57 @@ export class InfrastructureMonitoringService {
|
|||||||
]);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!(error instanceof Prisma.PrismaClientKnownRequestError) || error.code !== 'P2002') throw error;
|
if (!(error instanceof Prisma.PrismaClientKnownRequestError) || error.code !== 'P2002') throw error;
|
||||||
const existing = await this.prisma.infrastructureAlertRead.findUniqueOrThrow({ where: { fingerprint_userId: { fingerprint, userId } } });
|
const existing = await this.prisma.infrastructureAlertRead.findUniqueOrThrow({
|
||||||
|
where: { fingerprint_userId: { fingerprint, userId } },
|
||||||
|
});
|
||||||
// 同一次触发重复点击不更新readAt也不重复写日志;activeAt变化才代表同指纹的新触发周期。
|
// 同一次触发重复点击不更新readAt也不重复写日志;activeAt变化才代表同指纹的新触发周期。
|
||||||
if (existing.activeAt.getTime() === activeAt.getTime()) read = existing;
|
if (existing.activeAt.getTime() === activeAt.getTime()) read = existing;
|
||||||
else [read] = await this.prisma.$transaction([
|
else
|
||||||
this.prisma.infrastructureAlertRead.update({ where: { fingerprint_userId: { fingerprint, userId } }, data: { activeAt, readAt } }),
|
[read] = await this.prisma.$transaction([
|
||||||
|
this.prisma.infrastructureAlertRead.update({
|
||||||
|
where: { fingerprint_userId: { fingerprint, userId } },
|
||||||
|
data: { activeAt, readAt },
|
||||||
|
}),
|
||||||
log(),
|
log(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
return { fingerprint, activeAt: read.activeAt.toISOString(), acknowledged: true, acknowledgedAt: read.readAt.toISOString() };
|
return {
|
||||||
|
fingerprint,
|
||||||
|
activeAt: read.activeAt.toISOString(),
|
||||||
|
acknowledged: true,
|
||||||
|
acknowledgedAt: read.readAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async alertHistory(from?: string, to?: string, rawPage?: string) {
|
||||||
|
const range = alertHistoryRange(from, to);
|
||||||
|
const page = rawPage === undefined ? 1 : Number(rawPage);
|
||||||
|
if (!Number.isSafeInteger(page) || page < 1) throw new BadRequestException('告警页码无效');
|
||||||
|
const history = new Map<string, AlertHistoryItem>();
|
||||||
|
try {
|
||||||
|
// Daily raw range vectors retain short events that a coarse query_range step would miss.
|
||||||
|
for (let start = range.start; start < range.end; start += 86400) {
|
||||||
|
const end = Math.min(start + 86400, range.end);
|
||||||
|
const response = await this.getJson<PrometheusQueryResponse>('/api/v1/query', {
|
||||||
|
query: `ALERTS_FOR_STATE[${Math.ceil(end - start)}s]`,
|
||||||
|
time: String(end),
|
||||||
|
});
|
||||||
|
mergeAlertHistory(history, response.data?.result ?? [], range.start, range.end);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw new ServiceUnavailableException('历史告警查询失败,请稍后重试');
|
||||||
|
}
|
||||||
|
const items = [...history.values()].sort(
|
||||||
|
(a, b) => b.startedAt.localeCompare(a.startedAt) || a.id.localeCompare(b.id),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
items: items.slice((page - 1) * 25, page * 25),
|
||||||
|
total: items.length,
|
||||||
|
page,
|
||||||
|
pageSize: 25,
|
||||||
|
startDate: range.startDate,
|
||||||
|
endDate: range.endDate,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseRange(value?: string): InfrastructureMonitoringRange {
|
private parseRange(value?: string): InfrastructureMonitoringRange {
|
||||||
@@ -264,12 +366,21 @@ export class InfrastructureMonitoringService {
|
|||||||
|
|
||||||
private async loadInstantMetrics() {
|
private async loadInstantMetrics() {
|
||||||
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) => { if (!key.startsWith('disk')) metrics[key] = vectorValue(responses[index]); });
|
keys.forEach((key, index) => {
|
||||||
|
if (!key.startsWith('disk')) metrics[key] = vectorValue(responses[index]);
|
||||||
|
});
|
||||||
const diskSamples = (key: keyof typeof metrics) => responses[keys.indexOf(key)].data?.result ?? [];
|
const diskSamples = (key: keyof typeof metrics) => responses[keys.indexOf(key)].data?.result ?? [];
|
||||||
const usage = new Map(diskSamples('diskUsagePercent').map((item) => [filesystemIdentity(item.metric), finiteNumber(item.value?.[1])]));
|
const usage = new Map(
|
||||||
const available = new Map(diskSamples('diskAvailableBytes').map((item) => [filesystemIdentity(item.metric), finiteNumber(item.value?.[1])]));
|
diskSamples('diskUsagePercent').map((item) => [filesystemIdentity(item.metric), finiteNumber(item.value?.[1])]),
|
||||||
|
);
|
||||||
|
const available = new Map(
|
||||||
|
diskSamples('diskAvailableBytes').map((item) => [filesystemIdentity(item.metric), finiteNumber(item.value?.[1])]),
|
||||||
|
);
|
||||||
const groups = new Map<string, PrometheusSeries[]>();
|
const groups = new Map<string, PrometheusSeries[]>();
|
||||||
for (const item of diskSamples('diskTotalBytes')) {
|
for (const item of diskSamples('diskTotalBytes')) {
|
||||||
if (!item.metric.device || !item.metric.mountpoint || (finiteNumber(item.value?.[1]) ?? 0) <= 0) continue;
|
if (!item.metric.device || !item.metric.mountpoint || (finiteNumber(item.value?.[1]) ?? 0) <= 0) continue;
|
||||||
@@ -278,18 +389,32 @@ export class InfrastructureMonitoringService {
|
|||||||
group.push(item);
|
group.push(item);
|
||||||
groups.set(id, group);
|
groups.set(id, group);
|
||||||
}
|
}
|
||||||
const disks = [...groups].map(([id, items]) => {
|
const disks = [...groups]
|
||||||
|
.map(([id, items]) => {
|
||||||
const mountpoints = [...new Set(items.map((item) => item.metric.mountpoint))].sort(compareMountpoints);
|
const mountpoints = [...new Set(items.map((item) => item.metric.mountpoint))].sort(compareMountpoints);
|
||||||
const metric = items[0].metric;
|
const metric = items[0].metric;
|
||||||
return {
|
return {
|
||||||
id, instance: metric.instance ?? '', device: metric.device, filesystem: metric.fstype ?? '',
|
id,
|
||||||
mountpoint: mountpoints[0], mountpoints,
|
instance: metric.instance ?? '',
|
||||||
|
device: metric.device,
|
||||||
|
filesystem: metric.fstype ?? '',
|
||||||
|
mountpoint: mountpoints[0],
|
||||||
|
mountpoints,
|
||||||
// Never sum aliases. Max/min also tolerate slight sampling differences.
|
// Never sum aliases. Max/min also tolerate slight sampling differences.
|
||||||
totalBytes: Math.max(...items.map((item) => finiteNumber(item.value?.[1])!)),
|
totalBytes: Math.max(...items.map((item) => finiteNumber(item.value?.[1])!)),
|
||||||
availableBytes: available.get(id) ?? null, usagePercent: usage.get(id) ?? null,
|
availableBytes: available.get(id) ?? null,
|
||||||
|
usagePercent: usage.get(id) ?? null,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((left, right) => left.instance.localeCompare(right.instance) || (left.mountpoint === '/' ? -1 : right.mountpoint === '/' ? 1 : left.mountpoint.localeCompare(right.mountpoint)));
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
left.instance.localeCompare(right.instance) ||
|
||||||
|
(left.mountpoint === '/'
|
||||||
|
? -1
|
||||||
|
: right.mountpoint === '/'
|
||||||
|
? 1
|
||||||
|
: left.mountpoint.localeCompare(right.mountpoint)),
|
||||||
|
);
|
||||||
const rootDisk = disks.find((disk) => disk.mountpoints.includes('/'));
|
const rootDisk = disks.find((disk) => disk.mountpoints.includes('/'));
|
||||||
metrics.diskUsagePercent = rootDisk?.usagePercent ?? null;
|
metrics.diskUsagePercent = rootDisk?.usagePercent ?? null;
|
||||||
metrics.diskTotalBytes = rootDisk?.totalBytes ?? null;
|
metrics.diskTotalBytes = rootDisk?.totalBytes ?? null;
|
||||||
@@ -304,21 +429,32 @@ export class InfrastructureMonitoringService {
|
|||||||
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 {
|
return {
|
||||||
metrics: Object.fromEntries(keys.map((key, index) => [key, key === 'diskUsagePercent' ? [] : matrixValues(responses[index])])) as InfrastructureMonitoringOverview['trends'],
|
metrics: Object.fromEntries(
|
||||||
disks: new Map((responses[keys.indexOf('diskUsagePercent')].data?.result ?? []).map((item) => [
|
keys.map((key, index) => [key, key === 'diskUsagePercent' ? [] : matrixValues(responses[index])]),
|
||||||
filesystemIdentity(item.metric), matrixValues({ status: 'success', data: { result: [item] } }),
|
) as InfrastructureMonitoringOverview['trends'],
|
||||||
])),
|
disks: new Map(
|
||||||
|
(responses[keys.indexOf('diskUsagePercent')].data?.result ?? []).map((item) => [
|
||||||
|
filesystemIdentity(item.metric),
|
||||||
|
matrixValues({ status: 'success', data: { result: [item] } }),
|
||||||
|
]),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseServices(response: PrometheusQueryResponse): InfrastructureServiceStatus[] {
|
private parseServices(response: PrometheusQueryResponse): InfrastructureServiceStatus[] {
|
||||||
const values = new Map<string, number>();
|
const values = new Map<string, number>();
|
||||||
for (const item of response.data?.result ?? []) {
|
for (const item of response.data?.result ?? []) {
|
||||||
if (item.metric.name) values.set(item.metric.name, vectorValue({ status: 'success', data: { result: [item] } }) ?? 0);
|
if (item.metric.name)
|
||||||
|
values.set(item.metric.name, vectorValue({ status: 'success', data: { result: [item] } }) ?? 0);
|
||||||
}
|
}
|
||||||
return SERVICE_DEFINITIONS.map((definition) => {
|
return SERVICE_DEFINITIONS.map((definition) => {
|
||||||
const present = definition.units.filter((unit) => values.has(unit));
|
const present = definition.units.filter((unit) => values.has(unit));
|
||||||
const status = present.length === 0 ? 'unknown' : present.some((unit) => (values.get(unit) ?? 0) >= 1) ? 'healthy' : 'unhealthy';
|
const status =
|
||||||
|
present.length === 0
|
||||||
|
? 'unknown'
|
||||||
|
: present.some((unit) => (values.get(unit) ?? 0) >= 1)
|
||||||
|
? 'healthy'
|
||||||
|
: 'unhealthy';
|
||||||
return { key: definition.key, name: definition.name, unit: present[0] ?? definition.units[0], status };
|
return { key: definition.key, name: definition.name, unit: present[0] ?? definition.units[0], status };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -329,7 +465,8 @@ export class InfrastructureMonitoringService {
|
|||||||
.map<InfrastructureAlert>((item) => {
|
.map<InfrastructureAlert>((item) => {
|
||||||
const labels = item.labels ?? {};
|
const labels = item.labels ?? {};
|
||||||
const annotations = item.annotations ?? {};
|
const annotations = item.annotations ?? {};
|
||||||
const severity: InfrastructureAlert['severity'] = labels.severity === 'critical' ? 'critical' : labels.severity === 'warning' ? 'warning' : 'info';
|
const severity: InfrastructureAlert['severity'] =
|
||||||
|
labels.severity === 'critical' ? 'critical' : labels.severity === 'warning' ? 'warning' : 'info';
|
||||||
const identity = JSON.stringify(Object.entries(labels).sort(([left], [right]) => left.localeCompare(right)));
|
const identity = JSON.stringify(Object.entries(labels).sort(([left], [right]) => left.localeCompare(right)));
|
||||||
return {
|
return {
|
||||||
fingerprint: createHash('sha256').update(identity).digest('hex').slice(0, 24),
|
fingerprint: createHash('sha256').update(identity).digest('hex').slice(0, 24),
|
||||||
@@ -348,7 +485,9 @@ export class InfrastructureMonitoringService {
|
|||||||
})
|
})
|
||||||
.sort((left, right) => {
|
.sort((left, right) => {
|
||||||
const priority: Record<InfrastructureAlert['severity'], number> = { critical: 0, warning: 1, info: 2 };
|
const priority: Record<InfrastructureAlert['severity'], number> = { critical: 0, warning: 1, info: 2 };
|
||||||
return priority[left.severity] - priority[right.severity] || Date.parse(left.startedAt) - Date.parse(right.startedAt);
|
return (
|
||||||
|
priority[left.severity] - priority[right.severity] || Date.parse(left.startedAt) - Date.parse(right.startedAt)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,24 +516,46 @@ export class InfrastructureMonitoringService {
|
|||||||
key: group.key,
|
key: group.key,
|
||||||
name: group.name,
|
name: group.name,
|
||||||
available: group.metrics.some((metric) => values.has(metric[2])),
|
available: group.metrics.some((metric) => values.has(metric[2])),
|
||||||
metrics: group.metrics.map(([key, label, metricName, unit]) => ({ key, label, value: values.get(metricName) ?? null, unit })),
|
metrics: group.metrics.map(([key, label, metricName, unit]) => ({
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
value: values.get(metricName) ?? null,
|
||||||
|
unit,
|
||||||
|
})),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
private unavailable(range: InfrastructureMonitoringRange, collectedAt: string): InfrastructureMonitoringOverview {
|
private unavailable(range: InfrastructureMonitoringRange, collectedAt: string): InfrastructureMonitoringOverview {
|
||||||
const services = SERVICE_DEFINITIONS.map((item) => ({ key: item.key, name: item.name, unit: item.units[0], status: 'unknown' as const }));
|
const services = SERVICE_DEFINITIONS.map((item) => ({
|
||||||
|
key: item.key,
|
||||||
|
name: item.name,
|
||||||
|
unit: item.units[0],
|
||||||
|
status: 'unknown' as const,
|
||||||
|
}));
|
||||||
return {
|
return {
|
||||||
available: false,
|
available: false,
|
||||||
range,
|
range,
|
||||||
collectedAt,
|
collectedAt,
|
||||||
lastSampleAt: null,
|
lastSampleAt: null,
|
||||||
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: [],
|
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: [],
|
||||||
|
})),
|
||||||
alerts: [],
|
alerts: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -404,15 +565,26 @@ export class InfrastructureMonitoringService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private queryRange(query: string, start: number, end: number, step: number) {
|
private queryRange(query: string, start: number, end: number, step: number) {
|
||||||
return this.getJson<PrometheusQueryResponse>('/api/v1/query_range', { query, start: String(start), end: String(end), step: String(step) });
|
return this.getJson<PrometheusQueryResponse>('/api/v1/query_range', {
|
||||||
|
query,
|
||||||
|
start: String(start),
|
||||||
|
end: String(end),
|
||||||
|
step: String(step),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getJson<T extends { status: 'success' | 'error'; error?: string }>(path: string, params: Record<string, string> = {}): Promise<T> {
|
private async getJson<T extends { status: 'success' | 'error'; error?: string }>(
|
||||||
|
path: string,
|
||||||
|
params: Record<string, string> = {},
|
||||||
|
): Promise<T> {
|
||||||
const url = new URL(`${this.prometheusUrl}${path}`);
|
const url = new URL(`${this.prometheusUrl}${path}`);
|
||||||
Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value));
|
Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value));
|
||||||
const response = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(this.queryTimeoutMs) });
|
const response = await fetch(url, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(this.queryTimeoutMs),
|
||||||
|
});
|
||||||
if (!response.ok) throw new Error(`Prometheus HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`Prometheus HTTP ${response.status}`);
|
||||||
const result = await response.json() as T;
|
const result = (await response.json()) as T;
|
||||||
if (result.status !== 'success') throw new Error('Prometheus query failed');
|
if (result.status !== 'success') throw new Error('Prometheus query failed');
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -634,7 +634,7 @@ describe('OperationsService', () => {
|
|||||||
},
|
},
|
||||||
today: expect.objectContaining({
|
today: expect.objectContaining({
|
||||||
returnedCents: 10,
|
returnedCents: 10,
|
||||||
segmentCount: 20,
|
segmentCount: 2,
|
||||||
deliveredSegmentCount: 18,
|
deliveredSegmentCount: 18,
|
||||||
arrivalRate: 90,
|
arrivalRate: 90,
|
||||||
billedCents: 360,
|
billedCents: 360,
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import { moneyToNumber } from '../../common/money';
|
import { moneyToNumber } from '../../common/money';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts';
|
|
||||||
import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers';
|
import {
|
||||||
|
messageWhere,
|
||||||
|
qualityBusinessDay,
|
||||||
|
returnedTransactionWhere,
|
||||||
|
downstreamAlertWindows,
|
||||||
|
stalledPendingWhere,
|
||||||
|
clientBatchTaskView,
|
||||||
|
clientAccountView,
|
||||||
|
clientRechargeView,
|
||||||
|
summarizeMessageGroups,
|
||||||
|
} from '../operations.helpers';
|
||||||
|
|
||||||
// R2 dashboard query domain. Method bodies are preserved byte-for-byte from the facade baseline.
|
// R2 dashboard query domain. Method bodies are preserved byte-for-byte from the facade baseline.
|
||||||
export class OperationsDashboardQueries {
|
export class OperationsDashboardQueries {
|
||||||
@@ -94,13 +102,15 @@ async dashboard(query: { tenantId?: string }) {
|
|||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 10,
|
take: 10,
|
||||||
}),
|
}),
|
||||||
this.prisma.$queryRaw<Array<{
|
this.prisma.$queryRaw<
|
||||||
|
Array<{
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
tenantName: string;
|
tenantName: string;
|
||||||
todaySpendCents: bigint;
|
todaySpendCents: bigint;
|
||||||
balanceCents: bigint;
|
balanceCents: bigint;
|
||||||
creditCents: bigint;
|
creditCents: bigint;
|
||||||
}>>(Prisma.sql`
|
}>
|
||||||
|
>(Prisma.sql`
|
||||||
SELECT
|
SELECT
|
||||||
tenant.id AS "tenantId",
|
tenant.id AS "tenantId",
|
||||||
tenant.name AS "tenantName",
|
tenant.name AS "tenantName",
|
||||||
@@ -118,12 +128,14 @@ async dashboard(query: { tenantId?: string }) {
|
|||||||
GROUP BY tenant.id, tenant.name, account."balanceCents", account."creditCents"
|
GROUP BY tenant.id, tenant.name, account."balanceCents", account."creditCents"
|
||||||
ORDER BY "todaySpendCents" DESC, tenant.name ASC
|
ORDER BY "todaySpendCents" DESC, tenant.name ASC
|
||||||
`),
|
`),
|
||||||
this.prisma.$queryRaw<Array<{
|
this.prisma.$queryRaw<
|
||||||
|
Array<{
|
||||||
segmentCount: bigint;
|
segmentCount: bigint;
|
||||||
deliveredSegmentCount: bigint;
|
deliveredSegmentCount: bigint;
|
||||||
billedCents: bigint;
|
billedCents: bigint;
|
||||||
costCents: bigint;
|
costCents: bigint;
|
||||||
}>>(Prisma.sql`
|
}>
|
||||||
|
>(Prisma.sql`
|
||||||
WITH segment_metrics AS (
|
WITH segment_metrics AS (
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(segment.id)::bigint AS "segmentCount",
|
COUNT(segment.id)::bigint AS "segmentCount",
|
||||||
@@ -208,11 +220,13 @@ async dashboard(query: { tenantId?: string }) {
|
|||||||
updatedAt: { gte: downstreamAlertWindow.recentFailedAt },
|
updatedAt: { gte: downstreamAlertWindow.recentFailedAt },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.$queryRaw<Array<{
|
this.prisma.$queryRaw<
|
||||||
|
Array<{
|
||||||
hour: number;
|
hour: number;
|
||||||
submittedCount: bigint;
|
submittedCount: bigint;
|
||||||
successCount: bigint;
|
successCount: bigint;
|
||||||
}>>(Prisma.sql`
|
}>
|
||||||
|
>(Prisma.sql`
|
||||||
SELECT
|
SELECT
|
||||||
EXTRACT(
|
EXTRACT(
|
||||||
HOUR FROM (message."queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai'
|
HOUR FROM (message."queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai'
|
||||||
@@ -227,11 +241,13 @@ async dashboard(query: { tenantId?: string }) {
|
|||||||
ORDER BY 1
|
ORDER BY 1
|
||||||
`),
|
`),
|
||||||
// Signature/template tables have no review timestamps, so their latest pending audit is paired with the review audit.
|
// Signature/template tables have no review timestamps, so their latest pending audit is paired with the review audit.
|
||||||
this.prisma.$queryRaw<Array<{
|
this.prisma.$queryRaw<
|
||||||
|
Array<{
|
||||||
category: string;
|
category: string;
|
||||||
count: bigint;
|
count: bigint;
|
||||||
averageProcessingMs: bigint | null;
|
averageProcessingMs: bigint | null;
|
||||||
}>>(Prisma.sql`
|
}>
|
||||||
|
>(Prisma.sql`
|
||||||
WITH review_samples AS (
|
WITH review_samples AS (
|
||||||
SELECT
|
SELECT
|
||||||
'enterpriseCertifications'::text AS category,
|
'enterpriseCertifications'::text AS category,
|
||||||
@@ -302,7 +318,8 @@ async dashboard(query: { tenantId?: string }) {
|
|||||||
]);
|
]);
|
||||||
const todayTotals = summarizeMessageGroups(todayMessageGroups);
|
const todayTotals = summarizeMessageGroups(todayMessageGroups);
|
||||||
const todayBusinessMetrics = todayBusinessMetricsRows[0];
|
const todayBusinessMetrics = todayBusinessMetricsRows[0];
|
||||||
const segmentCount = Number(todayBusinessMetrics?.segmentCount ?? 0);
|
const supplierSegmentCount = Number(todayBusinessMetrics?.segmentCount ?? 0);
|
||||||
|
const segmentCount = todayTotals.billingUnits;
|
||||||
const deliveredSegmentCount = Number(todayBusinessMetrics?.deliveredSegmentCount ?? 0);
|
const deliveredSegmentCount = Number(todayBusinessMetrics?.deliveredSegmentCount ?? 0);
|
||||||
const billedCents = moneyToNumber(todayBusinessMetrics?.billedCents);
|
const billedCents = moneyToNumber(todayBusinessMetrics?.billedCents);
|
||||||
const costCents = moneyToNumber(todayBusinessMetrics?.costCents);
|
const costCents = moneyToNumber(todayBusinessMetrics?.costCents);
|
||||||
@@ -334,7 +351,8 @@ async dashboard(query: { tenantId?: string }) {
|
|||||||
averageProcessingMs: row?.averageProcessingMs == null ? null : Number(row.averageProcessingMs),
|
averageProcessingMs: row?.averageProcessingMs == null ? null : Number(row.averageProcessingMs),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const downstreamAlertCount = downstreamStalledPendingCount + downstreamStalledAckCount + downstreamRecentFailedCount;
|
const downstreamAlertCount =
|
||||||
|
downstreamStalledPendingCount + downstreamStalledAckCount + downstreamRecentFailedCount;
|
||||||
return {
|
return {
|
||||||
taskCount,
|
taskCount,
|
||||||
messageStatus: messageGroups,
|
messageStatus: messageGroups,
|
||||||
@@ -349,7 +367,8 @@ async dashboard(query: { tenantId?: string }) {
|
|||||||
billingUnits: todayTotals.billingUnits,
|
billingUnits: todayTotals.billingUnits,
|
||||||
segmentCount,
|
segmentCount,
|
||||||
deliveredSegmentCount,
|
deliveredSegmentCount,
|
||||||
arrivalRate: segmentCount > 0 ? Number(((deliveredSegmentCount / segmentCount) * 100).toFixed(1)) : 0,
|
arrivalRate:
|
||||||
|
supplierSegmentCount > 0 ? Number(((deliveredSegmentCount / supplierSegmentCount) * 100).toFixed(1)) : 0,
|
||||||
billedCents,
|
billedCents,
|
||||||
profitCents,
|
profitCents,
|
||||||
profitRate: billedCents > 0 ? Number(((profitCents / billedCents) * 100).toFixed(1)) : 0,
|
profitRate: billedCents > 0 ? Number(((profitCents / billedCents) * 100).toFixed(1)) : 0,
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { SendingMonitorService } from './sending-monitor.module';
|
||||||
|
|
||||||
|
describe('sending alert read filters', () => {
|
||||||
|
it.each(['', 'read', 'unread'])(
|
||||||
|
'applies identical user/state/read criteria to rows and total: %s',
|
||||||
|
async (readStatus) => {
|
||||||
|
const prisma = {
|
||||||
|
$queryRawUnsafe: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
.mockResolvedValueOnce([{ total: 0 }]),
|
||||||
|
};
|
||||||
|
await new SendingMonitorService(prisma as never).alerts({ state: 'active', readStatus, page: '2' }, 'user-a');
|
||||||
|
const [list, count] = prisma.$queryRawUnsafe.mock.calls;
|
||||||
|
expect(list.slice(1)).toEqual(['user-a', 'active', readStatus, 20, 20]);
|
||||||
|
expect(count.slice(1)).toEqual(['user-a', 'active', readStatus]);
|
||||||
|
expect(list[0].split('FROM')[1].split('ORDER BY')[0].trim()).toBe(count[0].split('FROM')[1].trim());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
it('rejects unknown read states before querying', async () => {
|
||||||
|
const prisma = { $queryRawUnsafe: jest.fn() };
|
||||||
|
await expect(new SendingMonitorService(prisma as never).alerts({ readStatus: 'bogus' }, 'user-a')).rejects.toThrow(
|
||||||
|
'已读状态无效',
|
||||||
|
);
|
||||||
|
expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -312,17 +312,23 @@ export class SendingMonitorService {
|
|||||||
size = pageNumber(query.pageSize, 20, 100);
|
size = pageNumber(query.pageSize, 20, 100);
|
||||||
const state = query.state ?? '';
|
const state = query.state ?? '';
|
||||||
if (state && !['active', 'recovered', 'closed'].includes(state)) throw new BadRequestException('告警状态无效');
|
if (state && !['active', 'recovered', 'closed'].includes(state)) throw new BadRequestException('告警状态无效');
|
||||||
|
const readStatus = query.readStatus ?? '';
|
||||||
|
if (!['', 'read', 'unread'].includes(readStatus)) throw new BadRequestException('已读状态无效');
|
||||||
|
const from = `FROM "SendingMonitorAlert" a LEFT JOIN "SendingMonitorAlertRead" r ON r."alertId"=a.id AND r."userId"=$1 WHERE ($2='' OR a.state=$2) AND ($3='' OR ($3='unread' AND r."readAt" IS NULL) OR ($3='read' AND r."readAt" IS NOT NULL))`;
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.prisma.$queryRawUnsafe(
|
this.prisma.$queryRawUnsafe(
|
||||||
`SELECT a.*,r."readAt" IS NULL unread FROM "SendingMonitorAlert" a LEFT JOIN "SendingMonitorAlertRead" r ON r."alertId"=a.id AND r."userId"=$1 WHERE ($2='' OR a.state=$2) ORDER BY a."openedAt" DESC,a.id LIMIT $3 OFFSET $4`,
|
`SELECT a.*,r."readAt" IS NULL unread ${from} ORDER BY a."openedAt" DESC,a.id LIMIT $4 OFFSET $5`,
|
||||||
user,
|
user,
|
||||||
state,
|
state,
|
||||||
|
readStatus,
|
||||||
size,
|
size,
|
||||||
(page - 1) * size,
|
(page - 1) * size,
|
||||||
),
|
),
|
||||||
this.prisma.$queryRawUnsafe<Array<{ total: number }>>(
|
this.prisma.$queryRawUnsafe<Array<{ total: number }>>(
|
||||||
`SELECT count(*)::int total FROM "SendingMonitorAlert" WHERE ($1='' OR state=$1)`,
|
`SELECT count(*)::int total ${from}`,
|
||||||
|
user,
|
||||||
state,
|
state,
|
||||||
|
readStatus,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
return { items, total: total[0].total, page, pageSize: size };
|
return { items, total: total[0].total, page, pageSize: size };
|
||||||
|
|||||||
@@ -2244,3 +2244,8 @@
|
|||||||
### 2026-09-08 夜补充:公共容量控件与顶栏通知可靠性
|
### 2026-09-08 夜补充:公共容量控件与顶栏通知可靠性
|
||||||
|
|
||||||
通道报备明细及签名质量四Tab统一使用公共Pagination,去掉可见“每页数量”文字,仅显示容量选项,保持可访问名称、10/25/50/100、默认25和各Tab独立日期。运营顶栏通知刷新应合并重复触发、限制在途批次、超时取消、隐藏/离线/锁定暂停和有上限的失败退避;计数失败保留上次真实值并明确暂不可用,不能归零冒充成功。实现与浏览器外部注入问题边界见operations-fixes-20260908.md“浏览器异常与通知刷新修复”,不改业务端口、后端计数口径或短信链路。
|
通道报备明细及签名质量四Tab统一使用公共Pagination,去掉可见“每页数量”文字,仅显示容量选项,保持可访问名称、10/25/50/100、默认25和各Tab独立日期。运营顶栏通知刷新应合并重复触发、限制在途批次、超时取消、隐藏/离线/锁定暂停和有上限的失败退避;计数失败保留上次真实值并明确暂不可用,不能归零冒充成功。实现与浏览器外部注入问题边界见operations-fixes-20260908.md“浏览器异常与通知刷新修复”,不改业务端口、后端计数口径或短信链路。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-09 运营九项修正
|
||||||
|
|
||||||
|
用户确认需求及实现范围见 [九项修复设计](operations-fixes-20260909.md)。通道报备发送统计按实际运营商分开;创建/修改弹窗默认仅显式关闭;签名活跃度的企业、应用、签名、通道独立组合筛选;系统监控增加日期可选、默认近7日历史;首页客户分片按唯一业务消息汇总;发送质量告警已读计数与阅读筛选;详情行去通道组重复文案;HTTP地址随开关显示,保存校验错误居中;清退预警展示去“请通知 企业:”。本节客户分片口径替代旧供应商分片总数口径,到达率仍沿用供应商分片分子/分母。
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# 2026-09-09 九项运营修复
|
||||||
|
|
||||||
|
状态:实现及本地验收完成,线上验收待授权部署;授权为修改并本地提交,不推送、不部署。与既有报备、发送监控、系统监控及清退设计配合,以下新口径替代旧首页分片口径;历史验收记录不改写。
|
||||||
|
|
||||||
|
1. 通道报备今日发送:保留供应商提交尝试口径,聚合键加入消息实际运营商;三网报备行分别匹配运营商。引流报备及无运营商的历史通道级报备保留汇总,未知运营商不得分摊给三网。
|
||||||
|
2. 公共 Modal 默认仅显式关闭按钮关闭,遮罩和 Escape 不关闭;创建、编辑使用统一默认,保留保存成功关闭、未保存确认、焦点陷阱及恢复。
|
||||||
|
3. 企业/通道活跃度分别提供企业、应用、签名输入;通道维度另有通道输入。条件按 AND 组合,各字段内部模糊匹配;各 Tab 独立,筛选回第一页,保留日期和分页。
|
||||||
|
4. 系统监控保留活动告警,新增独立历史查询和日期范围(上海时区,默认含今天近7日,最多31日)。读取 Prometheus 保留的真实告警时间序列,明确采样和保留周期边界;采样缺失不等于确定恢复,不伪造已读、解决时间、摘要或历史数据库记录。无数据库迁移,不写监控配置。
|
||||||
|
5. 今日消息分片数按客户业务消息 queuedAt 的上海自然日汇总 billingUnits,每个消息仅一次;不受多通道提交、补发和供应商分片变化影响。到达率继续使用原供应商分片分子/分母,避免只换分母导致超过100%;供应商分片数仅用于到达率内部计算,利润和收入口径不变。
|
||||||
|
6. 发送质量告警新增 readStatus=read/unread/空,列表与 total 同条件,并与生命周期筛选 AND。已读只影响当前用户未读活动告警数,不消除真实活动告警;保持幂等及顶栏刷新。
|
||||||
|
7. 发送详情仅移除通道发送与回执每行通道组文案,保留顶部汇总和真实通道/回执字段。
|
||||||
|
8. HTTP 开关关闭时回执、上行地址与其他 HTTP 参数一并隐藏,不清空值;保存失败和校验错误用居中 Modal 展示,保留表单内容,不把部分写入冒报为成功。
|
||||||
|
9. 清退预警明细展示去掉行首“请通知 企业名称:”,兼容已有消息;不改数据库历史内容和外发模板,不发送通知。
|
||||||
|
|
||||||
|
验收:定向失败回归、前端/API全量、类型/构建和质量门禁;真实 PostgreSQL 只读对账和真实 API/浏览器三尺寸。禁止发送短信和更改客户/通道/余额,写入场景仅隔离测试;缺少环境明确标记未验证。已有脏文件按开工副本保护,文档仅暂存本轮增量。
|
||||||
|
|
||||||
|
执行结果与限制见 [测试进度](testing-progress.md) 的“2026-09-09 九项运营修复执行结果”。本地提交仅包含本轮代码及上述文档增量;测试、预生产版本未变化。
|
||||||
@@ -249,3 +249,10 @@ type InfrastructureOverview = {
|
|||||||
- “已读”只表示某位管理员已查看某一次 Prometheus 活动告警,不是 resolve、silence 或 acknowledge 外部告警管理器;页面活动告警总数与平台健康状态仍按 Prometheus 原始 firing/pending 计算。
|
- “已读”只表示某位管理员已查看某一次 Prometheus 活动告警,不是 resolve、silence 或 acknowledge 外部告警管理器;页面活动告警总数与平台健康状态仍按 Prometheus 原始 firing/pending 计算。
|
||||||
- 指纹由排序后的 Prometheus labels 稳定生成,`activeAt`区分同一指纹的不同触发周期。数据库以`fingerprint + userId`唯一,upsert同时更新`activeAt/readAt`;读取时只有数据库 activeAt 与当前 Prometheus activeAt 相同才算已读。
|
- 指纹由排序后的 Prometheus labels 稳定生成,`activeAt`区分同一指纹的不同触发周期。数据库以`fingerprint + userId`唯一,upsert同时更新`activeAt/readAt`;读取时只有数据库 activeAt 与当前 Prometheus activeAt 相同才算已读。
|
||||||
- 标记前必须回读当前 Prometheus 告警并校验指纹和 activeAt,防止客户端伪造或把已经恢复的新周期误标已读。预警中心轻量汇总只扣减当前管理员本次已读项;数据库故障不得用 localStorage 或静态状态替代。
|
- 标记前必须回读当前 Prometheus 告警并校验指纹和 activeAt,防止客户端伪造或把已经恢复的新周期误标已读。预警中心轻量汇总只扣减当前管理员本次已读项;数据库故障不得用 localStorage 或静态状态替代。
|
||||||
|
|
||||||
|
|
||||||
|
## 12. 历史告警查询(2026-09-09)
|
||||||
|
|
||||||
|
新增GET /admin/infrastructure-monitoring/alert-history,继承运营端会话鉴权,参数from/to为上海自然日,默认含今天近7日、最多31日,page正整数、每页25条。逐日查询Prometheus原始ALERTS_FOR_STATE范围向量,以标签指纹+activeAt分开触发周期,跨日采样合并;不使用粗粒度步长丢掉短周期,不把等待触发当作已发送告警。保留真实触发时间和范围内最后观测时间,最后观测不代表准确恢复。
|
||||||
|
|
||||||
|
历史和活动列表独立;历史不提供伪造恢复状态、旧annotations、批量已读或清理功能。超出Prometheus保留期/采集缺口的历史无法追溯,界面明确说明。失败返回503并展示错误,日期非法返回400;不迁移数据库、不变更阈值或采集配置。完整实现/验收及限制见operations-fixes-20260909.md。
|
||||||
|
|||||||
@@ -5335,3 +5335,20 @@ OPS0908-01至07已按本轮范围验证;精确证据见testing-progress.md对
|
|||||||
| OPS-PAGE0908-06、通知登录后业务与恢复演练 | 预生产现有会话锁定,待用户解锁后验证;未改账号,不能引用测试环境通过作为此项通过。独立恢复资产已保留,实际恢复演练未执行 |
|
| OPS-PAGE0908-06、通知登录后业务与恢复演练 | 预生产现有会话锁定,待用户解锁后验证;未改账号,不能引用测试环境通过作为此项通过。独立恢复资产已保留,实际恢复演练未执行 |
|
||||||
|
|
||||||
证据:%TEMP%/cmpp-starttime-pagination-20260908/public-verify/run-2026-09-08T15-03-44-416Z/verification.json 与 public-verify/anonymous-2026-09-08T15-07-16-145Z/review.json。复核状态为 anonymous_ui_verified_with_network_limitation,未宣称预生产业务全部通过或历史 startTime/网络关闭问题全部根除。
|
证据:%TEMP%/cmpp-starttime-pagination-20260908/public-verify/run-2026-09-08T15-03-44-416Z/verification.json 与 public-verify/anonymous-2026-09-08T15-07-16-145Z/review.json。复核状态为 anonymous_ui_verified_with_network_limitation,未宣称预生产业务全部通过或历史 startTime/网络关闭问题全部根除。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-09 九项运营修复验收
|
||||||
|
|
||||||
|
设计见 [九项修复](operations-fixes-20260909.md),执行结果单独记testing-progress.md。本节TC-OPS0909-05替代TC-DASHBOARD-FRAGMENT-001的首页分片口径,历史测试结果不改写。
|
||||||
|
|
||||||
|
| 用例 | 场景 | 预期 |
|
||||||
|
|---|---|---|
|
||||||
|
| TC-OPS0909-01 | 同签名三网通道分别1/2/3次提交,并混入未知运营商;回执状态各异 | 三网独立统计数量、比例及最近成功时间;未知不分摊;历史无运营商通道级及引流仍汇总 |
|
||||||
|
| TC-OPS0909-02 | 创建/编辑,未修改/已修改,遮罩、内容、Escape、页脚和叉;连续快速关闭 | 非显式动作不关闭;保存成功/关闭按钮正常;dirty确认、焦点恢复无卸载异常 |
|
||||||
|
| TC-OPS0909-03 | 活跃度企业、应用、签名、通道组合与空条件、跨Tab、刷新、三尺寸 | AND过滤、字段独立、筛选回第一页,日期/分页保持原行为、无窄屏溢出 |
|
||||||
|
| TC-OPS0909-04 | 历史默认日期、指定日期、重复触发、跨日、分页、非法日期、Prometheus失败 | 近7日真实历史按周期分开;最后采样不假定恢复;失败不假成功,无保留数据明确空态 |
|
||||||
|
| TC-OPS0909-05 | 同客户长短信经多个通道多次提交,另有未路由消息及跨日记录 | 今日按消息billingUnits一次汇总;换通道不重复,含今日尚未向供应商提交的消息;到达率分母不混换 |
|
||||||
|
| TC-OPS0909-06 | 当前用户已读、重复已读、另一管理员、read/unread与生命周期组合、快速切换 | 未读活动数恰好扣一次,其他用户不受影响;列表/total同条件,顶栏刷新,慢响应不覆盖新筛选 |
|
||||||
|
| TC-OPS0909-07 | 短信记录发送详情多个提交/回执 | 每行无通道组文案,顶部汇总及通道/回执数据仍存在 |
|
||||||
|
| TC-OPS0909-08 | HTTP关/开/关/开;本地及API校验失败 | 地址随HTTP参数隐藏展示且保值;居中错误弹窗保留表单,不伪报保存成功 |
|
||||||
|
| TC-OPS0909-09 | 历史/新清退预警多行、包含企业名称、非前缀正文 | 仅展示去行首请通知企业文案,保留签名/运营商/数量正文;不改数据库/外发消息 |
|
||||||
|
|||||||
@@ -4820,3 +4820,21 @@ git diff --check
|
|||||||
- 匿名浏览器 1600×1000、1366×768、390×844 页面与截图复核正常,验证码均 200、pageerror 均 0,实际主 JS 不含 react_stack_bottom_frame/JSX dev runtime。两个 /cdn-cgi/rum POST 由只读验收策略主动阻断,单独列为预期;另一次外域 GET 在响应头前真实 ERR_CONNECTION_CLOSED,初始探针未保留具体来源,网络关闭层仍未确定,不归为已修复。原自动化还误要求登录页自发请求 session;经核验页面没有该请求,独立匿名 GET /api/admin/auth/session 返回预期 401,不能冒称执行登录。
|
- 匿名浏览器 1600×1000、1366×768、390×844 页面与截图复核正常,验证码均 200、pageerror 均 0,实际主 JS 不含 react_stack_bottom_frame/JSX dev runtime。两个 /cdn-cgi/rum POST 由只读验收策略主动阻断,单独列为预期;另一次外域 GET 在响应头前真实 ERR_CONNECTION_CLOSED,初始探针未保留具体来源,网络关闭层仍未确定,不归为已修复。原自动化还误要求登录页自发请求 session;经核验页面没有该请求,独立匿名 GET /api/admin/auth/session 返回预期 401,不能冒称执行登录。
|
||||||
- 生产 React、通知轮询及公共分页修复已发布;测试环境的真实业务验收结论保留原环境。预生产现有登录会话因空闲锁定,待用户解锁,登录后分页/通知业务验收尚未完成;未读取新凭据、修改账号配置或恢复管理员。Chrome DevTools startTime 沿用此前注入归因与已合入上游修复的证据,应用不屏蔽异常;不宣称所有 Chrome 或历史网络 CLOSED 问题已解决。
|
- 生产 React、通知轮询及公共分页修复已发布;测试环境的真实业务验收结论保留原环境。预生产现有登录会话因空闲锁定,待用户解锁,登录后分页/通知业务验收尚未完成;未读取新凭据、修改账号配置或恢复管理员。Chrome DevTools startTime 沿用此前注入归因与已合入上游修复的证据,应用不屏蔽异常;不宣称所有 Chrome 或历史网络 CLOSED 问题已解决。
|
||||||
- 证据:主 TEMP/public-verify/run-2026-09-08T15-03-44-416Z/verification.json 与 public-verify/anonymous-2026-09-08T15-07-16-145Z/review.json(主 TEMP=%TEMP%/cmpp-starttime-pagination-20260908)。后者状态为 anonymous_ui_verified_with_network_limitation,明确保留原始自动化失败和人工复核差异,不记为登录后全通过。
|
- 证据:主 TEMP/public-verify/run-2026-09-08T15-03-44-416Z/verification.json 与 public-verify/anonymous-2026-09-08T15-07-16-145Z/review.json(主 TEMP=%TEMP%/cmpp-starttime-pagination-20260908)。后者状态为 anonymous_ui_verified_with_network_limitation,明确保留原始自动化失败和人工复核差异,不记为登录后全通过。
|
||||||
|
|
||||||
|
## 2026-09-09 九项运营修复执行结果(23:10 CST)
|
||||||
|
|
||||||
|
授权:修改并本地提交;未授权推送、测试部署或预生产部署。设计及根因见 [九项运营修复](operations-fixes-20260909.md),用例 TC-OPS0909-01 至09。本轮实现与本地验收完成,线上验收待部署。
|
||||||
|
|
||||||
|
- Git 开工核验:main / HEAD / 实际 origin/main 均为 6d63eb5452ffc7c802960d044bf598cc8646564d。开工已有19个 tracked 和21个具体 untracked 文件;40份开工副本逐一校验,既有文件内容保留。metrics、发布工具/脚本、AGENTS及已有治理文档不夹带。需求/UI/监控设计/系统用例/本记录仅提交本轮追加部分。
|
||||||
|
- 根因:报备 SQL 未按消息运营商聚合,三网任务重复取通道/签名总数;首页错误采用供应商分片,现按 queuedAt 上海自然日客户消息 billingUnits 求和。到达率保留供应商口径,未改变计费。
|
||||||
|
- 关闭交互:检索74个文件127处公共 Modal 调用,默认禁止遮罩/Escape关闭,并修复卸载时未取消的焦点动画帧;通道编辑的显式旧设置一并收敛。保留显式关闭、dirty确认和成功保存。只读签名详情抽屉非创建/编辑表单,未改其交互。
|
||||||
|
- 自动验证:前端27文件136测试通过(maxWorkers=2);API66套701测试通过(工作区含原有未提交metrics测试,不将其计作本轮新增/提交)。前后端TypeScript/生产构建、lint、格式、Stylelint、CSS治理15测试、bundle/security/deploy静态门禁通过。lint仍有既有AdminAnalyticsPage loadData依赖警告,0 error;入口gzip107.14KiB,在250KiB预算内。Gateway/队列发送链路未改,未执行发送smoke或压测。
|
||||||
|
- 真实环境:本地独立克隆库 cmpp_qa_nine_1788964857865 从原本地95迁移版本执行既有迁移到100;未迁移原库或远端。真实PostgreSQL、API和生产构建浏览器登录验收,非mock/假会话。认证使用现有Redis7通过独立DB15及本轮随机key前缀,队列留在独立本地Redis;不接短信发送Worker。Browser插件不可用,采用已安装Playwright + Edge。
|
||||||
|
- 非零数据:隔离SQL记录经真实API验证三网1/2/3次,未知运营商不分摊;7条业务消息共14客户分片,对应两通道8条提交尝试,首页仍14。 fixture为已拒绝/失败数据,仅SQL写入独立库,无入队/发送。当前用户告警已读数减1,重复读不再扣,read/unread列表与总数一致;其他用户隔离由现有实现及单元测试覆盖。
|
||||||
|
- 浏览器:企业/通道签名活动独立字段和三尺寸1600×1000、1366×768、390×844;创建通道遮罩/Escape不关闭且显式关闭正常;HTTP地址关/开跟随参数,非法扩展码在居中错误框展示且不保存配置。历史告警默认近7日取得真实Prometheus25个周期,选择今天后请求成功,三尺寸截图;短信历史记录详情不再有每行通道组,顶部保留;独立库清退消息经真实API展示去前缀,数据库原文不变。上述成功运行无未捕获pageerror,未声称逐个手工验收127处弹窗。
|
||||||
|
- 当前环境只读证据:本轮约22:20预生产今日10257条客户消息、18270客户分片、31482供应商分片;近7日三网尝试13311/13647/13572、未知28,证明口径差异。测试和预生产仍为809175b544f2526891ba6d2dece1a50eadaf57a0,均未发布本轮代码。MinIO/Gateway写路径不在修改范围,未验证文件上传和真实短信链路。
|
||||||
|
- 原失败保留:前端首轮高并发超时、观察器按钮/label不匹配、日期选定后未点确定、SQL fixture先用非统计状态failed以及清退fixture先缺关联检测记录均修正后重跑,不能记为产品通过。Prometheus隧道曾Connection reset造成一次真实503,重建只读连接后成功;未屏蔽应用异常。Redis5不支持认证GETDEL且原库缺表,后改独立库及真实Redis7进行隔离验收。
|
||||||
|
- 本地启动影响:首轮API启动曾自动尝试连接两个通道,因本地Gateway不可达失败,写入本地CmppConnectionState运行状态;未发送短信、未修改通道配置。后续延后启动重连、关闭相关扫描器与归档/报表调度。临时QA用户/操作日志/告警和业务fixture及认证前缀按本轮ID清理;不恢复或改写原有运行状态。
|
||||||
|
- 遗留边界:创建通道390宽原有布局拥挤,关闭按钮可见可用,未扩大为视觉改版;历史告警受Prometheus保留和采样限制,含等待触发周期,最后采样不等于恢复时刻。预生产历史ERR_CONNECTION_CLOSED根因和已有管理员会话锁定未在本轮解决,未尝试恢复管理员。未推送、未测试部署、未预生产部署,线上真实业务验收未执行。
|
||||||
|
|
||||||
|
本地证据目录:C:/Users/hectorzhao/AppData/Local/Temp/cmpp-nine-fixes-20260909。自动日志api-full.log、frontend-final.log、build-last.log、api-build-final.log、lint-last.log、security.log等;浏览器成功记录browser-run7.log、browser-extra2.log、browser-retirement2.log、browser-counts2.log及各browser-*目录截图/结果,失败日志保留。敏感认证值不写入文档或Git。
|
||||||
|
|||||||
@@ -142,3 +142,8 @@
|
|||||||
### 公共分页容量展示补充(2026-09-08 夜)
|
### 公共分页容量展示补充(2026-09-08 夜)
|
||||||
|
|
||||||
公共Pagination中的容量Select只显示“10/25/50/100 条/页”选中项,不显示“每页数量”标题;与翻页控件放在同一操作区。复用sr-only隐藏标签并以唯一id关联,保持键盘和读屏可访问;不要删除其他表单Select的可见标签。签名质量四Tab和通道报备明细沿用该组件,默认25和独立筛选状态保持。顶栏通知接口失败时须说明计数暂不可用,既有真实值只能作为上次数据保留,不能归零伪装无通知。
|
公共Pagination中的容量Select只显示“10/25/50/100 条/页”选中项,不显示“每页数量”标题;与翻页控件放在同一操作区。复用sr-only隐藏标签并以唯一id关联,保持键盘和读屏可访问;不要删除其他表单Select的可见标签。签名质量四Tab和通道报备明细沿用该组件,默认25和独立筛选状态保持。顶栏通知接口失败时须说明计数暂不可用,既有真实值只能作为上次数据保留,不能归零伪装无通知。
|
||||||
|
|
||||||
|
|
||||||
|
### 弹窗显式关闭补充(2026-09-09)
|
||||||
|
|
||||||
|
公共Modal默认closeOnBackdrop=false、closeOnEscape=false;全部创建/修改表单沿用该默认,点击遮罩、内容空白或Escape不关闭。页脚关闭/取消及右上角叉是显式关闭入口,仍保留dirty确认;提交成功可由业务关闭。只读公共弹窗同样采用显式关闭默认,自定义只读质量抽屉保持原交互。
|
||||||
|
|||||||
@@ -7,6 +7,24 @@ import type {
|
|||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
export const adminInfrastructureMonitoringApi = {
|
export const adminInfrastructureMonitoringApi = {
|
||||||
|
getInfrastructureAlertHistory: (from?: string, to?: string, page = 1) =>
|
||||||
|
request<{
|
||||||
|
items: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
severity: string;
|
||||||
|
service: string;
|
||||||
|
instance: string;
|
||||||
|
startedAt: string;
|
||||||
|
firstObservedAt: string;
|
||||||
|
lastObservedAt: string;
|
||||||
|
}>;
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
}>(withQuery('/admin/infrastructure-monitoring/alert-history', { from, to, page })),
|
||||||
getInfrastructureMonitoringOverview: (range: InfrastructureMonitoringRange) =>
|
getInfrastructureMonitoringOverview: (range: InfrastructureMonitoringRange) =>
|
||||||
request<InfrastructureMonitoringOverview>(withQuery('/admin/infrastructure-monitoring/overview', { range })),
|
request<InfrastructureMonitoringOverview>(withQuery('/admin/infrastructure-monitoring/overview', { range })),
|
||||||
getInfrastructureMonitoringNotificationSummary: (signal?: AbortSignal) =>
|
getInfrastructureMonitoringNotificationSummary: (signal?: AbortSignal) =>
|
||||||
|
|||||||
@@ -10,6 +10,23 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-analytics-page .signature-retirement-heatmap__heading {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-analytics-page .analytics-activity-filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: end;
|
||||||
|
gap: var(--space-3);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-analytics-page .analytics-activity-filters .ui-field {
|
||||||
|
flex: 1 1 180px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@media (width <= 600px) {
|
@media (width <= 600px) {
|
||||||
.admin-analytics-page .page-heading {
|
.admin-analytics-page .page-heading {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
|||||||
@@ -8,6 +8,47 @@ const { api } = vi.hoisted(() => ({
|
|||||||
vi.mock('@/api/adminApi', () => ({ adminApi: api }));
|
vi.mock('@/api/adminApi', () => ({ adminApi: api }));
|
||||||
|
|
||||||
describe('independent analytics tabs', () => {
|
describe('independent analytics tabs', () => {
|
||||||
|
it('combines separate activity search fields with AND and preserves them across tabs', async () => {
|
||||||
|
api.getSignatureRetirementHeatmap.mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
dimensions: [
|
||||||
|
{
|
||||||
|
dimensionType: 'channel',
|
||||||
|
signatureId: 'a',
|
||||||
|
signatureName: '签名甲',
|
||||||
|
tenantName: '企业甲',
|
||||||
|
applicationName: '应用甲',
|
||||||
|
channelName: '通道甲',
|
||||||
|
channelId: 'c',
|
||||||
|
carrier: 'mobile',
|
||||||
|
approvedAt: '2026-08-01',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dimensionType: 'channel',
|
||||||
|
signatureId: 'b',
|
||||||
|
signatureName: '签名乙',
|
||||||
|
tenantName: '企业甲',
|
||||||
|
applicationName: '应用乙',
|
||||||
|
channelName: '通道乙',
|
||||||
|
channelId: 'd',
|
||||||
|
carrier: 'unicom',
|
||||||
|
approvedAt: '2026-08-01',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
render(<AdminAnalyticsPage />);
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: '通道签名活跃度' }));
|
||||||
|
const panel = screen.getByRole('region', { name: '通道签名活跃度' });
|
||||||
|
await within(panel).findByText('签名甲');
|
||||||
|
fireEvent.change(within(panel).getByLabelText('企业'), { target: { value: '企业甲' } });
|
||||||
|
fireEvent.change(within(panel).getByLabelText('企业应用'), { target: { value: '应用甲' } });
|
||||||
|
await waitFor(() => expect(within(panel).queryByText('签名乙')).not.toBeInTheDocument());
|
||||||
|
fireEvent.change(within(panel).getByLabelText('通道'), { target: { value: '通道乙' } });
|
||||||
|
await waitFor(() => expect(within(panel).queryByText('签名甲')).not.toBeInTheDocument());
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: '企业签名活跃度' }));
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: '通道签名活跃度' }));
|
||||||
|
expect(within(panel).getByLabelText('企业应用')).toHaveValue('应用甲');
|
||||||
|
});
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
api.getSignatureQuality.mockImplementation(async (query) => ({ ...query, total: 0, items: [] }));
|
api.getSignatureQuality.mockImplementation(async (query) => ({ ...query, total: 0, items: [] }));
|
||||||
|
|||||||
@@ -382,8 +382,8 @@ function RetirementHeatmap({
|
|||||||
title: string;
|
title: string;
|
||||||
}) {
|
}) {
|
||||||
const [pageState, setPageState] = useState({ key: '', page: 1 });
|
const [pageState, setPageState] = useState({ key: '', page: 1 });
|
||||||
const [keyword, setKeyword] = useState('');
|
const [filters, setFilters] = useState({ tenantName: '', applicationName: '', signatureName: '', channelName: '' });
|
||||||
const deferredKeyword = useDeferredValue(keyword.trim().toLocaleLowerCase('zh-CN'));
|
const deferredFilters = useDeferredValue(filters);
|
||||||
const visible = items.filter((item) => item.dimensionType === dimensionType);
|
const visible = items.filter((item) => item.dimensionType === dimensionType);
|
||||||
const dates = previousDateKeys(date, 30);
|
const dates = previousDateKeys(date, 30);
|
||||||
const cellMap = new Map(
|
const cellMap = new Map(
|
||||||
@@ -394,11 +394,13 @@ function RetirementHeatmap({
|
|||||||
);
|
);
|
||||||
const rows = dimensions
|
const rows = dimensions
|
||||||
.filter((item) => item.dimensionType === dimensionType)
|
.filter((item) => item.dimensionType === dimensionType)
|
||||||
.filter(
|
.filter((item) =>
|
||||||
(item) =>
|
Object.entries(deferredFilters).every(
|
||||||
!deferredKeyword ||
|
([key, value]) =>
|
||||||
[item.channelName, item.tenantName, item.applicationName, item.signatureName].some((value) =>
|
!value.trim() ||
|
||||||
value?.toLocaleLowerCase('zh-CN').includes(deferredKeyword),
|
(item[key as keyof typeof deferredFilters] ?? '')
|
||||||
|
.toLocaleLowerCase('zh-CN')
|
||||||
|
.includes(value.trim().toLocaleLowerCase('zh-CN')),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
@@ -419,7 +421,7 @@ function RetirementHeatmap({
|
|||||||
}))
|
}))
|
||||||
.sort((left, right) => right.total - left.total || left.signatureName.localeCompare(right.signatureName, 'zh-CN'));
|
.sort((left, right) => right.total - left.total || left.signatureName.localeCompare(right.signatureName, 'zh-CN'));
|
||||||
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
|
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
|
||||||
const paginationKey = JSON.stringify([date, deferredKeyword, dimensionType, dimensions.length, pageSize]);
|
const paginationKey = JSON.stringify([date, deferredFilters, dimensionType, dimensions.length, pageSize]);
|
||||||
const page = pageState.key === paginationKey ? pageState.page : 1;
|
const page = pageState.key === paginationKey ? pageState.page : 1;
|
||||||
const setPage = (value: number) => setPageState({ key: paginationKey, page: value });
|
const setPage = (value: number) => setPageState({ key: paginationKey, page: value });
|
||||||
const currentPage = Math.min(page, totalPages);
|
const currentPage = Math.min(page, totalPages);
|
||||||
@@ -432,13 +434,23 @@ function RetirementHeatmap({
|
|||||||
<h2>{title}</h2>
|
<h2>{title}</h2>
|
||||||
<p className="muted">数字为真实受理业务短信数;零提交为灰色,非零按现有六档成功率色阶展示。</p>
|
<p className="muted">数字为真实受理业务短信数;零提交为灰色,非零按现有六档成功率色阶展示。</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="signature-retirement-heatmap__actions">
|
<div className="analytics-activity-filters">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['tenantName', '企业'],
|
||||||
|
['applicationName', '企业应用'],
|
||||||
|
['signatureName', '签名'],
|
||||||
|
...(dimensionType === 'channel' ? [['channelName', '通道']] : []),
|
||||||
|
] as Array<[keyof typeof filters, string]>
|
||||||
|
).map(([key, label]) => (
|
||||||
<Input
|
<Input
|
||||||
aria-label={`${title}搜索通道、企业、企业应用或签名`}
|
key={key}
|
||||||
onChange={(event) => setKeyword(event.target.value)}
|
label={label}
|
||||||
placeholder={dimensionType === 'channel' ? '搜索通道、企业、应用或签名' : '搜索企业、应用或签名'}
|
placeholder={`搜索${label}`}
|
||||||
value={keyword}
|
value={filters[key]}
|
||||||
|
onChange={(event) => setFilters((current) => ({ ...current, [key]: event.target.value }))}
|
||||||
/>
|
/>
|
||||||
|
))}
|
||||||
<Tag tone="info">T-1 至 T-30</Tag>
|
<Tag tone="info">T-1 至 T-30</Tag>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -500,7 +512,7 @@ function RetirementHeatmap({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="empty-state">
|
<p className="empty-state">
|
||||||
{deferredKeyword
|
{Object.values(deferredFilters).some((value) => value.trim())
|
||||||
? '没有匹配企业、企业应用或签名的热力图维度。'
|
? '没有匹配企业、企业应用或签名的热力图维度。'
|
||||||
: '暂无已确认到运营商的报备事实,尚未形成检测热力图。'}
|
: '暂无已确认到运营商的报备事实,尚未形成检测热力图。'}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -197,12 +197,15 @@ export function AdminSignatureRetirementPage() {
|
|||||||
{item.dailyGroupKey ? (
|
{item.dailyGroupKey ? (
|
||||||
<details>
|
<details>
|
||||||
<summary>{item.detections?.length ?? 0} 项预警明细</summary>
|
<summary>{item.detections?.length ?? 0} 项预警明细</summary>
|
||||||
{item.content.split('\n').map((line, index) => (
|
{retirementDisplayContent(item.content, item.tenantName)
|
||||||
|
.split('\n')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line, index) => (
|
||||||
<p key={index}>{line}</p>
|
<p key={index}>{line}</p>
|
||||||
))}
|
))}
|
||||||
</details>
|
</details>
|
||||||
) : (
|
) : (
|
||||||
item.content
|
retirementDisplayContent(item.content, item.tenantName)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -983,3 +986,17 @@ function differenceInDateKeys(from: string, to: string) {
|
|||||||
const toDate = new Date(`${to}T12:00:00+08:00`);
|
const toDate = new Date(`${to}T12:00:00+08:00`);
|
||||||
return Math.round((toDate.getTime() - fromDate.getTime()) / 86_400_000);
|
return Math.round((toDate.getTime() - fromDate.getTime()) / 86_400_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function retirementDisplayContent(content: string, tenantName?: string | null) {
|
||||||
|
return content
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => {
|
||||||
|
const trimmed = line.trimStart();
|
||||||
|
if (!tenantName || !trimmed.startsWith('请通知')) return line;
|
||||||
|
const rest = trimmed.slice(3).trimStart();
|
||||||
|
if (!rest.startsWith(tenantName)) return line;
|
||||||
|
const afterName = rest.slice(tenantName.length).trimStart();
|
||||||
|
return /^[::]/.test(afterName) ? afterName.slice(1).trimStart() : line;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { AdminSmsApplicationFormPage } from './AdminSmsApplicationFormPage';
|
||||||
|
|
||||||
|
vi.mock('@/api/adminApi', () => ({
|
||||||
|
adminApi: {
|
||||||
|
listChannelGroups: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([{ id: 'group', name: '移动测试组', carrier: 'mobile', status: 'active' }]),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('application form feedback', () => {
|
||||||
|
it('hides HTTP addresses with the protocol and retains input across toggles; invalid save uses a modal', async () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/enterprise/e/new']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/enterprise/:enterpriseId/new" element={<AdminSmsApplicationFormPage />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: '未开通' })).toBeInTheDocument());
|
||||||
|
expect(screen.queryByLabelText(/^HTTP 回执地址/)).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '未开通' }));
|
||||||
|
fireEvent.change(screen.getByLabelText(/^HTTP 回执地址/), { target: { value: 'https://example.test/receipt' } });
|
||||||
|
expect(screen.getByLabelText(/^HTTP 上行地址/)).toBeInTheDocument();
|
||||||
|
fireEvent.click(
|
||||||
|
within(document.querySelector('.admin-app-protocol-section--http') as HTMLElement).getByRole('button', {
|
||||||
|
name: '已开通',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(screen.queryByLabelText(/^HTTP 回执地址/)).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '未开通' }));
|
||||||
|
expect(screen.getByLabelText(/^HTTP 回执地址/)).toHaveValue('https://example.test/receipt');
|
||||||
|
fireEvent.change(screen.getByLabelText(/^应用名称/), { target: { value: '测试应用' } });
|
||||||
|
fireEvent.click(screen.getByLabelText('移动通道组'));
|
||||||
|
fireEvent.click(screen.getByRole('option', { name: '移动测试组' }));
|
||||||
|
fireEvent.change(screen.getByLabelText(/^应用扩展码/), { target: { value: 'invalid' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '创建应用' }));
|
||||||
|
const dialog = screen.getByRole('dialog', { name: '短信应用保存失败' });
|
||||||
|
expect(within(dialog).getByRole('alert')).toHaveTextContent('应用扩展码只能填写数字');
|
||||||
|
fireEvent.mouseDown(document.querySelector('.ui-modal__mask')!);
|
||||||
|
expect(dialog).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { ArrowLeft, Globe2, Info, RadioTower, RefreshCw } from 'lucide-react';
|
import { ArrowLeft, Globe2, Info, RadioTower, RefreshCw } from 'lucide-react';
|
||||||
import { adminApi, type ChannelGroup, type DictionaryItem, type EnterpriseApplication, type HttpApiConfig } from '@/api/adminApi';
|
import {
|
||||||
import { Breadcrumb, Button, CarrierTag, Input, Select, Tag } from '@/components/ui';
|
adminApi,
|
||||||
|
type ChannelGroup,
|
||||||
|
type DictionaryItem,
|
||||||
|
type EnterpriseApplication,
|
||||||
|
type HttpApiConfig,
|
||||||
|
} from '@/api/adminApi';
|
||||||
|
import { Breadcrumb, Button, CarrierTag, Input, Modal, Select, Tag } from '@/components/ui';
|
||||||
import { isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency';
|
import { isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency';
|
||||||
import { createRandomHex } from '@/utils/randomId';
|
import { createRandomHex } from '@/utils/randomId';
|
||||||
|
|
||||||
@@ -47,12 +53,27 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
const [downstreamUplinkRetryEnabled, setDownstreamUplinkRetryEnabled] = useState(true);
|
const [downstreamUplinkRetryEnabled, setDownstreamUplinkRetryEnabled] = useState(true);
|
||||||
const [ipAddress, setIpAddress] = useState('');
|
const [ipAddress, setIpAddress] = useState('');
|
||||||
const [httpConfig, setHttpConfig] = useState<HttpApiConfig>({
|
const [httpConfig, setHttpConfig] = useState<HttpApiConfig>({
|
||||||
enabled: false, sendEnabled: true, messageQueryEnabled: true, receiptWebhookEnabled: true,
|
enabled: false,
|
||||||
uplinkWebhookEnabled: true, uplinkQueryEnabled: true, credentialSelfServiceEnabled: true,
|
sendEnabled: true,
|
||||||
qpsLimit: 10, timestampToleranceSeconds: 300, maxCredentialCount: 2, uplinkRetentionDays: 90,
|
messageQueryEnabled: true,
|
||||||
maxQueryRangeDays: 31, maxPageSize: 100, receiptDeliveryMode: 'http', uplinkDeliveryMode: 'http',
|
receiptWebhookEnabled: true,
|
||||||
webhookRetryEnabled: true, webhookMaxAttempts: 7, webhookTimeoutSeconds: 10, requireHttps: true,
|
uplinkWebhookEnabled: true,
|
||||||
allowClientManualRetry: true, allowClientTest: true,
|
uplinkQueryEnabled: true,
|
||||||
|
credentialSelfServiceEnabled: true,
|
||||||
|
qpsLimit: 10,
|
||||||
|
timestampToleranceSeconds: 300,
|
||||||
|
maxCredentialCount: 2,
|
||||||
|
uplinkRetentionDays: 90,
|
||||||
|
maxQueryRangeDays: 31,
|
||||||
|
maxPageSize: 100,
|
||||||
|
receiptDeliveryMode: 'http',
|
||||||
|
uplinkDeliveryMode: 'http',
|
||||||
|
webhookRetryEnabled: true,
|
||||||
|
webhookMaxAttempts: 7,
|
||||||
|
webhookTimeoutSeconds: 10,
|
||||||
|
requireHttps: true,
|
||||||
|
allowClientManualRetry: true,
|
||||||
|
allowClientTest: true,
|
||||||
});
|
});
|
||||||
const [httpIpAddress, setHttpIpAddress] = useState('');
|
const [httpIpAddress, setHttpIpAddress] = useState('');
|
||||||
const [receiptWebhookUrl, setReceiptWebhookUrl] = useState('');
|
const [receiptWebhookUrl, setReceiptWebhookUrl] = useState('');
|
||||||
@@ -62,6 +83,7 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
const [unicomGroupId, setUnicomGroupId] = useState('');
|
const [unicomGroupId, setUnicomGroupId] = useState('');
|
||||||
const [telecomGroupId, setTelecomGroupId] = useState('');
|
const [telecomGroupId, setTelecomGroupId] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [saveError, setSaveError] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -92,7 +114,9 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
}
|
}
|
||||||
const [groupItems, application, routeRules] = await Promise.all([
|
const [groupItems, application, routeRules] = await Promise.all([
|
||||||
adminApi.listChannelGroups(),
|
adminApi.listChannelGroups(),
|
||||||
isEdit && appId ? adminApi.getEnterpriseApplication(appId) : Promise.resolve<EnterpriseApplication | null>(null),
|
isEdit && appId
|
||||||
|
? adminApi.getEnterpriseApplication(appId)
|
||||||
|
: Promise.resolve<EnterpriseApplication | null>(null),
|
||||||
isEdit ? adminApi.listChannelRouteRules() : Promise.resolve<DictionaryItem[]>([]),
|
isEdit ? adminApi.listChannelRouteRules() : Promise.resolve<DictionaryItem[]>([]),
|
||||||
]);
|
]);
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
@@ -122,19 +146,20 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!appId) return;
|
if (!appId) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
Promise.all([
|
Promise.all([adminApi.getApplicationHttpApiConfig(appId), adminApi.listApplicationHttpWebhooks(appId)])
|
||||||
adminApi.getApplicationHttpApiConfig(appId),
|
.then(([result, webhooks]) => {
|
||||||
adminApi.listApplicationHttpWebhooks(appId),
|
|
||||||
]).then(([result, webhooks]) => {
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (result.config) setHttpConfig(result.config);
|
if (result.config) setHttpConfig(result.config);
|
||||||
setHttpIpAddress(result.ipAllowlist.join('\n'));
|
setHttpIpAddress(result.ipAllowlist.join('\n'));
|
||||||
setReceiptWebhookUrl(webhooks.find((item) => item.eventType === 'receipt')?.url ?? '');
|
setReceiptWebhookUrl(webhooks.find((item) => item.eventType === 'receipt')?.url ?? '');
|
||||||
setUplinkWebhookUrl(webhooks.find((item) => item.eventType === 'uplink')?.url ?? '');
|
setUplinkWebhookUrl(webhooks.find((item) => item.eventType === 'uplink')?.url ?? '');
|
||||||
}).catch((failure: Error) => {
|
})
|
||||||
|
.catch((failure: Error) => {
|
||||||
if (!cancelled) setError(failure.message || 'HTTP接口配置加载失败');
|
if (!cancelled) setError(failure.message || 'HTTP接口配置加载失败');
|
||||||
});
|
});
|
||||||
return () => { cancelled = true; };
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [appId]);
|
}, [appId]);
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
@@ -160,12 +185,9 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
setDownstreamUplinkRetryEnabled(application.downstreamUplinkRetryEnabled !== false);
|
setDownstreamUplinkRetryEnabled(application.downstreamUplinkRetryEnabled !== false);
|
||||||
setIpAddress(application.ipAllowlist?.map((item) => item.ipCidr).join('\n') ?? '');
|
setIpAddress(application.ipAllowlist?.map((item) => item.ipCidr).join('\n') ?? '');
|
||||||
|
|
||||||
const activeRules = routeRules.filter((rule) => (
|
const activeRules = routeRules.filter(
|
||||||
rule.applicationId === application.id
|
(rule) => rule.applicationId === application.id && rule.status !== 'deleted' && !rule.province && !rule.channelId,
|
||||||
&& rule.status !== 'deleted'
|
);
|
||||||
&& !rule.province
|
|
||||||
&& !rule.channelId
|
|
||||||
));
|
|
||||||
setMobileGroupId(getRouteGroupId(activeRules, 'mobile'));
|
setMobileGroupId(getRouteGroupId(activeRules, 'mobile'));
|
||||||
setUnicomGroupId(getRouteGroupId(activeRules, 'unicom'));
|
setUnicomGroupId(getRouteGroupId(activeRules, 'unicom'));
|
||||||
setTelecomGroupId(getRouteGroupId(activeRules, 'telecom'));
|
setTelecomGroupId(getRouteGroupId(activeRules, 'telecom'));
|
||||||
@@ -173,7 +195,7 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!enterpriseId) {
|
if (!enterpriseId) {
|
||||||
setError('缺少企业 ID');
|
setSaveError('缺少企业 ID');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const selectedGroups = [
|
const selectedGroups = [
|
||||||
@@ -182,29 +204,29 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
{ carrier: 'telecom' as Carrier, groupId: telecomGroupId },
|
{ carrier: 'telecom' as Carrier, groupId: telecomGroupId },
|
||||||
].filter((item) => item.groupId);
|
].filter((item) => item.groupId);
|
||||||
if (selectedGroups.length === 0) {
|
if (selectedGroups.length === 0) {
|
||||||
setError('请至少配置一个运营商通道组');
|
setSaveError('请至少配置一个运营商通道组');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isValidMoneyInput(customerUnitPrice)) {
|
if (!isValidMoneyInput(customerUnitPrice)) {
|
||||||
setError('客户单价必须是非负金额,且最多保留小数点后 4 位');
|
setSaveError('客户单价必须是非负金额,且最多保留小数点后 4 位');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const normalizedExtension = applicationExtension.trim();
|
const normalizedExtension = applicationExtension.trim();
|
||||||
const normalizedFillPrefix = accessNumberFillPrefix.trim();
|
const normalizedFillPrefix = accessNumberFillPrefix.trim();
|
||||||
if (normalizedExtension && !/^\d+$/.test(normalizedExtension)) {
|
if (normalizedExtension && !/^\d+$/.test(normalizedExtension)) {
|
||||||
setError('应用扩展码只能填写数字');
|
setSaveError('应用扩展码只能填写数字');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (accessNumberFillEnabled && !normalizedExtension) {
|
if (accessNumberFillEnabled && !normalizedExtension) {
|
||||||
setError('开启接入号填充时必须填写应用扩展码');
|
setSaveError('开启接入号填充时必须填写应用扩展码');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (accessNumberFillEnabled && !/^\d+$/.test(normalizedFillPrefix)) {
|
if (accessNumberFillEnabled && !/^\d+$/.test(normalizedFillPrefix)) {
|
||||||
setError('开启接入号填充时必须填写数字格式的填充前缀');
|
setSaveError('开启接入号填充时必须填写数字格式的填充前缀');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (`${accessNumberFillEnabled ? normalizedFillPrefix : ''}${normalizedExtension}`.length > 21) {
|
if (`${accessNumberFillEnabled ? normalizedFillPrefix : ''}${normalizedExtension}`.length > 21) {
|
||||||
setError('客户侧接入号不能超过 21 位');
|
setSaveError('客户侧接入号不能超过 21 位');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -228,9 +250,10 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError('');
|
setSaveError('');
|
||||||
try {
|
try {
|
||||||
const application = isEdit && appId
|
const application =
|
||||||
|
isEdit && appId
|
||||||
? await adminApi.updateEnterpriseApplication(appId, payload)
|
? await adminApi.updateEnterpriseApplication(appId, payload)
|
||||||
: await adminApi.createEnterpriseApplication({ tenantId: enterpriseId, ...payload });
|
: await adminApi.createEnterpriseApplication({ tenantId: enterpriseId, ...payload });
|
||||||
await adminApi.replaceApplicationRouteRules(application.id, {
|
await adminApi.replaceApplicationRouteRules(application.id, {
|
||||||
@@ -241,14 +264,17 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
status: 'active',
|
status: 'active',
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
await adminApi.updateApplicationHttpApiConfig(application.id, { ...httpConfig, ipAllowlist: parseIpAllowlist(httpIpAddress) });
|
await adminApi.updateApplicationHttpApiConfig(application.id, {
|
||||||
|
...httpConfig,
|
||||||
|
ipAllowlist: parseIpAllowlist(httpIpAddress),
|
||||||
|
});
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
adminApi.saveApplicationHttpWebhook(application.id, 'receipt', { url: receiptWebhookUrl.trim() }),
|
adminApi.saveApplicationHttpWebhook(application.id, 'receipt', { url: receiptWebhookUrl.trim() }),
|
||||||
adminApi.saveApplicationHttpWebhook(application.id, 'uplink', { url: uplinkWebhookUrl.trim() }),
|
adminApi.saveApplicationHttpWebhook(application.id, 'uplink', { url: uplinkWebhookUrl.trim() }),
|
||||||
]);
|
]);
|
||||||
goBack();
|
goBack();
|
||||||
} catch (failure) {
|
} catch (failure) {
|
||||||
setError(failure instanceof Error ? failure.message : '短信应用保存失败');
|
setSaveError(failure instanceof Error ? failure.message : '短信应用保存失败');
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -273,27 +299,65 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
<Breadcrumb items={[isEdit ? '编辑短信应用' : '添加短信应用']} />
|
<Breadcrumb items={[isEdit ? '编辑短信应用' : '添加短信应用']} />
|
||||||
<p>短信应用和三网通道组配置写入真实后台接口。</p>
|
<p>短信应用和三网通道组配置写入真实后台接口。</p>
|
||||||
</div>
|
</div>
|
||||||
<Button icon={<ArrowLeft size={16} />} onClick={goBack} variant="ghost">返回企业应用管理</Button>
|
<Button icon={<ArrowLeft size={16} />} onClick={goBack} variant="ghost">
|
||||||
|
返回企业应用管理
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
|
|
||||||
<div className="surface admin-app-form-card">
|
<div className="surface admin-app-form-card">
|
||||||
<section className="ui-detail-section">
|
<section className="ui-detail-section">
|
||||||
<div className="ui-detail-section__header"><h3>业务信息</h3><p>先填写应用基础信息,保存后将生成真实企业应用。</p></div>
|
<div className="ui-detail-section__header">
|
||||||
|
<h3>业务信息</h3>
|
||||||
|
<p>先填写应用基础信息,保存后将生成真实企业应用。</p>
|
||||||
|
</div>
|
||||||
<div className="admin-app-form-grid">
|
<div className="admin-app-form-grid">
|
||||||
<Input label="应用名称" onChange={(event) => setAppName(event.target.value)} placeholder="请输入应用名称" required value={appName} />
|
<Input
|
||||||
<Input label="应用场景" onChange={(event) => setScene(event.target.value)} placeholder="行业通知/营销推广/验证码" value={scene} />
|
label="应用名称"
|
||||||
<Input label="日发送数量限制" onChange={(event) => setDailyLimit(event.target.value)} placeholder="100000" required value={dailyLimit} />
|
onChange={(event) => setAppName(event.target.value)}
|
||||||
<Input label="客户单价(元/条)" onChange={(event) => setCustomerUnitPrice(event.target.value)} placeholder="0.0300" required step="0.0001" type="number" value={customerUnitPrice} />
|
placeholder="请输入应用名称"
|
||||||
|
required
|
||||||
|
value={appName}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="应用场景"
|
||||||
|
onChange={(event) => setScene(event.target.value)}
|
||||||
|
placeholder="行业通知/营销推广/验证码"
|
||||||
|
value={scene}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="日发送数量限制"
|
||||||
|
onChange={(event) => setDailyLimit(event.target.value)}
|
||||||
|
placeholder="100000"
|
||||||
|
required
|
||||||
|
value={dailyLimit}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="客户单价(元/条)"
|
||||||
|
onChange={(event) => setCustomerUnitPrice(event.target.value)}
|
||||||
|
placeholder="0.0300"
|
||||||
|
required
|
||||||
|
step="0.0001"
|
||||||
|
type="number"
|
||||||
|
value={customerUnitPrice}
|
||||||
|
/>
|
||||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||||
<span>发送队列</span>
|
<span>发送队列</span>
|
||||||
<div className="radio-row">
|
<div className="radio-row">
|
||||||
<label>
|
<label>
|
||||||
<input checked={queuePriority === 'priority'} onChange={() => setQueuePriority('priority')} type="radio" />
|
<input
|
||||||
|
checked={queuePriority === 'priority'}
|
||||||
|
onChange={() => setQueuePriority('priority')}
|
||||||
|
type="radio"
|
||||||
|
/>
|
||||||
优先队列(行业短信)
|
优先队列(行业短信)
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<input checked={queuePriority === 'normal'} onChange={() => setQueuePriority('normal')} type="radio" />
|
<input
|
||||||
|
checked={queuePriority === 'normal'}
|
||||||
|
onChange={() => setQueuePriority('normal')}
|
||||||
|
type="radio"
|
||||||
|
/>
|
||||||
普通队列(会员营销)
|
普通队列(会员营销)
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -319,10 +383,19 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
<section className="ui-detail-section admin-app-protocol-section admin-app-protocol-section--cmpp">
|
<section className="ui-detail-section admin-app-protocol-section admin-app-protocol-section--cmpp">
|
||||||
<div className="ui-detail-section__header admin-app-protocol-header">
|
<div className="ui-detail-section__header admin-app-protocol-header">
|
||||||
<div className="admin-app-protocol-heading">
|
<div className="admin-app-protocol-heading">
|
||||||
<span className="admin-app-protocol-icon"><RadioTower size={19} /></span>
|
<span className="admin-app-protocol-icon">
|
||||||
<div><h3>CMPP 接入配置</h3><p>管理客户端长连接、账号、接入号与下游回执投递。</p></div>
|
<RadioTower size={19} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h3>CMPP 接入配置</h3>
|
||||||
|
<p>管理客户端长连接、账号、接入号与下游回执投递。</p>
|
||||||
</div>
|
</div>
|
||||||
<button className={interfaceEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setInterfaceEnabled((current) => !current)} type="button">
|
</div>
|
||||||
|
<button
|
||||||
|
className={interfaceEnabled ? 'admin-switch is-on' : 'admin-switch'}
|
||||||
|
onClick={() => setInterfaceEnabled((current) => !current)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
<span />
|
<span />
|
||||||
{interfaceEnabled ? '已开通' : '未开通'}
|
{interfaceEnabled ? '已开通' : '未开通'}
|
||||||
</button>
|
</button>
|
||||||
@@ -331,10 +404,30 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
<div className="admin-app-form-grid admin-app-protocol-body">
|
<div className="admin-app-form-grid admin-app-protocol-body">
|
||||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||||
<span>CMPP 协议</span>
|
<span>CMPP 协议</span>
|
||||||
<div className="radio-row"><label><input checked={interfaceType === 'cmpp20'} onChange={() => setInterfaceType('cmpp20')} type="radio" />CMPP2.0</label></div>
|
<div className="radio-row">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
checked={interfaceType === 'cmpp20'}
|
||||||
|
onChange={() => setInterfaceType('cmpp20')}
|
||||||
|
type="radio"
|
||||||
|
/>
|
||||||
|
CMPP2.0
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<Input label="CMPP 6位账号" onChange={(event) => setCmppAccount(event.target.value)} placeholder="留空自动生成" value={cmppAccount} />
|
</div>
|
||||||
<Input disabled hint="企业代码始终与 CMPP 6位账号一致;账号留空自动生成时,保存后自动生成相同企业代码。" label="企业代码" placeholder="跟随 CMPP 6位账号自动生成" value={cmppAccount} />
|
<Input
|
||||||
|
label="CMPP 6位账号"
|
||||||
|
onChange={(event) => setCmppAccount(event.target.value)}
|
||||||
|
placeholder="留空自动生成"
|
||||||
|
value={cmppAccount}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
disabled
|
||||||
|
hint="企业代码始终与 CMPP 6位账号一致;账号留空自动生成时,保存后自动生成相同企业代码。"
|
||||||
|
label="企业代码"
|
||||||
|
placeholder="跟随 CMPP 6位账号自动生成"
|
||||||
|
value={cmppAccount}
|
||||||
|
/>
|
||||||
<Input
|
<Input
|
||||||
hint="真实扩展码会追加到上游通道基础接入号后,例如基础号 1069999999、扩展码 0001,最终发送号为 10699999990001。留空则继续使用通道基础号。"
|
hint="真实扩展码会追加到上游通道基础接入号后,例如基础号 1069999999、扩展码 0001,最终发送号为 10699999990001。留空则继续使用通道基础号。"
|
||||||
label="应用扩展码"
|
label="应用扩展码"
|
||||||
@@ -344,40 +437,121 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
/>
|
/>
|
||||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||||
<span>客户接入号填充</span>
|
<span>客户接入号填充</span>
|
||||||
<button className={accessNumberFillEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setAccessNumberFillEnabled((current) => !current)} type="button"><span />{accessNumberFillEnabled ? '开启' : '关闭'}</button>
|
<button
|
||||||
<div className="admin-app-form-tip"><Info size={17} /><span>填充前缀只用于满足客户系统的接入号长度限制;平台校验客户 Src_Id 时去掉开头前缀,上游发送时只拼接真实应用扩展码。</span></div>
|
className={accessNumberFillEnabled ? 'admin-switch is-on' : 'admin-switch'}
|
||||||
|
onClick={() => setAccessNumberFillEnabled((current) => !current)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
{accessNumberFillEnabled ? '开启' : '关闭'}
|
||||||
|
</button>
|
||||||
|
<div className="admin-app-form-tip">
|
||||||
|
<Info size={17} />
|
||||||
|
<span>
|
||||||
|
填充前缀只用于满足客户系统的接入号长度限制;平台校验客户 Src_Id
|
||||||
|
时去掉开头前缀,上游发送时只拼接真实应用扩展码。
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{accessNumberFillEnabled ? <Input hint="只能填写数字,且只允许作为客户 Src_Id 的开头前缀。" label="填充前缀" onChange={(event) => setAccessNumberFillPrefix(event.target.value)} placeholder="例如 00" required value={accessNumberFillPrefix} /> : null}
|
</div>
|
||||||
<Input disabled hint="客户 CMPP SUBMIT 必须填写该完整值;填充前缀不会发送给上游。" label="客户侧接入号" placeholder="根据填充前缀和应用扩展码自动生成" value={clientSrcIdPreview} />
|
{accessNumberFillEnabled ? (
|
||||||
|
<Input
|
||||||
|
hint="只能填写数字,且只允许作为客户 Src_Id 的开头前缀。"
|
||||||
|
label="填充前缀"
|
||||||
|
onChange={(event) => setAccessNumberFillPrefix(event.target.value)}
|
||||||
|
placeholder="例如 00"
|
||||||
|
required
|
||||||
|
value={accessNumberFillPrefix}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Input
|
||||||
|
disabled
|
||||||
|
hint="客户 CMPP SUBMIT 必须填写该完整值;填充前缀不会发送给上游。"
|
||||||
|
label="客户侧接入号"
|
||||||
|
placeholder="根据填充前缀和应用扩展码自动生成"
|
||||||
|
value={clientSrcIdPreview}
|
||||||
|
/>
|
||||||
<Input
|
<Input
|
||||||
hint={isEdit ? '留空则不修改接口密码;填写 16 位字符后覆盖。' : '默认随机生成,可按需修改。'}
|
hint={isEdit ? '留空则不修改接口密码;填写 16 位字符后覆盖。' : '默认随机生成,可按需修改。'}
|
||||||
label="CMPP 接口密码"
|
label="CMPP 接口密码"
|
||||||
onChange={(event) => setPasswordCipher(event.target.value)}
|
onChange={(event) => setPasswordCipher(event.target.value)}
|
||||||
placeholder="16 位接口密码"
|
placeholder="16 位接口密码"
|
||||||
suffix={<button aria-label="随机生成接口密码" className="icon-button" onClick={() => setPasswordCipher(generateApplicationPassword())} type="button"><RefreshCw size={15} /></button>}
|
suffix={
|
||||||
|
<button
|
||||||
|
aria-label="随机生成接口密码"
|
||||||
|
className="icon-button"
|
||||||
|
onClick={() => setPasswordCipher(generateApplicationPassword())}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<RefreshCw size={15} />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
value={passwordCipher}
|
value={passwordCipher}
|
||||||
/>
|
/>
|
||||||
<Input label="客户最大连接数" onChange={(event) => setCmppMaxConnections(event.target.value)} placeholder="1" required value={cmppMaxConnections} />
|
<Input
|
||||||
<Input label="CMPP IP 白名单" onChange={(event) => setIpAddress(event.target.value)} placeholder="多个 IP/CIDR 可用逗号、空格或换行分隔" value={ipAddress} />
|
label="客户最大连接数"
|
||||||
|
onChange={(event) => setCmppMaxConnections(event.target.value)}
|
||||||
|
placeholder="1"
|
||||||
|
required
|
||||||
|
value={cmppMaxConnections}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="CMPP IP 白名单"
|
||||||
|
onChange={(event) => setIpAddress(event.target.value)}
|
||||||
|
placeholder="多个 IP/CIDR 可用逗号、空格或换行分隔"
|
||||||
|
value={ipAddress}
|
||||||
|
/>
|
||||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||||
<span>CMPP 下游投递策略</span>
|
<span>CMPP 下游投递策略</span>
|
||||||
<div className="radio-row">
|
<div className="radio-row">
|
||||||
<button className={downstreamReceiptRetryEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setDownstreamReceiptRetryEnabled((current) => !current)} type="button"><span />回执自动重试:{downstreamReceiptRetryEnabled ? '开启' : '关闭'}</button>
|
<button
|
||||||
<button className={downstreamUplinkRetryEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setDownstreamUplinkRetryEnabled((current) => !current)} type="button"><span />上行自动重试:{downstreamUplinkRetryEnabled ? '开启' : '关闭'}</button>
|
className={downstreamReceiptRetryEnabled ? 'admin-switch is-on' : 'admin-switch'}
|
||||||
|
onClick={() => setDownstreamReceiptRetryEnabled((current) => !current)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
回执自动重试:{downstreamReceiptRetryEnabled ? '开启' : '关闭'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={downstreamUplinkRetryEnabled ? 'admin-switch is-on' : 'admin-switch'}
|
||||||
|
onClick={() => setDownstreamUplinkRetryEnabled((current) => !current)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
上行自动重试:{downstreamUplinkRetryEnabled ? '开启' : '关闭'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-app-form-tip"><Info size={17} /><span>首次投递始终保留;关闭后,已写出但未收到 CMPP_DELIVER_RESP 的消息不会自动重发,仍可在下游投递记录中手工重投。</span></div>
|
<div className="admin-app-form-tip">
|
||||||
|
<Info size={17} />
|
||||||
|
<span>
|
||||||
|
首次投递始终保留;关闭后,已写出但未收到 CMPP_DELIVER_RESP
|
||||||
|
的消息不会自动重发,仍可在下游投递记录中手工重投。
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : <div className="admin-app-protocol-empty">CMPP 接口未开通,账号、接入号和长连接参数已收起。</div>}
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="admin-app-protocol-empty">CMPP 接口未开通,账号、接入号和长连接参数已收起。</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="ui-detail-section admin-app-protocol-section admin-app-protocol-section--http">
|
<section className="ui-detail-section admin-app-protocol-section admin-app-protocol-section--http">
|
||||||
<div className="ui-detail-section__header admin-app-protocol-header">
|
<div className="ui-detail-section__header admin-app-protocol-header">
|
||||||
<div className="admin-app-protocol-heading">
|
<div className="admin-app-protocol-heading">
|
||||||
<span className="admin-app-protocol-icon"><Globe2 size={19} /></span>
|
<span className="admin-app-protocol-icon">
|
||||||
<div><h3>HTTP 接口配置</h3><p>管理接口能力、机器鉴权、查询限制与 Webhook 投递策略。</p></div>
|
<Globe2 size={19} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h3>HTTP 接口配置</h3>
|
||||||
|
<p>管理接口能力、机器鉴权、查询限制与 Webhook 投递策略。</p>
|
||||||
</div>
|
</div>
|
||||||
<button className={httpConfig.enabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setHttpConfig((current) => current.enabled ? { ...current, enabled: false } : {
|
</div>
|
||||||
|
<button
|
||||||
|
className={httpConfig.enabled ? 'admin-switch is-on' : 'admin-switch'}
|
||||||
|
onClick={() =>
|
||||||
|
setHttpConfig((current) =>
|
||||||
|
current.enabled
|
||||||
|
? { ...current, enabled: false }
|
||||||
|
: {
|
||||||
...current,
|
...current,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
sendEnabled: true,
|
sendEnabled: true,
|
||||||
@@ -388,30 +562,121 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
credentialSelfServiceEnabled: true,
|
credentialSelfServiceEnabled: true,
|
||||||
receiptDeliveryMode: 'http',
|
receiptDeliveryMode: 'http',
|
||||||
uplinkDeliveryMode: 'http',
|
uplinkDeliveryMode: 'http',
|
||||||
})} type="button"><span />{httpConfig.enabled ? '已开通' : '未开通'}</button>
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
{httpConfig.enabled ? '已开通' : '未开通'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{httpConfig.enabled ? (
|
{httpConfig.enabled ? (
|
||||||
<div className="admin-app-form-grid admin-app-protocol-body">
|
<div className="admin-app-form-grid admin-app-protocol-body">
|
||||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||||
<span>HTTP 能力</span>
|
<span>HTTP 能力</span>
|
||||||
<div className="radio-row">
|
<div className="radio-row">
|
||||||
{httpCapabilityOptions.map(({ key, label }) => <label key={key}><input checked={Boolean(httpConfig[key])} onChange={() => setHttpConfig((current) => ({ ...current, [key]: !current[key] }))} type="checkbox" />{label}</label>)}
|
{httpCapabilityOptions.map(({ key, label }) => (
|
||||||
|
<label key={key}>
|
||||||
|
<input
|
||||||
|
checked={Boolean(httpConfig[key])}
|
||||||
|
onChange={() => setHttpConfig((current) => ({ ...current, [key]: !current[key] }))}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-app-form-tip"><Info size={17} /><span>访问密钥由客户端“接口对接”页面按权限创建;HTTP 白名单与 CMPP 白名单完全独立。</span></div>
|
<div className="admin-app-form-tip">
|
||||||
|
<Info size={17} />
|
||||||
|
<span>访问密钥由客户端“接口对接”页面按权限创建;HTTP 白名单与 CMPP 白名单完全独立。</span>
|
||||||
</div>
|
</div>
|
||||||
<Input label="HTTP IP 白名单" onChange={(event) => setHttpIpAddress(event.target.value)} placeholder="多个 IP/CIDR 可换行填写,留空表示不限制" value={httpIpAddress} />
|
|
||||||
<Input label="HTTP QPS" onChange={(event) => setHttpConfig((current) => ({ ...current, qpsLimit: Number(event.target.value) || 1 }))} value={String(httpConfig.qpsLimit)} />
|
|
||||||
<Input label="签名时间容差(秒)" onChange={(event) => setHttpConfig((current) => ({ ...current, timestampToleranceSeconds: Number(event.target.value) || 300 }))} value={String(httpConfig.timestampToleranceSeconds)} />
|
|
||||||
<Input label="最多有效凭据数" onChange={(event) => setHttpConfig((current) => ({ ...current, maxCredentialCount: Number(event.target.value) || 2 }))} value={String(httpConfig.maxCredentialCount)} />
|
|
||||||
<Input label="Webhook 超时(秒)" onChange={(event) => setHttpConfig((current) => ({ ...current, webhookTimeoutSeconds: Number(event.target.value) || 10 }))} value={String(httpConfig.webhookTimeoutSeconds)} />
|
|
||||||
<Input label="Webhook 最大尝试次数" onChange={(event) => setHttpConfig((current) => ({ ...current, webhookMaxAttempts: Number(event.target.value) || 7 }))} value={String(httpConfig.webhookMaxAttempts)} />
|
|
||||||
<div className="admin-app-form-row admin-app-form-row--wide"><span>HTTP 安全与重试</span><div className="radio-row">
|
|
||||||
<label><input checked={httpConfig.requireHttps} onChange={() => setHttpConfig((current) => ({ ...current, requireHttps: !current.requireHttps }))} type="checkbox" />生产回调强制 HTTPS</label>
|
|
||||||
<label><input checked={httpConfig.webhookRetryEnabled} onChange={() => setHttpConfig((current) => ({ ...current, webhookRetryEnabled: !current.webhookRetryEnabled }))} type="checkbox" />Webhook 自动重试</label>
|
|
||||||
<label><input checked={httpConfig.allowClientManualRetry} onChange={() => setHttpConfig((current) => ({ ...current, allowClientManualRetry: !current.allowClientManualRetry }))} type="checkbox" />允许客户端手工重投</label>
|
|
||||||
</div></div>
|
|
||||||
</div>
|
</div>
|
||||||
) : <div className="admin-app-protocol-empty">HTTP 接口未开通,接口能力和鉴权参数已收起。</div>}
|
<Input
|
||||||
|
label="HTTP IP 白名单"
|
||||||
|
onChange={(event) => setHttpIpAddress(event.target.value)}
|
||||||
|
placeholder="多个 IP/CIDR 可换行填写,留空表示不限制"
|
||||||
|
value={httpIpAddress}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="HTTP QPS"
|
||||||
|
onChange={(event) =>
|
||||||
|
setHttpConfig((current) => ({ ...current, qpsLimit: Number(event.target.value) || 1 }))
|
||||||
|
}
|
||||||
|
value={String(httpConfig.qpsLimit)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="签名时间容差(秒)"
|
||||||
|
onChange={(event) =>
|
||||||
|
setHttpConfig((current) => ({
|
||||||
|
...current,
|
||||||
|
timestampToleranceSeconds: Number(event.target.value) || 300,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
value={String(httpConfig.timestampToleranceSeconds)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="最多有效凭据数"
|
||||||
|
onChange={(event) =>
|
||||||
|
setHttpConfig((current) => ({ ...current, maxCredentialCount: Number(event.target.value) || 2 }))
|
||||||
|
}
|
||||||
|
value={String(httpConfig.maxCredentialCount)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Webhook 超时(秒)"
|
||||||
|
onChange={(event) =>
|
||||||
|
setHttpConfig((current) => ({ ...current, webhookTimeoutSeconds: Number(event.target.value) || 10 }))
|
||||||
|
}
|
||||||
|
value={String(httpConfig.webhookTimeoutSeconds)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Webhook 最大尝试次数"
|
||||||
|
onChange={(event) =>
|
||||||
|
setHttpConfig((current) => ({ ...current, webhookMaxAttempts: Number(event.target.value) || 7 }))
|
||||||
|
}
|
||||||
|
value={String(httpConfig.webhookMaxAttempts)}
|
||||||
|
/>
|
||||||
|
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||||
|
<span>HTTP 安全与重试</span>
|
||||||
|
<div className="radio-row">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
checked={httpConfig.requireHttps}
|
||||||
|
onChange={() => setHttpConfig((current) => ({ ...current, requireHttps: !current.requireHttps }))}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
生产回调强制 HTTPS
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
checked={httpConfig.webhookRetryEnabled}
|
||||||
|
onChange={() =>
|
||||||
|
setHttpConfig((current) => ({ ...current, webhookRetryEnabled: !current.webhookRetryEnabled }))
|
||||||
|
}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
Webhook 自动重试
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
checked={httpConfig.allowClientManualRetry}
|
||||||
|
onChange={() =>
|
||||||
|
setHttpConfig((current) => ({
|
||||||
|
...current,
|
||||||
|
allowClientManualRetry: !current.allowClientManualRetry,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
允许客户端手工重投
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="admin-app-protocol-empty">HTTP 接口未开通,接口能力和鉴权参数已收起。</div>
|
||||||
|
)}
|
||||||
|
{httpConfig.enabled ? (
|
||||||
<div className="admin-app-form-grid admin-app-protocol-body">
|
<div className="admin-app-form-grid admin-app-protocol-body">
|
||||||
<Input
|
<Input
|
||||||
hint="留空不推送;HTTP接口开通后按该地址推送状态回执。"
|
hint="留空不推送;HTTP接口开通后按该地址推送状态回执。"
|
||||||
@@ -428,9 +693,15 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
value={uplinkWebhookUrl}
|
value={uplinkWebhookUrl}
|
||||||
/>
|
/>
|
||||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||||
<div className="admin-app-form-tip"><Info size={17} /><span>投递通道由接口开通状态自动决定:CMPP开通则走CMPP,HTTP开通且地址非空则走HTTP,两者都开通时双投;运营端无需另选投递方式。</span></div>
|
<div className="admin-app-form-tip">
|
||||||
|
<Info size={17} />
|
||||||
|
<span>
|
||||||
|
投递通道由接口开通状态自动决定:CMPP开通则走CMPP,HTTP开通且地址非空则走HTTP,两者都开通时双投;运营端无需另选投递方式。
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="ui-detail-section">
|
<section className="ui-detail-section">
|
||||||
@@ -446,14 +717,23 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
const available = groups.filter((group) => group.carrier === card.carrier);
|
const available = groups.filter((group) => group.carrier === card.carrier);
|
||||||
const meta = carrierMeta[card.carrier];
|
const meta = carrierMeta[card.carrier];
|
||||||
return (
|
return (
|
||||||
<div className={['admin-app-route-card', card.groupId ? 'is-selected' : ''].filter(Boolean).join(' ')} key={card.carrier}>
|
<div
|
||||||
|
className={['admin-app-route-card', card.groupId ? 'is-selected' : ''].filter(Boolean).join(' ')}
|
||||||
|
key={card.carrier}
|
||||||
|
>
|
||||||
<header>
|
<header>
|
||||||
<span><RadioTower size={18} /></span>
|
<span>
|
||||||
|
<RadioTower size={18} />
|
||||||
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<strong><CarrierTag carrier={card.carrier} /> 通道组</strong>
|
<strong>
|
||||||
|
<CarrierTag carrier={card.carrier} /> 通道组
|
||||||
|
</strong>
|
||||||
<small>{meta.description}</small>
|
<small>{meta.description}</small>
|
||||||
</div>
|
</div>
|
||||||
<Tag tone={card.groupId ? 'success' : available.length ? 'neutral' : 'warning'}>{card.groupId ? '已选择' : `${available.length} 个可选`}</Tag>
|
<Tag tone={card.groupId ? 'success' : available.length ? 'neutral' : 'warning'}>
|
||||||
|
{card.groupId ? '已选择' : `${available.length} 个可选`}
|
||||||
|
</Tag>
|
||||||
</header>
|
</header>
|
||||||
<Select
|
<Select
|
||||||
label={`${meta.label}通道组`}
|
label={`${meta.label}通道组`}
|
||||||
@@ -469,10 +749,27 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="enterprise-form-footer">
|
<div className="enterprise-form-footer">
|
||||||
<Button disabled={!appName || selectedGroupCount === 0 || saving} onClick={() => { void submit(); }}>{saving ? '保存中...' : isEdit ? '保存应用' : '创建应用'}</Button>
|
<Button
|
||||||
<Button onClick={goBack} variant="ghost">取消</Button>
|
disabled={!appName || selectedGroupCount === 0 || saving}
|
||||||
|
onClick={() => {
|
||||||
|
void submit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving ? '保存中...' : isEdit ? '保存应用' : '创建应用'}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={goBack} variant="ghost">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Modal
|
||||||
|
open={Boolean(saveError)}
|
||||||
|
title="短信应用保存失败"
|
||||||
|
onClose={() => setSaveError('')}
|
||||||
|
footer={<Button onClick={() => setSaveError('')}>关闭</Button>}
|
||||||
|
>
|
||||||
|
<p role="alert">{saveError}</p>
|
||||||
|
</Modal>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,8 +86,8 @@ export function ChannelFormModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
closeOnBackdrop={modal.mode === 'edit'}
|
closeOnBackdrop={false}
|
||||||
closeOnEscape={modal.mode === 'edit'}
|
closeOnEscape={false}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button onClick={onClose} variant="ghost">
|
<Button onClick={onClose} variant="ghost">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { EChartsOption } from 'echarts';
|
import type { EChartsOption } from 'echarts';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Button, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
import { Button, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||||
@@ -212,31 +212,37 @@ export function MonitorAlerts() {
|
|||||||
const [data, setData] = useState<Page<Alert>>({ items: [], total: 0, page: 1, pageSize: 20 }),
|
const [data, setData] = useState<Page<Alert>>({ items: [], total: 0, page: 1, pageSize: 20 }),
|
||||||
[page, setPage] = useState(1),
|
[page, setPage] = useState(1),
|
||||||
[state, setState] = useState(''),
|
[state, setState] = useState(''),
|
||||||
|
[readStatus, setReadStatus] = useState(''),
|
||||||
[error, setError] = useState('');
|
[error, setError] = useState('');
|
||||||
const [detail, setDetail] = useState<Alert | null>(null),
|
const [detail, setDetail] = useState<Alert | null>(null),
|
||||||
[busy, setBusy] = useState(false);
|
[busy, setBusy] = useState(false);
|
||||||
const load = useCallback(
|
const requestSequence = useRef(0);
|
||||||
() =>
|
const load = useCallback(async () => {
|
||||||
monitorApi
|
const sequence = ++requestSequence.current;
|
||||||
.alerts(page, state)
|
try {
|
||||||
.then((r) => {
|
const result = await monitorApi.alerts(page, state, readStatus);
|
||||||
setData(r);
|
if (sequence !== requestSequence.current) return;
|
||||||
|
setData(result);
|
||||||
setError('');
|
setError('');
|
||||||
})
|
} catch (failure) {
|
||||||
.catch((e) => setError(e.message)),
|
if (sequence === requestSequence.current) setError(failure instanceof Error ? failure.message : '告警加载失败');
|
||||||
[page, state],
|
}
|
||||||
);
|
}, [page, state, readStatus]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
if (!document.hidden) void load();
|
if (!document.hidden) void load();
|
||||||
}, 30000);
|
}, 30000);
|
||||||
return () => clearInterval(timer);
|
return () => {
|
||||||
|
clearInterval(timer);
|
||||||
|
requestSequence.current += 1;
|
||||||
|
};
|
||||||
}, [load]);
|
}, [load]);
|
||||||
async function read(row: Alert) {
|
async function read(row: Alert) {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await monitorApi.read(row.id);
|
await monitorApi.read(row.id);
|
||||||
|
setDetail((current) => (current?.id === row.id ? { ...current, unread: false } : current));
|
||||||
window.dispatchEvent(new Event('cmpp-monitor-alert-refresh'));
|
window.dispatchEvent(new Event('cmpp-monitor-alert-refresh'));
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -312,6 +318,19 @@ export function MonitorAlerts() {
|
|||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<div className="page-stack">
|
<div className="page-stack">
|
||||||
|
<Select
|
||||||
|
label="阅读状态"
|
||||||
|
value={readStatus}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: '全部' },
|
||||||
|
{ value: 'unread', label: '未读' },
|
||||||
|
{ value: 'read', label: '已读' },
|
||||||
|
]}
|
||||||
|
onChange={(event) => {
|
||||||
|
setReadStatus(event.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Select
|
<Select
|
||||||
label="告警状态"
|
label="告警状态"
|
||||||
value={state}
|
value={state}
|
||||||
|
|||||||
@@ -158,8 +158,8 @@ export const monitorApi = {
|
|||||||
request<{ id: string; name: string }[]>(
|
request<{ id: string; name: string }[]>(
|
||||||
withQuery('/admin/sending-monitor/options', { kind, ...scope, keyword, page }),
|
withQuery('/admin/sending-monitor/options', { kind, ...scope, keyword, page }),
|
||||||
),
|
),
|
||||||
alerts: (page: number, state: string, signal?: AbortSignal) =>
|
alerts: (page: number, state: string, readStatus = '', signal?: AbortSignal) =>
|
||||||
request<Page<Alert>>(withQuery('/admin/sending-monitor/alerts', { page, state }), { signal }),
|
request<Page<Alert>>(withQuery('/admin/sending-monitor/alerts', { page, state, readStatus }), { signal }),
|
||||||
read: (id: string) => request(`/admin/sending-monitor/alerts/${id}/read`, { method: 'POST' }),
|
read: (id: string) => request(`/admin/sending-monitor/alerts/${id}/read`, { method: 'POST' }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,7 @@ type SendDetailModalProps = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SendDetailModal({
|
export function SendDetailModal({ record, segmentAudits, segmentLoading, onClose }: SendDetailModalProps) {
|
||||||
record,
|
|
||||||
segmentAudits,
|
|
||||||
segmentLoading,
|
|
||||||
onClose,
|
|
||||||
}: SendDetailModalProps) {
|
|
||||||
const routeRows = buildRouteRows(record, segmentAudits);
|
const routeRows = buildRouteRows(record, segmentAudits);
|
||||||
const channelGroupNames = Array.from(new Set(routeRows.map((route) => route.channelGroup).filter(Boolean)));
|
const channelGroupNames = Array.from(new Set(routeRows.map((route) => route.channelGroup).filter(Boolean)));
|
||||||
const orderedSegmentAudits = [...segmentAudits].sort((left, right) => {
|
const orderedSegmentAudits = [...segmentAudits].sort((left, right) => {
|
||||||
@@ -36,11 +31,20 @@ export function SendDetailModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
footer={<Button onClick={onClose} variant="ghost">关闭</Button>}
|
footer={
|
||||||
|
<Button onClick={onClose} variant="ghost">
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
open
|
open
|
||||||
size="xl"
|
size="xl"
|
||||||
title={<div className="template-modal-title"><h2>发送详情</h2><p>{record.messageId}</p></div>}
|
title={
|
||||||
|
<div className="template-modal-title">
|
||||||
|
<h2>发送详情</h2>
|
||||||
|
<p>{record.messageId}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="admin-sms-send-detail">
|
<div className="admin-sms-send-detail">
|
||||||
<div className="admin-sms-detail-overview">
|
<div className="admin-sms-detail-overview">
|
||||||
@@ -48,16 +52,54 @@ export function SendDetailModal({
|
|||||||
<span>最终状态</span>
|
<span>最终状态</span>
|
||||||
<Tag tone={statusToneMap[displayStatus] ?? 'info'}>{getRecordStatusLabel(record)}</Tag>
|
<Tag tone={statusToneMap[displayStatus] ?? 'info'}>{getRecordStatusLabel(record)}</Tag>
|
||||||
</div>
|
</div>
|
||||||
<div><span>提交状态</span><strong>{record.submitStatus ?? '-'}</strong></div>
|
<div>
|
||||||
<div><span>回执状态</span><strong>{record.receiptStatus ?? '-'}</strong></div>
|
<span>提交状态</span>
|
||||||
<div><span>最终回执时间</span><strong>{getTime(record.deliveredAt)}</strong></div>
|
<strong>{record.submitStatus ?? '-'}</strong>
|
||||||
<div><span>引流信息</span><Tag tone={record.hasDrainageContent === true ? 'warning' : 'neutral'}>{record.hasDrainageContent === true ? '含引流' : record.hasDrainageContent === false ? '不含引流' : '未检测'}</Tag></div>
|
</div>
|
||||||
<div><span>提交时间</span><strong>{getTime(record.queuedAt)}</strong></div>
|
<div>
|
||||||
<div><span>发送号码</span><strong>{record.phoneNumber || '-'}</strong></div>
|
<span>回执状态</span>
|
||||||
<div><span>号码归属</span><strong>{record.province ?? '-'} / {record.carrier ? <CarrierTag carrier={record.carrier} /> : '-'}</strong></div>
|
<strong>{record.receiptStatus ?? '-'}</strong>
|
||||||
<div><span>通道组</span><strong>{channelGroupNames.join(' / ') || '-'}</strong></div>
|
</div>
|
||||||
<div><span>收到的接入号</span><strong>{record.clientSrcId || '-'}</strong></div>
|
<div>
|
||||||
<div><span>发送的接入号</span><strong>{sentAccessNumber || '-'}</strong></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>{record.phoneNumber || '-'}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>号码归属</span>
|
||||||
|
<strong>
|
||||||
|
{record.province ?? '-'} / {record.carrier ? <CarrierTag carrier={record.carrier} /> : '-'}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>通道组</span>
|
||||||
|
<strong>{channelGroupNames.join(' / ') || '-'}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>收到的接入号</span>
|
||||||
|
<strong>{record.clientSrcId || '-'}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>发送的接入号</span>
|
||||||
|
<strong>{sentAccessNumber || '-'}</strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{receiptNotice ? (
|
{receiptNotice ? (
|
||||||
<div className="admin-sms-detail-notice" role="status">
|
<div className="admin-sms-detail-notice" role="status">
|
||||||
@@ -66,8 +108,12 @@ export function SendDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<section>
|
<section>
|
||||||
<h3><MessageSquare size={18} /> 短信内容</h3>
|
<h3>
|
||||||
<p className="admin-sms-detail-content"><DrainageContent record={record} /></p>
|
<MessageSquare size={18} /> 短信内容
|
||||||
|
</h3>
|
||||||
|
<p className="admin-sms-detail-content">
|
||||||
|
<DrainageContent record={record} />
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
@@ -78,12 +124,23 @@ export function SendDetailModal({
|
|||||||
<span>{index + 1}</span>
|
<span>{index + 1}</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>{route.channel}</strong>
|
<strong>{route.channel}</strong>
|
||||||
<p className="muted">通道组:{route.channelGroup ?? '-'}</p>
|
|
||||||
<dl>
|
<dl>
|
||||||
<div><dt>发送时间</dt><dd>{getTime(route.sentAt)}</dd></div>
|
<div>
|
||||||
<div><dt>回执时间</dt><dd>{getTime(route.receiptAt)}</dd></div>
|
<dt>发送时间</dt>
|
||||||
<div><dt>回执码</dt><dd>{route.receiptCode ?? '-'}</dd></div>
|
<dd>{getTime(route.sentAt)}</dd>
|
||||||
<div><dt>提交状态</dt><dd>{route.submitStatus ?? '-'}</dd></div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>回执时间</dt>
|
||||||
|
<dd>{getTime(route.receiptAt)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>回执码</dt>
|
||||||
|
<dd>{route.receiptCode ?? '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>提交状态</dt>
|
||||||
|
<dd>{route.submitStatus ?? '-'}</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -94,42 +151,104 @@ export function SendDetailModal({
|
|||||||
<section>
|
<section>
|
||||||
<h3>状态信息</h3>
|
<h3>状态信息</h3>
|
||||||
<div className="admin-sms-detail-status-grid">
|
<div className="admin-sms-detail-status-grid">
|
||||||
<div><span>消息编号</span><strong>{record.messageId}</strong></div>
|
<div>
|
||||||
<div><span>发送状态</span><strong>{getRecordStatusLabel(record)}</strong></div>
|
<span>消息编号</span>
|
||||||
<div><span>提交状态</span><strong>{record.submitStatus ?? '-'}</strong></div>
|
<strong>{record.messageId}</strong>
|
||||||
<div><span>回执状态</span><strong>{record.receiptStatus ?? '-'}</strong></div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>发送状态</span>
|
||||||
|
<strong>{getRecordStatusLabel(record)}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>提交状态</span>
|
||||||
|
<strong>{record.submitStatus ?? '-'}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>回执状态</span>
|
||||||
|
<strong>{record.receiptStatus ?? '-'}</strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{['submit_failed', 'failed', 'rejected'].includes(displayStatus) ? (
|
{['submit_failed', 'failed', 'rejected'].includes(displayStatus) ? (
|
||||||
<div className="admin-sms-detail-failure" role="alert">
|
<div className="admin-sms-detail-failure" role="alert">
|
||||||
<AlertTriangle size={20} />
|
<AlertTriangle size={20} />
|
||||||
<div><span>失败原因</span><strong>{record.errorMessage ?? record.errorCode ?? '未返回明确失败原因'}</strong></div>
|
<div>
|
||||||
|
<span>失败原因</span>
|
||||||
|
<strong>{record.errorMessage ?? record.errorCode ?? '未返回明确失败原因'}</strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3>分片补偿审计</h3>
|
<h3>分片补偿审计</h3>
|
||||||
{segmentLoading ? <div className="ui-table__empty">加载中...</div> : segmentAudits.length === 0 ? (
|
{segmentLoading ? (
|
||||||
|
<div className="ui-table__empty">加载中...</div>
|
||||||
|
) : segmentAudits.length === 0 ? (
|
||||||
<div className="ui-table__empty">暂无分片审计</div>
|
<div className="ui-table__empty">暂无分片审计</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="admin-sms-segment-list">
|
<div className="admin-sms-segment-list">
|
||||||
{orderedSegmentAudits.map((segment) => (
|
{orderedSegmentAudits.map((segment) => (
|
||||||
<article className="admin-sms-segment-card" key={segment.id}>
|
<article className="admin-sms-segment-card" key={segment.id}>
|
||||||
<header>
|
<header>
|
||||||
<strong>分片 {segment.segmentIndex}/{segment.segmentTotal}</strong>
|
<strong>
|
||||||
|
分片 {segment.segmentIndex}/{segment.segmentTotal}
|
||||||
|
</strong>
|
||||||
<div>
|
<div>
|
||||||
<Tag tone={segment.submitStatus === 'accepted' ? 'success' : segment.submitStatus === 'queued' ? 'info' : 'danger'}>{segment.submitStatus}</Tag>
|
<Tag
|
||||||
{segment.receiptStatus ? <Tag tone={segment.receiptStatus === 'delivered' ? 'success' : segment.receiptStatus === 'unknown' ? 'neutral' : 'danger'}>{segment.receiptStatus}</Tag> : null}
|
tone={
|
||||||
|
segment.submitStatus === 'accepted'
|
||||||
|
? 'success'
|
||||||
|
: segment.submitStatus === 'queued'
|
||||||
|
? 'info'
|
||||||
|
: 'danger'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{segment.submitStatus}
|
||||||
|
</Tag>
|
||||||
|
{segment.receiptStatus ? (
|
||||||
|
<Tag
|
||||||
|
tone={
|
||||||
|
segment.receiptStatus === 'delivered'
|
||||||
|
? 'success'
|
||||||
|
: segment.receiptStatus === 'unknown'
|
||||||
|
? 'neutral'
|
||||||
|
: 'danger'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{segment.receiptStatus}
|
||||||
|
</Tag>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<dl>
|
<dl>
|
||||||
<div><dt>通道</dt><dd>{segment.channel?.name ?? segment.channelId ?? '-'}</dd></div>
|
<div>
|
||||||
<div><dt>Sequence</dt><dd>{segment.sequenceId ?? '-'}</dd></div>
|
<dt>通道</dt>
|
||||||
<div><dt>提交 ID</dt><dd>{segment.submitId}</dd></div>
|
<dd>{segment.channel?.name ?? segment.channelId ?? '-'}</dd>
|
||||||
<div><dt>网关 MsgId</dt><dd>{segment.gatewayMessageId ?? '-'}</dd></div>
|
</div>
|
||||||
<div><dt>补偿方式</dt><dd>{segment.compensationType ?? '-'}</dd></div>
|
<div>
|
||||||
<div><dt>审计时间</dt><dd>{getTime(segment.createdAt)}</dd></div>
|
<dt>Sequence</dt>
|
||||||
<div><dt>错误信息</dt><dd>{segment.errorMessage ?? segment.errorCode ?? '-'}</dd></div>
|
<dd>{segment.sequenceId ?? '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>提交 ID</dt>
|
||||||
|
<dd>{segment.submitId}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>网关 MsgId</dt>
|
||||||
|
<dd>{segment.gatewayMessageId ?? '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>补偿方式</dt>
|
||||||
|
<dd>{segment.compensationType ?? '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>审计时间</dt>
|
||||||
|
<dd>{getTime(segment.createdAt)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>错误信息</dt>
|
||||||
|
<dd>{segment.errorMessage ?? segment.errorCode ?? '-'}</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
import { Breadcrumb, Button, Input, Modal, Table, Tag, type TableColumn } from '@/components/ui';
|
import { Breadcrumb, Button, Input, Modal, Table, Tag, type TableColumn } from '@/components/ui';
|
||||||
import { Chart } from '@/components/ui/Chart';
|
import { Chart } from '@/components/ui/Chart';
|
||||||
import './AdminSystemMonitoringPage.css';
|
import './AdminSystemMonitoringPage.css';
|
||||||
|
import { AlertHistory } from './AlertHistory';
|
||||||
|
|
||||||
const RANGE_OPTIONS: Array<{ value: InfrastructureMonitoringRange; label: string }> = [
|
const RANGE_OPTIONS: Array<{ value: InfrastructureMonitoringRange; label: string }> = [
|
||||||
{ value: '1h', label: '近1小时' },
|
{ value: '1h', label: '近1小时' },
|
||||||
@@ -62,23 +63,52 @@ function formatRate(value: number | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DiskMetricCards({ disks }: { disks: InfrastructureMonitoringOverview['disks'] }) {
|
export function DiskMetricCards({ disks }: { disks: InfrastructureMonitoringOverview['disks'] }) {
|
||||||
if (!disks.length) return <article className="surface system-monitoring-metric"><HardDrive size={19} /><div><span>磁盘</span><strong>暂无数据</strong></div></article>;
|
if (!disks.length)
|
||||||
return <>{disks.map((disk) => {
|
return (
|
||||||
const aliases = (disk.mountpoints ?? [disk.mountpoint]).filter((path) => path !== disk.mountpoint);
|
<article className="surface system-monitoring-metric">
|
||||||
return <article className="surface system-monitoring-metric" key={disk.id}>
|
<HardDrive size={19} />
|
||||||
<div className="system-monitoring-metric__icon is-amber"><HardDrive size={19} /></div>
|
|
||||||
<div>
|
<div>
|
||||||
<span>{disk.mountpoint === '/' ? '系统盘' : '磁盘'} {disk.mountpoint}</span>
|
<span>磁盘</span>
|
||||||
<strong>{formatPercent(disk.usagePercent)}</strong>
|
<strong>暂无数据</strong>
|
||||||
<small title={`${disk.device} · ${disk.filesystem} · ${disk.instance}`}>{disk.device} · {disk.filesystem}</small>
|
|
||||||
<small>{formatBytes(disk.availableBytes)} 可用 / {formatBytes(disk.totalBytes)}</small>
|
|
||||||
{aliases.length > 0 ? <details className="system-monitoring-metric__mounts">
|
|
||||||
<summary>其他挂载点({aliases.length})</summary>
|
|
||||||
<ul>{aliases.map((path) => <li key={path}>{path}</li>)}</ul>
|
|
||||||
</details> : null}
|
|
||||||
</div>
|
</div>
|
||||||
</article>;
|
</article>
|
||||||
})}</>;
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{disks.map((disk) => {
|
||||||
|
const aliases = (disk.mountpoints ?? [disk.mountpoint]).filter((path) => path !== disk.mountpoint);
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
{aliases.length > 0 ? (
|
||||||
|
<details className="system-monitoring-metric__mounts">
|
||||||
|
<summary>其他挂载点({aliases.length})</summary>
|
||||||
|
<ul>
|
||||||
|
{aliases.map((path) => (
|
||||||
|
<li key={path}>{path}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function totalNetworkRate(receive: number | null | undefined, transmit: number | null | undefined) {
|
function totalNetworkRate(receive: number | null | undefined, transmit: number | null | undefined) {
|
||||||
@@ -105,7 +135,12 @@ function formatServiceMetric(value: number | null, unit: 'percent' | 'seconds' |
|
|||||||
function formatTime(value: string | null) {
|
function formatTime(value: string | null) {
|
||||||
if (!value) return '暂无采样';
|
if (!value) return '暂无采样';
|
||||||
return new Intl.DateTimeFormat('zh-CN', {
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
}).format(new Date(value));
|
}).format(new Date(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,10 +154,14 @@ function formatDuration(startedAt: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function timeLabels(points: InfrastructureMetricPoint[], range: InfrastructureMonitoringRange) {
|
function timeLabels(points: InfrastructureMetricPoint[], range: InfrastructureMonitoringRange) {
|
||||||
return points.map((point) => new Intl.DateTimeFormat('zh-CN', range === '7d'
|
return points.map((point) =>
|
||||||
|
new Intl.DateTimeFormat(
|
||||||
|
'zh-CN',
|
||||||
|
range === '7d'
|
||||||
? { month: '2-digit', day: '2-digit', hour: '2-digit', hour12: false }
|
? { month: '2-digit', day: '2-digit', hour: '2-digit', hour12: false }
|
||||||
: { hour: '2-digit', minute: '2-digit', hour12: false })
|
: { hour: '2-digit', minute: '2-digit', hour12: false },
|
||||||
.format(new Date(point.timestamp)));
|
).format(new Date(point.timestamp)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeTrendOption(params: {
|
function makeTrendOption(params: {
|
||||||
@@ -131,12 +170,17 @@ function makeTrendOption(params: {
|
|||||||
suffix: string;
|
suffix: string;
|
||||||
maximum?: number;
|
maximum?: number;
|
||||||
}): EChartsOption {
|
}): EChartsOption {
|
||||||
const timestamps = [...new Set(params.series.flatMap((series) => series.points.map((point) => point.timestamp)))].sort();
|
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 ? { type: 'scroll', top: 0, left: 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}`,
|
||||||
@@ -144,13 +188,18 @@ function makeTrendOption(params: {
|
|||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
boundaryGap: false,
|
boundaryGap: false,
|
||||||
data: timeLabels(timestamps.map((timestamp) => ({ timestamp, value: 0 })), 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 },
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: 'value', min: 0, max: params.maximum,
|
type: 'value',
|
||||||
|
min: 0,
|
||||||
|
max: params.maximum,
|
||||||
axisLabel: { color: '#9ca3af', formatter: `{value}${params.suffix}` },
|
axisLabel: { color: '#9ca3af', formatter: `{value}${params.suffix}` },
|
||||||
splitLine: { lineStyle: { color: '#eef0f3' } },
|
splitLine: { lineStyle: { color: '#eef0f3' } },
|
||||||
},
|
},
|
||||||
@@ -175,23 +224,58 @@ function severityTag(severity: InfrastructureAlert['severity']) {
|
|||||||
return <Tag tone="info">提示</Tag>;
|
return <Tag tone="info">提示</Tag>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeAlertColumns(onMarkRead: (alert: InfrastructureAlert) => void, readingFingerprint: string): Array<TableColumn<InfrastructureAlert>> { return [
|
function makeAlertColumns(
|
||||||
|
onMarkRead: (alert: InfrastructureAlert) => void,
|
||||||
|
readingFingerprint: string,
|
||||||
|
): Array<TableColumn<InfrastructureAlert>> {
|
||||||
|
return [
|
||||||
{ key: 'severity', title: '级别', width: '82px', render: (record) => severityTag(record.severity) },
|
{ key: 'severity', title: '级别', width: '82px', render: (record) => severityTag(record.severity) },
|
||||||
{
|
{
|
||||||
key: 'alert', title: '告警', width: '280px', render: (record) => (
|
key: 'alert',
|
||||||
<div className="system-monitoring-alert-copy"><strong>{record.name}</strong><span>{record.summary}</span></div>
|
title: '告警',
|
||||||
|
width: '280px',
|
||||||
|
render: (record) => (
|
||||||
|
<div className="system-monitoring-alert-copy">
|
||||||
|
<strong>{record.name}</strong>
|
||||||
|
<span>{record.summary}</span>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ key: 'service', title: '服务 / 实例', width: '190px', render: (record) => record.service || record.instance || '主机资源' },
|
{
|
||||||
{ key: 'value', title: '当前值 / 阈值', width: '150px', render: (record) => `${record.currentValue || '—'} / ${record.threshold || '—'}` },
|
key: 'service',
|
||||||
|
title: '服务 / 实例',
|
||||||
|
width: '190px',
|
||||||
|
render: (record) => record.service || record.instance || '主机资源',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'value',
|
||||||
|
title: '当前值 / 阈值',
|
||||||
|
width: '150px',
|
||||||
|
render: (record) => `${record.currentValue || '—'} / ${record.threshold || '—'}`,
|
||||||
|
},
|
||||||
{ key: 'startedAt', title: '开始时间', width: '150px', render: (record) => formatTime(record.startedAt) },
|
{ key: 'startedAt', title: '开始时间', width: '150px', render: (record) => formatTime(record.startedAt) },
|
||||||
{ key: 'duration', title: '持续时间', width: '120px', render: (record) => formatDuration(record.startedAt) },
|
{ key: 'duration', title: '持续时间', width: '120px', render: (record) => formatDuration(record.startedAt) },
|
||||||
{
|
{
|
||||||
key: 'actions', title: '操作', width: '112px', render: (record) => record.acknowledged
|
key: 'actions',
|
||||||
? <Tag tone="neutral">已读</Tag>
|
title: '操作',
|
||||||
: <Button disabled={readingFingerprint === record.fingerprint} icon={<CheckCircle2 size={14} />} onClick={() => onMarkRead(record)} size="sm" variant="ghost">{readingFingerprint === record.fingerprint ? '处理中' : '标记已读'}</Button>,
|
width: '112px',
|
||||||
|
render: (record) =>
|
||||||
|
record.acknowledged ? (
|
||||||
|
<Tag tone="neutral">已读</Tag>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
disabled={readingFingerprint === record.fingerprint}
|
||||||
|
icon={<CheckCircle2 size={14} />}
|
||||||
|
onClick={() => onMarkRead(record)}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
{readingFingerprint === record.fingerprint ? '处理中' : '标记已读'}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
]; }
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export function AdminSystemMonitoringPage() {
|
export function AdminSystemMonitoringPage() {
|
||||||
const [range, setRange] = useState<InfrastructureMonitoringRange>('24h');
|
const [range, setRange] = useState<InfrastructureMonitoringRange>('24h');
|
||||||
@@ -208,7 +292,8 @@ export function AdminSystemMonitoringPage() {
|
|||||||
const requestSequence = useRef(0);
|
const requestSequence = useRef(0);
|
||||||
const pendingRequests = useRef(0);
|
const pendingRequests = useRef(0);
|
||||||
|
|
||||||
const loadData = useCallback(async (supersede = false) => {
|
const loadData = useCallback(
|
||||||
|
async (supersede = false) => {
|
||||||
if (!supersede && pendingRequests.current > 0) return;
|
if (!supersede && pendingRequests.current > 0) return;
|
||||||
pendingRequests.current += 1;
|
pendingRequests.current += 1;
|
||||||
const sequence = ++requestSequence.current;
|
const sequence = ++requestSequence.current;
|
||||||
@@ -226,7 +311,9 @@ export function AdminSystemMonitoringPage() {
|
|||||||
if (sequence === requestSequence.current) setLoading(false);
|
if (sequence === requestSequence.current) setLoading(false);
|
||||||
pendingRequests.current -= 1;
|
pendingRequests.current -= 1;
|
||||||
}
|
}
|
||||||
}, [range]);
|
},
|
||||||
|
[range],
|
||||||
|
);
|
||||||
|
|
||||||
const loadSettings = useCallback(async () => {
|
const loadSettings = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -244,7 +331,10 @@ export function AdminSystemMonitoringPage() {
|
|||||||
setSavingSettings(true);
|
setSavingSettings(true);
|
||||||
setSettingsError('');
|
setSettingsError('');
|
||||||
try {
|
try {
|
||||||
const result = await adminApi.updateInfrastructureAlertThresholds({ configVersion: settings.configVersion, thresholds: draftThresholds });
|
const result = await adminApi.updateInfrastructureAlertThresholds({
|
||||||
|
configVersion: settings.configVersion,
|
||||||
|
thresholds: draftThresholds,
|
||||||
|
});
|
||||||
setSettings(result);
|
setSettings(result);
|
||||||
setDraftThresholds(result.thresholds);
|
setDraftThresholds(result.thresholds);
|
||||||
setShowSettings(false);
|
setShowSettings(false);
|
||||||
@@ -262,12 +352,18 @@ export function AdminSystemMonitoringPage() {
|
|||||||
setReadError('');
|
setReadError('');
|
||||||
try {
|
try {
|
||||||
const result = await adminApi.markInfrastructureAlertRead(alert.fingerprint, alert.startedAt);
|
const result = await adminApi.markInfrastructureAlertRead(alert.fingerprint, alert.startedAt);
|
||||||
setOverview((current) => current ? {
|
setOverview((current) =>
|
||||||
|
current
|
||||||
|
? {
|
||||||
...current,
|
...current,
|
||||||
alerts: current.alerts.map((item) => item.fingerprint === result.fingerprint && Date.parse(item.startedAt) === Date.parse(result.activeAt)
|
alerts: current.alerts.map((item) =>
|
||||||
|
item.fingerprint === result.fingerprint && Date.parse(item.startedAt) === Date.parse(result.activeAt)
|
||||||
? { ...item, acknowledged: true, acknowledgedAt: result.acknowledgedAt }
|
? { ...item, acknowledged: true, acknowledgedAt: result.acknowledgedAt }
|
||||||
: item),
|
: item,
|
||||||
} : current);
|
),
|
||||||
|
}
|
||||||
|
: current,
|
||||||
|
);
|
||||||
window.dispatchEvent(new Event('cmpp-infrastructure-alert-count-refresh'));
|
window.dispatchEvent(new Event('cmpp-infrastructure-alert-count-refresh'));
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setReadError(reason instanceof Error ? reason.message : '活动告警标记已读失败');
|
setReadError(reason instanceof Error ? reason.message : '活动告警标记已读失败');
|
||||||
@@ -294,30 +390,63 @@ export function AdminSystemMonitoringPage() {
|
|||||||
}, [loadData, loadSettings]);
|
}, [loadData, loadSettings]);
|
||||||
|
|
||||||
const status = STATUS_COPY[overview?.summary.overallStatus ?? 'unknown'];
|
const status = STATUS_COPY[overview?.summary.overallStatus ?? 'unknown'];
|
||||||
const cpuOption = useMemo(() => makeTrendOption({
|
const cpuOption = useMemo(
|
||||||
range, maximum: 100, suffix: '%', series: [{ name: 'CPU', points: overview?.trends.cpuUsagePercent ?? [], color: '#2563eb' }],
|
() =>
|
||||||
}), [overview?.trends.cpuUsagePercent, range]);
|
makeTrendOption({
|
||||||
const memoryOption = useMemo(() => makeTrendOption({
|
range,
|
||||||
range, maximum: 100, suffix: '%', series: [{ name: '内存', points: overview?.trends.memoryUsagePercent ?? [], color: '#7c3aed' }],
|
maximum: 100,
|
||||||
}), [overview?.trends.memoryUsagePercent, range]);
|
suffix: '%',
|
||||||
const diskOption = useMemo(() => makeTrendOption({
|
series: [{ name: 'CPU', points: overview?.trends.cpuUsagePercent ?? [], color: '#2563eb' }],
|
||||||
range, maximum: 100, suffix: '%', series: (overview?.disks ?? []).map((disk, index) => ({
|
}),
|
||||||
|
[overview?.trends.cpuUsagePercent, range],
|
||||||
|
);
|
||||||
|
const memoryOption = useMemo(
|
||||||
|
() =>
|
||||||
|
makeTrendOption({
|
||||||
|
range,
|
||||||
|
maximum: 100,
|
||||||
|
suffix: '%',
|
||||||
|
series: [{ name: '内存', points: overview?.trends.memoryUsagePercent ?? [], color: '#7c3aed' }],
|
||||||
|
}),
|
||||||
|
[overview?.trends.memoryUsagePercent, range],
|
||||||
|
);
|
||||||
|
const diskOption = useMemo(
|
||||||
|
() =>
|
||||||
|
makeTrendOption({
|
||||||
|
range,
|
||||||
|
maximum: 100,
|
||||||
|
suffix: '%',
|
||||||
|
series: (overview?.disks ?? []).map((disk, index) => ({
|
||||||
name: `${disk.mountpoint} · ${disk.device} · ${disk.instance}`,
|
name: `${disk.mountpoint} · ${disk.device} · ${disk.instance}`,
|
||||||
points: disk.trend,
|
points: disk.trend,
|
||||||
color: ['#d97706', '#2563eb', '#0f766e', '#7c3aed', '#dc2626', '#0891b2'][index % 6],
|
color: ['#d97706', '#2563eb', '#0f766e', '#7c3aed', '#dc2626', '#0891b2'][index % 6],
|
||||||
})),
|
})),
|
||||||
}), [overview?.disks, range]);
|
}),
|
||||||
const networkOption = useMemo(() => makeTrendOption({
|
[overview?.disks, range],
|
||||||
range, suffix: ' B/s', series: [
|
);
|
||||||
|
const networkOption = useMemo(
|
||||||
|
() =>
|
||||||
|
makeTrendOption({
|
||||||
|
range,
|
||||||
|
suffix: ' B/s',
|
||||||
|
series: [
|
||||||
{ name: '接收', points: overview?.trends.networkReceiveBytesPerSecond ?? [], color: '#0f766e' },
|
{ name: '接收', points: overview?.trends.networkReceiveBytesPerSecond ?? [], color: '#0f766e' },
|
||||||
{ name: '发送', points: overview?.trends.networkTransmitBytesPerSecond ?? [], color: '#2563eb' },
|
{ name: '发送', points: overview?.trends.networkTransmitBytesPerSecond ?? [], color: '#2563eb' },
|
||||||
],
|
],
|
||||||
}), [overview?.trends.networkReceiveBytesPerSecond, overview?.trends.networkTransmitBytesPerSecond, range]);
|
}),
|
||||||
|
[overview?.trends.networkReceiveBytesPerSecond, overview?.trends.networkTransmitBytesPerSecond, range],
|
||||||
|
);
|
||||||
|
|
||||||
const metrics = overview?.metrics;
|
const metrics = overview?.metrics;
|
||||||
const serviceHealthy = overview?.summary.serviceHealthy ?? 0;
|
const serviceHealthy = overview?.summary.serviceHealthy ?? 0;
|
||||||
const serviceTotal = overview?.summary.serviceTotal ?? 6;
|
const serviceTotal = overview?.summary.serviceTotal ?? 6;
|
||||||
const alertColumns = useMemo(() => makeAlertColumns((alert) => { void markAlertRead(alert); }, readingFingerprint), [markAlertRead, readingFingerprint]);
|
const alertColumns = useMemo(
|
||||||
|
() =>
|
||||||
|
makeAlertColumns((alert) => {
|
||||||
|
void markAlertRead(alert);
|
||||||
|
}, readingFingerprint),
|
||||||
|
[markAlertRead, readingFingerprint],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page-stack admin-system-monitoring-page">
|
<section className="page-stack admin-system-monitoring-page">
|
||||||
@@ -338,10 +467,17 @@ export function AdminSystemMonitoringPage() {
|
|||||||
key={option.value}
|
key={option.value}
|
||||||
onClick={() => setRange(option.value)}
|
onClick={() => setRange(option.value)}
|
||||||
type="button"
|
type="button"
|
||||||
>{option.label}</button>
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Button disabled={loading} icon={<RefreshCw className={loading ? 'is-spinning' : ''} size={16} />} onClick={() => void loadData()} variant="ghost">
|
<Button
|
||||||
|
disabled={loading}
|
||||||
|
icon={<RefreshCw className={loading ? 'is-spinning' : ''} size={16} />}
|
||||||
|
onClick={() => void loadData()}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
{loading ? '刷新中' : '刷新'}
|
{loading ? '刷新中' : '刷新'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -350,7 +486,10 @@ export function AdminSystemMonitoringPage() {
|
|||||||
{error ? (
|
{error ? (
|
||||||
<div className="system-monitoring-unavailable" role="alert">
|
<div className="system-monitoring-unavailable" role="alert">
|
||||||
<ShieldAlert size={20} />
|
<ShieldAlert size={20} />
|
||||||
<div><strong>监控数据不可用</strong><span>{error}。页面不会展示历史缓存值。</span></div>
|
<div>
|
||||||
|
<strong>监控数据不可用</strong>
|
||||||
|
<span>{error}。页面不会展示历史缓存值。</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -363,78 +502,321 @@ export function AdminSystemMonitoringPage() {
|
|||||||
<strong>{status.label}</strong>
|
<strong>{status.label}</strong>
|
||||||
<small>最新采样 {formatTime(overview?.lastSampleAt ?? null)}</small>
|
<small>最新采样 {formatTime(overview?.lastSampleAt ?? null)}</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="system-monitoring-health__fact"><span>核心服务</span><strong>{serviceHealthy}/{serviceTotal}</strong><small>正常运行</small></div>
|
<div className="system-monitoring-health__fact">
|
||||||
<div className="system-monitoring-health__fact"><span>活动告警</span><strong>{overview?.summary.activeAlerts ?? 0}</strong><small>{overview?.summary.criticalAlerts ?? 0} 严重 · {overview?.summary.warningAlerts ?? 0} 警告</small></div>
|
<span>核心服务</span>
|
||||||
<div className="system-monitoring-health__fact"><span>系统负载</span><strong>{metrics?.load1 === null || metrics?.load1 === undefined ? '—' : metrics.load1.toFixed(2)}</strong><small>最近1分钟</small></div>
|
<strong>
|
||||||
<div className="system-monitoring-health__fact"><span>持续运行</span><strong>{formatUptime(metrics?.uptimeSeconds ?? null)}</strong><small>主机启动后</small></div>
|
{serviceHealthy}/{serviceTotal}
|
||||||
|
</strong>
|
||||||
|
<small>正常运行</small>
|
||||||
|
</div>
|
||||||
|
<div className="system-monitoring-health__fact">
|
||||||
|
<span>活动告警</span>
|
||||||
|
<strong>{overview?.summary.activeAlerts ?? 0}</strong>
|
||||||
|
<small>
|
||||||
|
{overview?.summary.criticalAlerts ?? 0} 严重 · {overview?.summary.warningAlerts ?? 0} 警告
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div className="system-monitoring-health__fact">
|
||||||
|
<span>系统负载</span>
|
||||||
|
<strong>{metrics?.load1 === null || metrics?.load1 === undefined ? '—' : metrics.load1.toFixed(2)}</strong>
|
||||||
|
<small>最近1分钟</small>
|
||||||
|
</div>
|
||||||
|
<div className="system-monitoring-health__fact">
|
||||||
|
<span>持续运行</span>
|
||||||
|
<strong>{formatUptime(metrics?.uptimeSeconds ?? null)}</strong>
|
||||||
|
<small>主机启动后</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">
|
||||||
<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>
|
<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>
|
||||||
<DiskMetricCards disks={overview?.disks ?? []} />
|
<DiskMetricCards disks={overview?.disks ?? []} />
|
||||||
<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>
|
||||||
|
|
||||||
<div className="system-monitoring-main-grid">
|
<div className="system-monitoring-main-grid">
|
||||||
<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">
|
||||||
<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>
|
<header>
|
||||||
<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>
|
<div>
|
||||||
<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>
|
<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>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside className="surface system-monitoring-services">
|
<aside className="surface system-monitoring-services">
|
||||||
<header><div><Server size={18} /><strong>核心服务</strong></div><Tag tone={serviceHealthy === serviceTotal && overview?.available ? 'success' : 'neutral'}>{serviceHealthy}/{serviceTotal} 正常</Tag></header>
|
<header>
|
||||||
|
<div>
|
||||||
|
<Server size={18} />
|
||||||
|
<strong>核心服务</strong>
|
||||||
|
</div>
|
||||||
|
<Tag tone={serviceHealthy === serviceTotal && overview?.available ? 'success' : 'neutral'}>
|
||||||
|
{serviceHealthy}/{serviceTotal} 正常
|
||||||
|
</Tag>
|
||||||
|
</header>
|
||||||
<div className="system-monitoring-service-list">
|
<div className="system-monitoring-service-list">
|
||||||
{(overview?.services ?? []).map((service) => (
|
{(overview?.services ?? []).map((service) => (
|
||||||
<div className="system-monitoring-service" key={service.key}>
|
<div className="system-monitoring-service" key={service.key}>
|
||||||
<span className={`system-monitoring-service__dot is-${service.status}`} />
|
<span className={`system-monitoring-service__dot is-${service.status}`} />
|
||||||
<div><strong>{service.name}</strong><small>{service.unit}</small></div>
|
<div>
|
||||||
|
<strong>{service.name}</strong>
|
||||||
|
<small>{service.unit}</small>
|
||||||
|
</div>
|
||||||
<span>{service.status === 'healthy' ? '正常' : service.status === 'unhealthy' ? '异常' : '未知'}</span>
|
<span>{service.status === 'healthy' ? '正常' : service.status === 'unhealthy' ? '异常' : '未知'}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!overview?.services.length ? [
|
{!overview?.services.length
|
||||||
['api', 'API服务'], ['gateway', 'Gateway服务'], ['postgresql', 'PostgreSQL'], ['redis', 'Redis'], ['minio', 'MinIO'], ['nginx', 'Nginx'],
|
? [
|
||||||
].map(([key, name]) => <div className="system-monitoring-service" key={key}><span className="system-monitoring-service__dot is-unknown" /><div><strong>{name}</strong><small>等待真实采集</small></div><span>未知</span></div>) : null}
|
['api', 'API服务'],
|
||||||
|
['gateway', 'Gateway服务'],
|
||||||
|
['postgresql', 'PostgreSQL'],
|
||||||
|
['redis', 'Redis'],
|
||||||
|
['minio', 'MinIO'],
|
||||||
|
['nginx', 'Nginx'],
|
||||||
|
].map(([key, name]) => (
|
||||||
|
<div className="system-monitoring-service" key={key}>
|
||||||
|
<span className="system-monitoring-service__dot is-unknown" />
|
||||||
|
<div>
|
||||||
|
<strong>{name}</strong>
|
||||||
|
<small>等待真实采集</small>
|
||||||
|
</div>
|
||||||
|
<span>未知</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
<div className="system-monitoring-collector-note">
|
||||||
|
<Database size={16} />
|
||||||
|
<span>指标由 Prometheus 采集,业务数据库不写入高频时序数据。</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="system-monitoring-collector-note"><Database size={16} /><span>指标由 Prometheus 采集,业务数据库不写入高频时序数据。</span></div>
|
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="surface system-monitoring-service-metrics">
|
<section className="surface system-monitoring-service-metrics">
|
||||||
<header>
|
<header>
|
||||||
<div><Database size={18} /><strong>服务关键指标</strong></div>
|
<div>
|
||||||
<div className="system-monitoring-service-actions"><span>固定低基数聚合,不含手机号、短信ID或SQL文本</span><Button icon={<Settings2 size={15} />} onClick={() => setShowSettings(true)} variant="ghost">告警阈值设置</Button></div>
|
<Database size={18} />
|
||||||
|
<strong>服务关键指标</strong>
|
||||||
|
</div>
|
||||||
|
<div className="system-monitoring-service-actions">
|
||||||
|
<span>固定低基数聚合,不含手机号、短信ID或SQL文本</span>
|
||||||
|
<Button icon={<Settings2 size={15} />} onClick={() => setShowSettings(true)} variant="ghost">
|
||||||
|
告警阈值设置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="system-monitoring-service-metric-grid">
|
<div className="system-monitoring-service-metric-grid">
|
||||||
{(overview?.serviceMetrics ?? []).map((group) => (
|
{(overview?.serviceMetrics ?? []).map((group) => (
|
||||||
<article key={group.key}>
|
<article key={group.key}>
|
||||||
<div className="system-monitoring-service-metric-title"><strong>{group.name}</strong><Tag tone={group.available ? 'success' : 'neutral'}>{group.available ? '已采集' : '待采集'}</Tag></div>
|
<div className="system-monitoring-service-metric-title">
|
||||||
{group.metrics.length ? group.metrics.map((metric) => <div className="system-monitoring-service-metric-row" key={metric.key}><span>{metric.label}</span><strong>{formatServiceMetric(metric.value, metric.unit)}</strong></div>) : <div className="system-monitoring-service-metric-empty">已监控服务可用性,待原生容量指标接入</div>}
|
<strong>{group.name}</strong>
|
||||||
|
<Tag tone={group.available ? 'success' : 'neutral'}>{group.available ? '已采集' : '待采集'}</Tag>
|
||||||
|
</div>
|
||||||
|
{group.metrics.length ? (
|
||||||
|
group.metrics.map((metric) => (
|
||||||
|
<div className="system-monitoring-service-metric-row" key={metric.key}>
|
||||||
|
<span>{metric.label}</span>
|
||||||
|
<strong>{formatServiceMetric(metric.value, metric.unit)}</strong>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="system-monitoring-service-metric-empty">已监控服务可用性,待原生容量指标接入</div>
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<AlertHistory />
|
||||||
<section className="surface system-monitoring-alerts" id="active-alerts">
|
<section className="surface system-monitoring-alerts" id="active-alerts">
|
||||||
<header><div><AlertTriangle size={18} /><strong>活动告警</strong><Tag tone={overview?.summary.activeAlerts ? 'warning' : 'success'}>{overview?.summary.activeAlerts ?? 0}</Tag></div><span><Clock3 size={14} /> 刷新于 {formatTime(overview?.collectedAt ?? null)}</span></header>
|
<header>
|
||||||
{readError ? <div className="system-monitoring-unavailable" role="alert"><AlertTriangle size={18} /><div><strong>标记已读失败</strong><span>{readError}</span></div></div> : null}
|
<div>
|
||||||
<Table columns={alertColumns} data={overview?.alerts ?? []} emptyText={overview?.available ? '当前没有活动告警' : '监控不可用,无法读取活动告警'} pagination={false} rowKey="fingerprint" />
|
<AlertTriangle size={18} />
|
||||||
|
<strong>活动告警</strong>
|
||||||
|
<Tag tone={overview?.summary.activeAlerts ? 'warning' : 'success'}>
|
||||||
|
{overview?.summary.activeAlerts ?? 0}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
<span>
|
||||||
|
<Clock3 size={14} /> 刷新于 {formatTime(overview?.collectedAt ?? null)}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
{readError ? (
|
||||||
|
<div className="system-monitoring-unavailable" role="alert">
|
||||||
|
<AlertTriangle size={18} />
|
||||||
|
<div>
|
||||||
|
<strong>标记已读失败</strong>
|
||||||
|
<span>{readError}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Table
|
||||||
|
columns={alertColumns}
|
||||||
|
data={overview?.alerts ?? []}
|
||||||
|
emptyText={overview?.available ? '当前没有活动告警' : '监控不可用,无法读取活动告警'}
|
||||||
|
pagination={false}
|
||||||
|
rowKey="fingerprint"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Modal footer={<><Button onClick={() => setShowSettings(false)} variant="ghost">取消</Button><Button disabled={savingSettings || !settings} onClick={() => void saveSettings()}>{savingSettings ? '验证并应用中' : '保存并应用'}</Button></>} onClose={() => setShowSettings(false)} open={showSettings} title="Prometheus 告警阈值设置">
|
<Modal
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button onClick={() => setShowSettings(false)} variant="ghost">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button disabled={savingSettings || !settings} onClick={() => void saveSettings()}>
|
||||||
|
{savingSettings ? '验证并应用中' : '保存并应用'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
onClose={() => setShowSettings(false)}
|
||||||
|
open={showSettings}
|
||||||
|
title="Prometheus 告警阈值设置"
|
||||||
|
>
|
||||||
<div className="system-monitoring-threshold-dialog">
|
<div className="system-monitoring-threshold-dialog">
|
||||||
<div className="system-monitoring-threshold-note"><ShieldAlert size={17} /><span>仅允许修改固定指标的数值阈值。保存时先由 promtool 校验,再原子替换规则并热加载 Prometheus。</span></div>
|
<div className="system-monitoring-threshold-note">
|
||||||
|
<ShieldAlert size={17} />
|
||||||
|
<span>仅允许修改固定指标的数值阈值。保存时先由 promtool 校验,再原子替换规则并热加载 Prometheus。</span>
|
||||||
|
</div>
|
||||||
{settings?.definitions.map((definition) => (
|
{settings?.definitions.map((definition) => (
|
||||||
<div className="system-monitoring-threshold-row" key={definition.key}>
|
<div className="system-monitoring-threshold-row" key={definition.key}>
|
||||||
<div><strong>{definition.label}</strong><small>单位:{definition.unit}</small></div>
|
<div>
|
||||||
<Input label="警告阈值" max={definition.max} min={definition.min} onChange={(event) => setDraftThresholds((current) => ({ ...current, [definition.key]: { ...current[definition.key], warning: Number(event.target.value) } }))} step={definition.step} type="number" value={draftThresholds[definition.key]?.warning ?? ''} />
|
<strong>{definition.label}</strong>
|
||||||
<Input label="严重阈值" max={definition.max} min={definition.min} onChange={(event) => setDraftThresholds((current) => ({ ...current, [definition.key]: { ...current[definition.key], critical: Number(event.target.value) } }))} step={definition.step} type="number" value={draftThresholds[definition.key]?.critical ?? ''} />
|
<small>单位:{definition.unit}</small>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="警告阈值"
|
||||||
|
max={definition.max}
|
||||||
|
min={definition.min}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraftThresholds((current) => ({
|
||||||
|
...current,
|
||||||
|
[definition.key]: { ...current[definition.key], warning: Number(event.target.value) },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
step={definition.step}
|
||||||
|
type="number"
|
||||||
|
value={draftThresholds[definition.key]?.warning ?? ''}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="严重阈值"
|
||||||
|
max={definition.max}
|
||||||
|
min={definition.min}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraftThresholds((current) => ({
|
||||||
|
...current,
|
||||||
|
[definition.key]: { ...current[definition.key], critical: Number(event.target.value) },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
step={definition.step}
|
||||||
|
type="number"
|
||||||
|
value={draftThresholds[definition.key]?.critical ?? ''}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{settings?.applyStatus === 'failed' ? <div className="system-monitoring-unavailable"><AlertTriangle size={18} /><div><strong>上次应用失败</strong><span>{settings.lastError}</span></div></div> : null}
|
{settings?.applyStatus === 'failed' ? (
|
||||||
{settingsError ? <div className="system-monitoring-unavailable"><AlertTriangle size={18} /><div><strong>阈值配置不可用</strong><span>{settingsError}</span></div></div> : null}
|
<div className="system-monitoring-unavailable">
|
||||||
|
<AlertTriangle size={18} />
|
||||||
|
<div>
|
||||||
|
<strong>上次应用失败</strong>
|
||||||
|
<span>{settings.lastError}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{settingsError ? (
|
||||||
|
<div className="system-monitoring-unavailable">
|
||||||
|
<AlertTriangle size={18} />
|
||||||
|
<div>
|
||||||
|
<strong>阈值配置不可用</strong>
|
||||||
|
<span>{settingsError}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</section>
|
</section>
|
||||||
@@ -442,5 +824,10 @@ export function AdminSystemMonitoringPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function EmptyChart() {
|
function EmptyChart() {
|
||||||
return <div className="system-monitoring-chart-empty"><Activity size={22} /><span>暂无真实趋势指标</span></div>;
|
return (
|
||||||
|
<div className="system-monitoring-chart-empty">
|
||||||
|
<Activity size={22} />
|
||||||
|
<span>暂无真实趋势指标</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { AlertHistory } from './AlertHistory';
|
||||||
|
const { get } = vi.hoisted(() => ({ get: vi.fn() }));
|
||||||
|
vi.mock('@/api/adminApi', () => ({ adminApi: { getInfrastructureAlertHistory: get } }));
|
||||||
|
|
||||||
|
describe('alert history', () => {
|
||||||
|
it('defaults to seven calendar days and sends the selected page; failure is not an empty success', async () => {
|
||||||
|
get.mockResolvedValue({ items: [], total: 30, page: 1, pageSize: 25 });
|
||||||
|
render(<AlertHistory />);
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: '下一页' })).toBeEnabled());
|
||||||
|
const [from, to, page] = get.mock.calls[0];
|
||||||
|
expect((Date.parse(to) - Date.parse(from)) / 86400_000).toBe(6);
|
||||||
|
expect(page).toBe(1);
|
||||||
|
get.mockRejectedValue(new Error('监控不可用'));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||||
|
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('监控不可用'));
|
||||||
|
expect(get).toHaveBeenLastCalledWith(from, to, 2);
|
||||||
|
expect(screen.getByText('历史告警不可用')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { adminApi } from '@/api/adminApi';
|
||||||
|
import { Button, DateRangeInput, Table, type TableColumn } from '@/components/ui';
|
||||||
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
|
|
||||||
|
type History = Awaited<ReturnType<typeof adminApi.getInfrastructureAlertHistory>>;
|
||||||
|
const columns: TableColumn<History['items'][number]>[] = [
|
||||||
|
{ key: 'name', title: '告警', width: '280px', render: (row) => row.name },
|
||||||
|
{
|
||||||
|
key: 'severity',
|
||||||
|
title: '级别',
|
||||||
|
width: '90px',
|
||||||
|
render: (row) => ({ critical: '严重', warning: '警告', info: '提示' })[row.severity] || row.severity,
|
||||||
|
},
|
||||||
|
{ key: 'instance', title: '服务 / 实例', width: '220px', render: (row) => row.service || row.instance || '主机资源' },
|
||||||
|
{ key: 'startedAt', title: '触发时间', width: '180px', render: (row) => formatDateTime(row.startedAt) },
|
||||||
|
{
|
||||||
|
key: 'lastObservedAt',
|
||||||
|
title: '范围内最后采样',
|
||||||
|
width: '180px',
|
||||||
|
render: (row) => formatDateTime(row.lastObservedAt),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AlertHistory() {
|
||||||
|
const [dates, setDates] = useState(() => {
|
||||||
|
const key = (time: number) => new Date(time + 8 * 3600_000).toISOString().slice(0, 10);
|
||||||
|
return { start: key(Date.now() - 6 * 86400_000), end: key(Date.now()) };
|
||||||
|
});
|
||||||
|
const [query, setQuery] = useState({ ...dates, page: 1, revision: 0 });
|
||||||
|
const [response, setResponse] = useState<{ query: typeof query; data: History | null; error: string }>();
|
||||||
|
const loading = response?.query !== query;
|
||||||
|
const data = loading ? null : response?.data;
|
||||||
|
const error = loading ? '' : response?.error;
|
||||||
|
useEffect(() => {
|
||||||
|
let current = true;
|
||||||
|
adminApi
|
||||||
|
.getInfrastructureAlertHistory(query.start, query.end, query.page)
|
||||||
|
.then((result) => {
|
||||||
|
if (current) setResponse({ query, data: result, error: '' });
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
if (current)
|
||||||
|
setResponse({ query, data: null, error: reason instanceof Error ? reason.message : '历史告警加载失败' });
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
current = false;
|
||||||
|
};
|
||||||
|
}, [query]);
|
||||||
|
return (
|
||||||
|
<section className="surface system-monitoring-alerts" aria-label="历史告警记录">
|
||||||
|
<header>
|
||||||
|
<strong>历史告警记录</strong>
|
||||||
|
</header>
|
||||||
|
<div className="filter-bar">
|
||||||
|
<DateRangeInput
|
||||||
|
label="告警日期"
|
||||||
|
value={dates}
|
||||||
|
onChange={(value) => setDates({ start: value.start || '', end: value.end || '' })}
|
||||||
|
/>
|
||||||
|
<Button disabled={loading} onClick={() => setQuery({ ...dates, page: 1, revision: query.revision + 1 })}>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="muted">
|
||||||
|
默认近7天,最多31天;读取 Prometheus
|
||||||
|
保留的真实触发周期(含等待触发),最后采样不代表准确恢复时间。保留期外或采集缺失的历史无法补齐。
|
||||||
|
</p>
|
||||||
|
{error ? (
|
||||||
|
<p role="alert" className="form-error">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
data={loading ? [] : (data?.items ?? [])}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
emptyText={loading ? '历史告警加载中…' : error ? '历史告警不可用' : '所选日期没有保留的告警记录'}
|
||||||
|
/>
|
||||||
|
<div className="table-footer">
|
||||||
|
<span>
|
||||||
|
共 {data?.total ?? 0} 条 · 第 {data?.page ?? query.page} 页
|
||||||
|
</span>
|
||||||
|
<Button disabled={loading || query.page <= 1} onClick={() => setQuery({ ...query, page: query.page - 1 })}>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={loading || !data || query.page * data.pageSize >= data.total}
|
||||||
|
onClick={() => setQuery({ ...query, page: query.page + 1 })}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ describe('Modal close policy', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
|
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
|
||||||
expect(close).toHaveBeenCalledOnce();
|
expect(close).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
it('preserves mask closing by default for existing consumers', () => {
|
it('ignores backdrop, panel and Escape by default, including clean forms', () => {
|
||||||
const close = vi.fn();
|
const close = vi.fn();
|
||||||
render(
|
render(
|
||||||
<Modal open title="普通弹窗" onClose={close}>
|
<Modal open title="普通弹窗" onClose={close}>
|
||||||
@@ -24,6 +24,32 @@ describe('Modal close policy', () => {
|
|||||||
</Modal>,
|
</Modal>,
|
||||||
);
|
);
|
||||||
fireEvent.mouseDown(document.querySelector('.ui-modal__mask')!);
|
fireEvent.mouseDown(document.querySelector('.ui-modal__mask')!);
|
||||||
|
fireEvent.mouseDown(screen.getByRole('dialog'));
|
||||||
|
fireEvent.keyDown(document, { key: 'Escape' });
|
||||||
|
expect(close).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
|
||||||
|
expect(close).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the dirty guard for explicit close and allows canceling it', () => {
|
||||||
|
const close = vi.fn();
|
||||||
|
render(
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
dirty
|
||||||
|
title="编辑"
|
||||||
|
onClose={close}
|
||||||
|
footer={({ requestClose }) => <button onClick={requestClose}>取消</button>}
|
||||||
|
>
|
||||||
|
未保存内容
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '取消' }));
|
||||||
|
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }));
|
||||||
|
expect(close).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '放弃并关闭' }));
|
||||||
expect(close).toHaveBeenCalledOnce();
|
expect(close).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -97,8 +97,8 @@ export function Modal({
|
|||||||
size = 'md',
|
size = 'md',
|
||||||
onClose,
|
onClose,
|
||||||
dirty = false,
|
dirty = false,
|
||||||
closeOnBackdrop = true,
|
closeOnBackdrop = false,
|
||||||
closeOnEscape = true,
|
closeOnEscape = false,
|
||||||
initialFocusRef,
|
initialFocusRef,
|
||||||
closeGuardTitle = '放弃未保存的修改?',
|
closeGuardTitle = '放弃未保存的修改?',
|
||||||
closeGuardDescription = '当前内容尚未保存。放弃后无法恢复,请确认是否关闭。',
|
closeGuardDescription = '当前内容尚未保存。放弃后无法恢复,请确认是否关闭。',
|
||||||
@@ -139,9 +139,10 @@ export function Modal({
|
|||||||
lockDocument(layer);
|
lockDocument(layer);
|
||||||
|
|
||||||
const focusTarget = initialFocusRef?.current ?? focusableElements(panel)[0] ?? panel;
|
const focusTarget = initialFocusRef?.current ?? focusableElements(panel)[0] ?? panel;
|
||||||
requestAnimationFrame(() => focusTarget.focus());
|
const focusFrame = requestAnimationFrame(() => focusTarget.focus());
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
cancelAnimationFrame(focusFrame);
|
||||||
const stackIndex = modalStack.lastIndexOf(panel);
|
const stackIndex = modalStack.lastIndexOf(panel);
|
||||||
if (stackIndex >= 0) modalStack.splice(stackIndex, 1);
|
if (stackIndex >= 0) modalStack.splice(stackIndex, 1);
|
||||||
unlockDocument();
|
unlockDocument();
|
||||||
@@ -197,8 +198,12 @@ export function Modal({
|
|||||||
if (!showCloseGuard) return;
|
if (!showCloseGuard) return;
|
||||||
const panel = panelRef.current;
|
const panel = panelRef.current;
|
||||||
if (panel) panel.inert = true;
|
if (panel) panel.inert = true;
|
||||||
requestAnimationFrame(() => focusableElements(guardRef.current ?? panelRef.current!)[0]?.focus());
|
const focusFrame = requestAnimationFrame(() => {
|
||||||
|
const root = guardRef.current ?? panelRef.current;
|
||||||
|
if (root) focusableElements(root)[0]?.focus();
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
|
cancelAnimationFrame(focusFrame);
|
||||||
if (panel) panel.inert = false;
|
if (panel) panel.inert = false;
|
||||||
const restoreTarget = guardRestoreFocusRef.current;
|
const restoreTarget = guardRestoreFocusRef.current;
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user