This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new af4c6a59e feat(consumer): add Consumer Group health diagnostics (#2881)
af4c6a59e is described below
commit af4c6a59e2eee8f57ac33edac9bf89be7c941c23
Author: coder999o <[email protected]>
AuthorDate: Wed Sep 2 16:29:12 2026 +0800
feat(consumer): add Consumer Group health diagnostics (#2881)
---
.../pages/instance/__tests__/ConsumerPage.test.tsx | 67 ++++
web/src/pages/instance/consumer.tsx | 223 ++++++++++-
web/src/utils/consumerGroupDiagnostics.test.ts | 157 ++++++++
web/src/utils/consumerGroupDiagnostics.ts | 423 +++++++++++++++++++++
4 files changed, 866 insertions(+), 4 deletions(-)
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index 12186cc05..761ea2858 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -482,6 +482,73 @@ describe('Consumer page', () => {
await waitFor(() =>
expect(screen.getAllByText('remote-topic').length).toBeGreaterThan(0));
});
+ it('shows group health diagnostics from subscriptions, progress and
clients', async () => {
+ const riskyGroup: ConsumerGroup = {
+ ...group,
+ totalLag: 1_200,
+ delaySeconds: 720,
+ onlineInstances: 2,
+ instances: [
+ {
+ clientId: '[email protected]',
+ protocol: 'GRPC',
+ address: '10.0.0.1:49152',
+ subscribedTopics: ['remote-topic'],
+ lastHeartbeat: '2026-07-23T00:00:00Z',
+ topicLag: { 'remote-topic': 10 },
+ },
+ {
+ clientId: '[email protected]',
+ protocol: 'REMOTING',
+ address: '10.0.0.2:49152',
+ subscribedTopics: [],
+ lastHeartbeat: '2026-07-23T00:00:00Z',
+ topicLag: {},
+ },
+ ],
+ };
+
vi.mocked(consumerService.listConsumerGroupPage).mockResolvedValue(groupPage([riskyGroup]));
+ vi.mocked(consumerService.getConsumerSubscriptions).mockResolvedValue([
+ {
+ topic: 'remote-topic',
+ expression: 'tagA',
+ type: 'NORMAL',
+ filterMode: 'Tag 过滤',
+ consistency: '不一致',
+ },
+ ]);
+ vi.mocked(consumerService.getConsumerProgress).mockResolvedValue([
+ {
+ topic: 'remote-topic',
+ broker: 'broker-a',
+ queueId: 0,
+ brokerOffset: 100,
+ consumerOffset: 90,
+ diffTotal: 10,
+ },
+ {
+ topic: 'remote-topic',
+ broker: 'broker-b',
+ queueId: 1,
+ brokerOffset: 1_200,
+ consumerOffset: 100,
+ diffTotal: 1_100,
+ },
+ ]);
+ const user = userEvent.setup();
+ renderWithProviders(<ConsumerPage />);
+
+ await user.click(await screen.findByRole('button', { name: /详情/ }));
+ await user.click(await screen.findByRole('tab', { name: /健康诊断/ }));
+ const panel = await screen.findByRole('tabpanel', { name: /健康诊断/ });
+
+ await waitFor(() =>
expect(within(panel).getAllByText('消费风险').length).toBeGreaterThan(0));
+ expect(within(panel).getByText('订阅表达式不一致')).toBeInTheDocument();
+ expect(within(panel).getByText('Queue 堆积分布严重倾斜')).toBeInTheDocument();
+ expect(within(panel).getAllByText('客户端心跳过期').length).toBeGreaterThan(0);
+ expect(within(panel).getByText('处理建议')).toBeInTheDocument();
+ });
+
it('filters queue progress to the topic of the clicked distribution button',
async () => {
vi.mocked(consumerService.getConsumerSubscriptions).mockResolvedValue([
{
diff --git a/web/src/pages/instance/consumer.tsx
b/web/src/pages/instance/consumer.tsx
index e286603ce..7ddf7da24 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -41,6 +41,7 @@ import {
DatePicker,
Tooltip,
Spin,
+ Progress,
message,
} from 'antd';
import {
@@ -103,6 +104,11 @@ import {
import { downloadCsv } from '../../utils/download';
import { formatLag, isLagAvailable, lagSortValue } from
'../../utils/consumerLag';
import { tableScrollX } from '../../utils/table';
+import {
+ analyzeConsumerGroupHealth,
+ type ConsumerGroupHealthIssue,
+ type ConsumerGroupHealthStatus,
+} from '../../utils/consumerGroupDiagnostics';
const { Text } = Typography;
@@ -188,6 +194,24 @@ const resetPreviewRiskLabel = (riskLevel: string) => {
return '正常';
};
+const healthStatusTagColor = (status: ConsumerGroupHealthStatus) => {
+ if (status === 'critical') return 'red';
+ if (status === 'warning') return 'orange';
+ return 'green';
+};
+
+const issueSeverityTagColor = (severity: ConsumerGroupHealthIssue['severity'])
=> {
+ if (severity === 'critical') return 'red';
+ if (severity === 'warning') return 'orange';
+ return 'blue';
+};
+
+const issueSeverityLabel = (severity: ConsumerGroupHealthIssue['severity']) =>
{
+ if (severity === 'critical') return '风险';
+ if (severity === 'warning') return '关注';
+ return '提示';
+};
+
const resetPreviewQueueMessage = (queue: ResetConsumerOffsetQueuePreview) => {
const messages: string[] = [];
if (queue.riskLevel === 'ERROR') {
@@ -477,6 +501,10 @@ const ConsumerPageContent = ({
if (key === 'settings' && selectedGroup && settingsGroup?.name !==
selectedGroup.name) {
void loadGroupSettings(selectedGroup);
}
+ if (key === 'health' && selectedGroup) {
+ void loadSubscriptions(selectedGroup.name);
+ void loadProgress(selectedGroup.name);
+ }
};
const saveSettings = async () => {
@@ -533,9 +561,10 @@ const ConsumerPageContent = ({
}
return Array.from(topics).map((topic) => ({ label: topic, value: topic }));
}, [resetDiagnosticKey, resetGroup, subscriptionsByGroup]);
- const selectedSubscriptions = selectedGroup
- ? (subscriptionsByGroup[selectedDiagnosticKey] ?? [])
- : [];
+ const selectedSubscriptions = useMemo(
+ () => (selectedGroupName ? (subscriptionsByGroup[selectedDiagnosticKey] ??
[]) : []),
+ [selectedDiagnosticKey, selectedGroupName, subscriptionsByGroup],
+ );
const inconsistentSubscriptions =
selectedSubscriptions.filter(isInconsistentSubscription);
const unknownSubscriptions = selectedSubscriptions.filter(
(subscription) =>
@@ -570,6 +599,13 @@ const ConsumerPageContent = ({
(sum, q) => sum + (isLagAvailable(q.diffTotal) ? q.diffTotal : 0),
0,
);
+ const selectedGroupHealth = useMemo(
+ () =>
+ selectedGroup
+ ? analyzeConsumerGroupHealth(selectedGroup, selectedSubscriptions,
selectedProgress)
+ : null,
+ [selectedGroup, selectedProgress, selectedSubscriptions],
+ );
const handlePreviewResetOffset = async () => {
if (!resetGroup || !resetTopic) {
@@ -1095,6 +1131,36 @@ const ConsumerPageContent = ({
},
];
+ const healthIssueColumns: ColumnsType<ConsumerGroupHealthIssue> = [
+ {
+ title: '级别',
+ dataIndex: 'severity',
+ key: 'severity',
+ width: 84,
+ render: (severity: ConsumerGroupHealthIssue['severity']) => (
+ <Tag
color={issueSeverityTagColor(severity)}>{issueSeverityLabel(severity)}</Tag>
+ ),
+ },
+ {
+ title: '诊断项',
+ dataIndex: 'title',
+ key: 'title',
+ width: 180,
+ render: (title: string, record) => (
+ <Space direction="vertical" size={0}>
+ <Text strong>{title}</Text>
+ {record.subject && <Text type="secondary">{record.subject}</Text>}
+ </Space>
+ ),
+ },
+ {
+ title: '说明',
+ dataIndex: 'description',
+ key: 'description',
+ render: (description: string) => <Text>{description}</Text>,
+ },
+ ];
+
/* ═══════════════════════════════════════════
Modal: Queue Progress Tab
═══════════════════════════════════════════ */
@@ -1487,7 +1553,7 @@ const ConsumerPageContent = ({
setSettingsLoading(false);
settingsForm.resetFields();
}}
- width={detailTab === 'progress' ? 1080 : 800}
+ width={detailTab === 'progress' || detailTab === 'health' ? 1080 : 800}
destroyOnHidden
footer={null}
>
@@ -1713,6 +1779,155 @@ const ConsumerPageContent = ({
</div>
),
},
+ /* ─── 健康诊断 Tab ─── */
+ {
+ key: 'health',
+ label: (
+ <Space size={4}>
+ <Info size={14} />
+ <span>健康诊断</span>
+ </Space>
+ ),
+ children: selectedGroupHealth && (
+ <Space direction="vertical" size={16} style={{ width: '100%'
}}>
+ <Flex justify="space-between" align="center" gap={12} wrap>
+ <Space direction="vertical" size={2}>
+ <Space>
+ <Tag
color={healthStatusTagColor(selectedGroupHealth.status)}>
+ {selectedGroupHealth.statusText}
+ </Tag>
+ <Text type="secondary">
+ 汇总订阅、队列进度和在线客户端,定位消费风险。
+ </Text>
+ </Space>
+ <Text type="secondary">诊断结果随详情弹窗每 2 秒自动刷新。</Text>
+ </Space>
+ <Button
+ size="small"
+ icon={<ArrowsClockwise size={14} />}
+
loading={subscriptionLoadingByGroup[selectedDiagnosticKey]}
+ onClick={() => {
+ void loadSubscriptions(selectedGroup.name, true);
+ void loadProgress(selectedGroup.name, true);
+ }}
+ >
+ 重新诊断
+ </Button>
+ </Flex>
+
+ {subscriptionErrorByGroup[selectedDiagnosticKey] && (
+ <Alert
+ type="warning"
+ showIcon
+ message="订阅一致性检查失败,诊断仍使用当前可用的进度和客户端数据。"
+ />
+ )}
+
+ <Row gutter={16}>
+ <Col span={6}>
+ <Card size="small" style={{ borderRadius: 8 }}>
+ <Statistic
+ title="健康分"
+ value={selectedGroupHealth.summary.healthScore}
+ suffix="/ 100"
+ valueStyle={{
+ color:
+ selectedGroupHealth.status === 'critical'
+ ? '#ff4d4f'
+ : selectedGroupHealth.status === 'warning'
+ ? '#faad14'
+ : '#52c41a',
+ }}
+ />
+ <Progress
+ percent={selectedGroupHealth.summary.healthScore}
+ showInfo={false}
+ status={
+ selectedGroupHealth.status === 'critical'
+ ? 'exception'
+ : selectedGroupHealth.status === 'warning'
+ ? 'active'
+ : 'success'
+ }
+ />
+ </Card>
+ </Col>
+ <Col span={6}>
+ <Card size="small" style={{ borderRadius: 8 }}>
+ <Statistic
+ title="已知堆积"
+ value={selectedGroupHealth.summary.totalKnownLag}
+ valueStyle={{
+ color:
lagColor(selectedGroupHealth.summary.totalKnownLag),
+ }}
+ />
+ <Text type="secondary">
+ 报告堆积:
+ {selectedGroupHealth.summary.reportedLag === null
+ ? UNAVAILABLE_LAG_LABEL
+ :
selectedGroupHealth.summary.reportedLag.toLocaleString()}
+ </Text>
+ </Card>
+ </Col>
+ <Col span={6}>
+ <Card size="small" style={{ borderRadius: 8 }}>
+ <Statistic
+ title="Queue 覆盖"
+ value={selectedGroupHealth.summary.queueCount}
+
suffix={`/${selectedGroupHealth.summary.subscribedTopicCount} Topic`}
+ />
+ <Text type="secondary">
+ {selectedGroupHealth.summary.unknownQueueCount > 0
+ ?
`${selectedGroupHealth.summary.unknownQueueCount} 个 Queue 堆积不可用`
+ : 'Queue 堆积均可计算'}
+ </Text>
+ </Card>
+ </Col>
+ <Col span={6}>
+ <Card size="small" style={{ borderRadius: 8 }}>
+ <Statistic
+ title="客户端"
+ value={selectedGroupHealth.summary.onlineInstances}
+ />
+ <Text type="secondary">
+ {selectedGroupHealth.summary.staleClientCount > 0
+ ?
`${selectedGroupHealth.summary.staleClientCount} 个心跳过期`
+ : '心跳状态正常'}
+ </Text>
+ </Card>
+ </Col>
+ </Row>
+
+ {selectedGroupHealth.issues.length > 0 ? (
+ <Table
+ columns={healthIssueColumns}
+ dataSource={selectedGroupHealth.issues}
+ rowKey="id"
+ pagination={false}
+ size="small"
+ scroll={{ x: tableScrollX(healthIssueColumns) }}
+ />
+ ) : (
+ <Alert type="success" showIcon message="未发现消费组健康风险" />
+ )}
+
+ {selectedGroupHealth.recommendations.length > 0 && (
+ <Alert
+ type="info"
+ showIcon
+ message="处理建议"
+ description={
+ <Space direction="vertical" size={4}>
+
{selectedGroupHealth.recommendations.map((recommendation) => (
+ <Text
key={recommendation}>{recommendation}</Text>
+ ))}
+ </Space>
+ }
+ />
+ )}
+ </Space>
+ ),
+ },
/* ─── 消费进度 Tab ─── */
{
key: 'progress',
diff --git a/web/src/utils/consumerGroupDiagnostics.test.ts
b/web/src/utils/consumerGroupDiagnostics.test.ts
new file mode 100644
index 000000000..b6a09bfcc
--- /dev/null
+++ b/web/src/utils/consumerGroupDiagnostics.test.ts
@@ -0,0 +1,157 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, expect, it } from 'vitest';
+import type { ConsumerGroup, QueueProgress, SubscriptionEntry } from
'../api/metadata';
+import { analyzeConsumerGroupHealth } from './consumerGroupDiagnostics';
+
+const group = (overrides: Partial<ConsumerGroup> = {}): ConsumerGroup => ({
+ name: 'orders-cg',
+ namespace: 'default',
+ clusterId: 'cluster-a',
+ instanceId: 'instance-a',
+ subscriptionMode: 'Push',
+ consumeType: 'CLUSTERING',
+ onlineInstances: 1,
+ totalLag: 12,
+ subscribedTopics: ['orders'],
+ subscriptionDataType: 'NORMAL',
+ retryMaxTimes: 16,
+ gmtCreate: '2026-08-31T10:00:00Z',
+ gmtModified: '2026-08-31T10:00:00Z',
+ delaySeconds: 10,
+ instances: [
+ {
+ clientId: '[email protected]',
+ protocol: 'GRPC',
+ address: '10.0.0.1:49152',
+ subscribedTopics: ['orders'],
+ lastHeartbeat: '2026-08-31T12:00:00Z',
+ topicLag: { orders: 12 },
+ },
+ ],
+ ...overrides,
+});
+
+const subscription = (overrides: Partial<SubscriptionEntry> = {}):
SubscriptionEntry => ({
+ topic: 'orders',
+ expression: '*',
+ type: 'NORMAL',
+ filterMode: '全量',
+ consistency: '一致',
+ ...overrides,
+});
+
+const queue = (overrides: Partial<QueueProgress> = {}): QueueProgress => ({
+ topic: 'orders',
+ broker: 'broker-a',
+ queueId: 0,
+ brokerOffset: 120,
+ consumerOffset: 114,
+ diffTotal: 6,
+ ...overrides,
+});
+
+describe('consumer group diagnostics', () => {
+ it('summarizes an active group with balanced queues as healthy', () => {
+ const diagnostics = analyzeConsumerGroupHealth(
+ group(),
+ [subscription()],
+ [queue(), queue({ broker: 'broker-b', queueId: 1 })],
+ { now: '2026-08-31T12:01:00Z' },
+ );
+
+ expect(diagnostics.status).toBe('healthy');
+ expect(diagnostics.summary).toMatchObject({
+ healthScore: 100,
+ onlineInstances: 1,
+ subscribedTopicCount: 1,
+ queueCount: 2,
+ lagQueueCount: 2,
+ totalKnownLag: 12,
+ reportedLag: 12,
+ staleClientCount: 0,
+ });
+ expect(diagnostics.issues).toEqual([]);
+ });
+
+ it('flags critical subscription, queue and runtime risks', () => {
+ const diagnostics = analyzeConsumerGroupHealth(
+ group({
+ onlineInstances: 0,
+ totalLag: 2_400,
+ delaySeconds: 1_900,
+ instances: [],
+ }),
+ [subscription({ consistency: '不一致', expression: 'tagA' })],
+ [queue({ diffTotal: 10 }), queue({ broker: 'broker-b', queueId: 1,
diffTotal: 1_100 })],
+ { now: '2026-08-31T12:01:00Z' },
+ );
+
+ expect(diagnostics.status).toBe('critical');
+ expect(diagnostics.summary.healthScore).toBeLessThan(50);
+ expect(diagnostics.issues.map((item) => item.code)).toEqual(
+ expect.arrayContaining([
+ 'SUBSCRIPTION_INCONSISTENT',
+ 'QUEUE_LAG_SKEW',
+ 'HIGH_GROUP_LAG',
+ 'NO_ACTIVE_CLIENTS_WITH_LAG',
+ 'HIGH_CONSUME_DELAY',
+ ]),
+ );
+ expect(diagnostics.recommendations).toEqual(
+ expect.arrayContaining([
+ '先确认消费者进程、Proxy/Broker 网络连通性和客户端心跳是否恢复。',
+ '检查热点 Queue 的分配、消费者线程池和单分区顺序消费阻塞情况。',
+ ]),
+ );
+ });
+
+ it('keeps warnings for unknown lag, unknown subscriptions and stale
clients', () => {
+ const diagnostics = analyzeConsumerGroupHealth(
+ group({
+ totalLag: -1,
+ delaySeconds: 360,
+ instances: [
+ {
+ clientId: '[email protected]',
+ protocol: 'GRPC',
+ address: '10.0.0.1:49152',
+ subscribedTopics: ['orders'],
+ lastHeartbeat: '2026-08-31T11:50:00Z',
+ topicLag: {},
+ },
+ ],
+ }),
+ [subscription({ consistency: 'unknown' })],
+ [queue({ diffTotal: -1 })],
+ { now: '2026-08-31T12:01:00Z' },
+ );
+
+ expect(diagnostics.status).toBe('warning');
+ expect(diagnostics.summary).toMatchObject({
+ reportedLag: 0,
+ unknownQueueCount: 1,
+ maxQueueLag: null,
+ maxHeartbeatAgeSeconds: 660,
+ staleClientCount: 1,
+ });
+ expect(diagnostics.issues.map((item) => item.code)).toEqual(
+ expect.arrayContaining(['SUBSCRIPTION_UNKNOWN', 'UNKNOWN_QUEUE_LAG',
'STALE_HEARTBEAT']),
+ );
+ });
+});
diff --git a/web/src/utils/consumerGroupDiagnostics.ts
b/web/src/utils/consumerGroupDiagnostics.ts
new file mode 100644
index 000000000..4e1d8c52a
--- /dev/null
+++ b/web/src/utils/consumerGroupDiagnostics.ts
@@ -0,0 +1,423 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { ConsumerGroup, QueueProgress, SubscriptionEntry } from
'../api/metadata';
+import { isLagAvailable } from './consumerLag';
+
+export type ConsumerGroupHealthStatus = 'healthy' | 'warning' | 'critical';
+
+export type ConsumerGroupHealthIssueCode =
+ | 'NO_ACTIVE_CLIENTS_WITH_LAG'
+ | 'NO_SUBSCRIPTION_DATA'
+ | 'SUBSCRIPTION_INCONSISTENT'
+ | 'SUBSCRIPTION_UNKNOWN'
+ | 'QUEUE_LAG_SKEW'
+ | 'HIGH_GROUP_LAG'
+ | 'HIGH_CONSUME_DELAY'
+ | 'UNKNOWN_QUEUE_LAG'
+ | 'STALE_HEARTBEAT';
+
+export interface ConsumerGroupHealthIssue {
+ id: string;
+ code: ConsumerGroupHealthIssueCode;
+ severity: Exclude<ConsumerGroupHealthStatus, 'healthy'>;
+ title: string;
+ description: string;
+ subject?: string;
+}
+
+export interface ConsumerGroupHealthSummary {
+ healthScore: number;
+ onlineInstances: number;
+ subscribedTopicCount: number;
+ queueCount: number;
+ lagQueueCount: number;
+ unknownQueueCount: number;
+ totalKnownLag: number;
+ reportedLag: number | null;
+ maxQueueLag: number | null;
+ maxHeartbeatAgeSeconds: number | null;
+ staleClientCount: number;
+}
+
+export interface ConsumerGroupHealthDiagnostics {
+ status: ConsumerGroupHealthStatus;
+ statusText: string;
+ statusColor: 'success' | 'warning' | 'error';
+ summary: ConsumerGroupHealthSummary;
+ issues: ConsumerGroupHealthIssue[];
+ recommendations: string[];
+}
+
+export interface ConsumerGroupHealthOptions {
+ now?: Date | string | number;
+ staleHeartbeatSeconds?: number;
+ highLagThreshold?: number;
+ criticalLagThreshold?: number;
+ highDelaySeconds?: number;
+ criticalDelaySeconds?: number;
+ skewWarningRatio?: number;
+ skewCriticalRatio?: number;
+}
+
+const STATUS_ORDER: Record<ConsumerGroupHealthStatus, number> = {
+ healthy: 0,
+ warning: 1,
+ critical: 2,
+};
+
+const STATUS_TEXT: Record<ConsumerGroupHealthStatus, string> = {
+ healthy: '消费组健康',
+ warning: '需要关注',
+ critical: '消费风险',
+};
+
+const STATUS_COLOR: Record<ConsumerGroupHealthStatus, 'success' | 'warning' |
'error'> = {
+ healthy: 'success',
+ warning: 'warning',
+ critical: 'error',
+};
+
+const DEFAULT_STALE_HEARTBEAT_SECONDS = 300;
+const DEFAULT_HIGH_LAG_THRESHOLD = 1_000;
+const DEFAULT_CRITICAL_LAG_THRESHOLD = 10_000;
+const DEFAULT_HIGH_DELAY_SECONDS = 300;
+const DEFAULT_CRITICAL_DELAY_SECONDS = 1_800;
+const DEFAULT_SKEW_WARNING_RATIO = 2;
+const DEFAULT_SKEW_CRITICAL_RATIO = 5;
+
+const issue = (
+ code: ConsumerGroupHealthIssueCode,
+ severity: Exclude<ConsumerGroupHealthStatus, 'healthy'>,
+ title: string,
+ description: string,
+ subject?: string,
+): ConsumerGroupHealthIssue => ({
+ id: [code, subject].filter(Boolean).join(':'),
+ code,
+ severity,
+ title,
+ description,
+ subject,
+});
+
+const normalizeText = (value?: string | null): string => value?.trim() ?? '';
+
+const normalizeKey = (value?: string | null): string =>
normalizeText(value).toLowerCase();
+
+const isConsistentSubscription = (subscription: SubscriptionEntry): boolean =>
+ ['consistent', '一致'].includes(normalizeKey(subscription.consistency));
+
+const isInconsistentSubscription = (subscription: SubscriptionEntry): boolean
=>
+ ['inconsistent', '不一致'].includes(normalizeKey(subscription.consistency));
+
+const parseTimestamp = (value: Date | string | number | undefined): number |
null => {
+ if (value === undefined || value === null || value === '') return null;
+ const timestamp = value instanceof Date ? value.getTime() : new
Date(value).getTime();
+ return Number.isFinite(timestamp) ? timestamp : null;
+};
+
+const heartbeatAgeSeconds = (lastHeartbeat: string | undefined, now: number):
number | null => {
+ const timestamp = parseTimestamp(lastHeartbeat);
+ if (timestamp === null) return null;
+ return Math.max(0, Math.floor((now - timestamp) / 1000));
+};
+
+const knownLag = (progress: QueueProgress[]): number =>
+ progress.reduce((sum, queue) => sum + (isLagAvailable(queue.diffTotal) ?
queue.diffTotal : 0), 0);
+
+const reportedLag = (group: ConsumerGroup, fallback: number): number | null =>
+ isLagAvailable(group.totalLag) ? group.totalLag : fallback;
+
+const topicCount = (group: ConsumerGroup, subscriptions: SubscriptionEntry[]):
number => {
+ const topics = new Set<string>();
+ for (const topic of group.subscribedTopics ?? []) {
+ const normalized = normalizeText(topic);
+ if (normalized) topics.add(normalized);
+ }
+ for (const subscription of subscriptions) {
+ const normalized = normalizeText(subscription.topic);
+ if (normalized) topics.add(normalized);
+ }
+ return topics.size;
+};
+
+const lagSkewRatio = (knownQueueLags: number[]): number => {
+ const positive = knownQueueLags.filter((lag) => lag > 0);
+ if (positive.length <= 1) return 0;
+ const min = Math.min(...positive);
+ if (min === 0) return 0;
+ return Math.round((Math.max(...positive) / min) * 100) / 100;
+};
+
+const maxStatus = (issues: ConsumerGroupHealthIssue[]):
ConsumerGroupHealthStatus =>
+ issues.reduce<ConsumerGroupHealthStatus>(
+ (status, current) =>
+ STATUS_ORDER[current.severity] > STATUS_ORDER[status] ? current.severity
: status,
+ 'healthy',
+ );
+
+const healthScore = (issues: ConsumerGroupHealthIssue[]): number => {
+ const penalties = issues.reduce(
+ (sum, current) => sum + (current.severity === 'critical' ? 25 : 12),
+ 0,
+ );
+ return Math.max(0, Math.min(100, Math.round(100 - penalties)));
+};
+
+const subscriptionIssues = (subscriptions: SubscriptionEntry[]):
ConsumerGroupHealthIssue[] => {
+ if (subscriptions.length === 0) {
+ return [
+ issue(
+ 'NO_SUBSCRIPTION_DATA',
+ 'warning',
+ '暂无订阅明细',
+ '无法从当前结果判断客户端订阅表达式是否一致。',
+ ),
+ ];
+ }
+
+ const issues: ConsumerGroupHealthIssue[] = [];
+ for (const subscription of subscriptions) {
+ if (isInconsistentSubscription(subscription)) {
+ issues.push(
+ issue(
+ 'SUBSCRIPTION_INCONSISTENT',
+ 'critical',
+ '订阅表达式不一致',
+ `${subscription.topic} 的订阅表达式在客户端之间不一致,可能导致消息遗漏或重复消费。`,
+ subscription.topic,
+ ),
+ );
+ } else if (!isConsistentSubscription(subscription)) {
+ issues.push(
+ issue(
+ 'SUBSCRIPTION_UNKNOWN',
+ 'warning',
+ '订阅一致性未知',
+ `${subscription.topic} 的一致性状态未知,建议重新检查客户端订阅。`,
+ subscription.topic,
+ ),
+ );
+ }
+ }
+ return issues;
+};
+
+const progressIssues = (
+ progress: QueueProgress[],
+ knownQueueLags: number[],
+ unknownQueueCount: number,
+ options: Required<
+ Pick<
+ ConsumerGroupHealthOptions,
+ 'skewWarningRatio' | 'skewCriticalRatio' | 'highLagThreshold' |
'criticalLagThreshold'
+ >
+ >,
+): ConsumerGroupHealthIssue[] => {
+ if (progress.length === 0) return [];
+
+ const issues: ConsumerGroupHealthIssue[] = [];
+ const skew = lagSkewRatio(knownQueueLags);
+ const totalLag = knownQueueLags.reduce((sum, lag) => sum + lag, 0);
+
+ if (unknownQueueCount > 0) {
+ issues.push(
+ issue(
+ 'UNKNOWN_QUEUE_LAG',
+ 'warning',
+ '部分 Queue 堆积不可用',
+ `${unknownQueueCount} 个 Queue 无法计算堆积,当前总堆积只包含可用数据。`,
+ ),
+ );
+ }
+ if (skew >= options.skewCriticalRatio) {
+ issues.push(
+ issue(
+ 'QUEUE_LAG_SKEW',
+ 'critical',
+ 'Queue 堆积分布严重倾斜',
+ `最大/最小 Queue 堆积约为 ${skew}:1,可能存在单队列热点或消费者分配不均。`,
+ ),
+ );
+ } else if (skew >= options.skewWarningRatio) {
+ issues.push(
+ issue(
+ 'QUEUE_LAG_SKEW',
+ 'warning',
+ 'Queue 堆积分布不均',
+ `最大/最小 Queue 堆积约为 ${skew}:1,建议观察是否持续扩大。`,
+ ),
+ );
+ }
+ if (totalLag >= options.criticalLagThreshold) {
+ issues.push(
+ issue(
+ 'HIGH_GROUP_LAG',
+ 'critical',
+ 'Group 总堆积过高',
+ `Group 当前已知堆积达到 ${totalLag.toLocaleString()} 条。`,
+ ),
+ );
+ } else if (totalLag >= options.highLagThreshold) {
+ issues.push(
+ issue(
+ 'HIGH_GROUP_LAG',
+ 'warning',
+ 'Group 总堆积偏高',
+ `Group 当前已知堆积达到 ${totalLag.toLocaleString()} 条。`,
+ ),
+ );
+ }
+ return issues;
+};
+
+const runtimeIssues = (
+ group: ConsumerGroup,
+ lag: number | null,
+ now: number,
+ options: Required<
+ Pick<
+ ConsumerGroupHealthOptions,
+ 'staleHeartbeatSeconds' | 'highDelaySeconds' | 'criticalDelaySeconds'
+ >
+ >,
+): ConsumerGroupHealthIssue[] => {
+ const issues: ConsumerGroupHealthIssue[] = [];
+ if ((group.onlineInstances ?? 0) === 0 && (lag ?? 0) > 0) {
+ issues.push(
+ issue(
+ 'NO_ACTIVE_CLIENTS_WITH_LAG',
+ 'critical',
+ '有堆积但无在线客户端',
+ '消费组存在未消费消息,但当前没有在线客户端处理这些消息。',
+ ),
+ );
+ }
+
+ for (const client of group.instances ?? []) {
+ const age = heartbeatAgeSeconds(client.lastHeartbeat, now);
+ if (age !== null && age > options.staleHeartbeatSeconds) {
+ issues.push(
+ issue(
+ 'STALE_HEARTBEAT',
+ 'warning',
+ '客户端心跳过期',
+ `${client.clientId} 的最后心跳已超过 ${options.staleHeartbeatSeconds} 秒。`,
+ client.clientId,
+ ),
+ );
+ }
+ }
+
+ const delaySeconds = Number.isFinite(group.delaySeconds) ?
group.delaySeconds : 0;
+ if (delaySeconds >= options.criticalDelaySeconds) {
+ issues.push(
+ issue(
+ 'HIGH_CONSUME_DELAY',
+ 'critical',
+ '消费延迟过高',
+ `Group 当前消费延迟约 ${delaySeconds.toLocaleString()} 秒,业务可能已经感知延迟。`,
+ ),
+ );
+ } else if (delaySeconds >= options.highDelaySeconds) {
+ issues.push(
+ issue(
+ 'HIGH_CONSUME_DELAY',
+ 'warning',
+ '消费延迟偏高',
+ `Group 当前消费延迟约 ${delaySeconds.toLocaleString()} 秒,建议继续观察趋势。`,
+ ),
+ );
+ }
+ return issues;
+};
+
+const recommendations = (issues: ConsumerGroupHealthIssue[]): string[] => {
+ const codes = new Set(issues.map((item) => item.code));
+ const result: string[] = [];
+ if (codes.has('NO_ACTIVE_CLIENTS_WITH_LAG') || codes.has('STALE_HEARTBEAT'))
{
+ result.push('先确认消费者进程、Proxy/Broker 网络连通性和客户端心跳是否恢复。');
+ }
+ if (codes.has('SUBSCRIPTION_INCONSISTENT') ||
codes.has('SUBSCRIPTION_UNKNOWN')) {
+ result.push('统一同一 Group 内所有客户端的订阅表达式,避免灰度期间同时运行不同过滤条件。');
+ }
+ if (codes.has('QUEUE_LAG_SKEW')) {
+ result.push('检查热点 Queue 的分配、消费者线程池和单分区顺序消费阻塞情况。');
+ }
+ if (codes.has('HIGH_GROUP_LAG') || codes.has('HIGH_CONSUME_DELAY')) {
+ result.push('结合消费 TPS、业务耗时和重试堆积判断是否需要扩容消费者或限流生产端。');
+ }
+ if (codes.has('UNKNOWN_QUEUE_LAG')) {
+ result.push('当堆积不可用时,优先确认 Proxy 指标采集和 Broker offset 查询权限。');
+ }
+ return result;
+};
+
+export const analyzeConsumerGroupHealth = (
+ group: ConsumerGroup,
+ subscriptions: SubscriptionEntry[],
+ progress: QueueProgress[],
+ options: ConsumerGroupHealthOptions = {},
+): ConsumerGroupHealthDiagnostics => {
+ const normalizedOptions = {
+ staleHeartbeatSeconds: options.staleHeartbeatSeconds ??
DEFAULT_STALE_HEARTBEAT_SECONDS,
+ highLagThreshold: options.highLagThreshold ?? DEFAULT_HIGH_LAG_THRESHOLD,
+ criticalLagThreshold: options.criticalLagThreshold ??
DEFAULT_CRITICAL_LAG_THRESHOLD,
+ highDelaySeconds: options.highDelaySeconds ?? DEFAULT_HIGH_DELAY_SECONDS,
+ criticalDelaySeconds: options.criticalDelaySeconds ??
DEFAULT_CRITICAL_DELAY_SECONDS,
+ skewWarningRatio: options.skewWarningRatio ?? DEFAULT_SKEW_WARNING_RATIO,
+ skewCriticalRatio: options.skewCriticalRatio ??
DEFAULT_SKEW_CRITICAL_RATIO,
+ };
+ const now = parseTimestamp(options.now ?? Date.now()) ?? Date.now();
+ const knownQueueLags = progress
+ .map((queue) => queue.diffTotal)
+ .filter((lag): lag is number => isLagAvailable(lag));
+ const totalKnownLag = knownLag(progress);
+ const summaryReportedLag = reportedLag(group, totalKnownLag);
+ const unknownQueueCount = progress.length - knownQueueLags.length;
+ const heartbeatAges = (group.instances ?? [])
+ .map((client) => heartbeatAgeSeconds(client.lastHeartbeat, now))
+ .filter((age): age is number => age !== null);
+ const issues = [
+ ...subscriptionIssues(subscriptions),
+ ...progressIssues(progress, knownQueueLags, unknownQueueCount,
normalizedOptions),
+ ...runtimeIssues(group, summaryReportedLag, now, normalizedOptions),
+ ];
+ const status = maxStatus(issues);
+
+ return {
+ status,
+ statusText: STATUS_TEXT[status],
+ statusColor: STATUS_COLOR[status],
+ summary: {
+ healthScore: healthScore(issues),
+ onlineInstances: group.onlineInstances ?? 0,
+ subscribedTopicCount: topicCount(group, subscriptions),
+ queueCount: progress.length,
+ lagQueueCount: knownQueueLags.filter((lag) => lag > 0).length,
+ unknownQueueCount,
+ totalKnownLag,
+ reportedLag: summaryReportedLag,
+ maxQueueLag: knownQueueLags.length > 0 ? Math.max(...knownQueueLags) :
null,
+ maxHeartbeatAgeSeconds: heartbeatAges.length > 0 ?
Math.max(...heartbeatAges) : null,
+ staleClientCount: issues.filter((item) => item.code ===
'STALE_HEARTBEAT').length,
+ },
+ issues,
+ recommendations: recommendations(issues),
+ };
+};