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 62d0ad92e fix(alerts): count 24h triggers from the UTC stamp (#5723)
62d0ad92e is described below
commit 62d0ad92e931de46a2fc944ae5d970e6fc3d7311
Author: 0 <[email protected]>
AuthorDate: Mon Oct 19 20:52:10 2026 +0800
fix(alerts): count 24h triggers from the UTC stamp (#5723)
Verified against `rocketmq-studio` at e1577bd31 before merging: the defect
is still present on the
current trunk and the change merges cleanly.
Merged as-is (squash); no maintainer edits were needed.
Locally verified: targeted suites green on the integration branch; a full
backend and frontend
build plus test pass runs on the trunk once this batch lands.
---
web/src/pages/ops/__tests__/AlertsPage.test.tsx | 23 ++++++++++++++++++++
web/src/pages/ops/alerts.tsx | 12 +++++++----
web/src/utils/format.test.ts | 28 +++++++++++++++++++++++++
web/src/utils/format.ts | 18 ++++++++++++++++
4 files changed, 77 insertions(+), 4 deletions(-)
diff --git a/web/src/pages/ops/__tests__/AlertsPage.test.tsx
b/web/src/pages/ops/__tests__/AlertsPage.test.tsx
index 3f25253cf..9fa63dad7 100644
--- a/web/src/pages/ops/__tests__/AlertsPage.test.tsx
+++ b/web/src/pages/ops/__tests__/AlertsPage.test.tsx
@@ -1205,6 +1205,29 @@ describe('AlertsPage', () => {
expect(screen.queryByText('Page one rule 1')).not.toBeInTheDocument();
});
+ it('counts the 24h-triggered stat against the UTC timestamp, not the browser
zone', async () => {
+ // Pin the viewer zone off UTC so a browser-zone parse is observably
wrong, and pin the
+ // clock so the fixture's age is exact. The stat must read the same value
the adjacent
+ // Last-Triggered column renders — UTC, per the formatUtcDateTime
convention.
+ vi.stubEnv('TZ', 'Asia/Shanghai');
+ const nowSpy = vi.spyOn(Date,
'now').mockReturnValue(Date.parse('2026-08-24T12:00:00Z'));
+ try {
+ // 2026-08-23T13:00:00 UTC is 23h before the pinned now. Parsed as
Shanghai wall time
+ // it becomes 05:00Z — 31h old — which is what the browser-zone parse
wrongly excludes.
+ vi.mocked(listAlertRulesPage).mockResolvedValue(
+ pageResult([{ ...cloneRule(alertRules[0]), lastTriggered:
'2026-08-23T13:00:00' }]),
+ );
+
+ renderPage();
+
+ const statLabel = await screen.findByText('本页 24h 触发');
+ expect(statLabel.nextElementSibling).toHaveTextContent('1');
+ } finally {
+ nowSpy.mockRestore();
+ vi.unstubAllEnvs();
+ }
+ });
+
it('clamps to the last valid page when a bulk delete empties the current
one', async () => {
// 21 rules: page 2 holds only rule 21. Deleting it leaves 20 rules across
1 page, so the
// refresh of page 2 comes back empty and the view must clamp to the last
valid page (1).
diff --git a/web/src/pages/ops/alerts.tsx b/web/src/pages/ops/alerts.tsx
index f81e20487..044527b97 100644
--- a/web/src/pages/ops/alerts.tsx
+++ b/web/src/pages/ops/alerts.tsx
@@ -66,7 +66,7 @@ import {
} from '../../services/opsService';
import { attachThresholdUnit, normalizeDuration, normalizeMetric } from
'./alertRulePayload';
import { tableScrollX } from '../../utils/table';
-import { formatUtcDateTime, ratioToPercent } from '../../utils/format';
+import { formatUtcDateTime, parseAlertTimestamp, ratioToPercent } from
'../../utils/format';
import { listInstances } from '../../services/instanceService';
import type { Instance } from '../../api/instance';
import { downloadBlob } from '../../utils/download';
@@ -415,9 +415,13 @@ const AlertsPageContent = ({ domain = 'CLUSTER' }:
AlertsPageProps) => {
// eslint-disable-next-line react-hooks/purity
const dayAgo = Date.now() - 24 * 60 * 60 * 1000;
- const triggered24h = rules.filter(
- (r) => r.lastTriggered && new Date(r.lastTriggered).getTime() > dayAgo,
- ).length;
+ // lastTriggered is a UTC LocalDateTime without an offset suffix (see the
column below), so the
+ // comparison must parse it as UTC — the same convention formatUtcDateTime
renders it with.
+ const triggered24h = rules.filter((r) => {
+ if (!r.lastTriggered) return false;
+ const triggeredAt = parseAlertTimestamp(r.lastTriggered);
+ return Number.isFinite(triggeredAt) && triggeredAt > dayAgo;
+ }).length;
const openCreateModal = () => {
setEditingRule(null);
diff --git a/web/src/utils/format.test.ts b/web/src/utils/format.test.ts
index de99180dd..8e7b992b0 100644
--- a/web/src/utils/format.test.ts
+++ b/web/src/utils/format.test.ts
@@ -11,6 +11,7 @@ import {
formatRelativeTime,
formatTimeOfDay,
formatTimeMs,
+ parseAlertTimestamp,
ratioToPercent,
roundPercent,
} from './format';
@@ -120,6 +121,33 @@ describe('formatBytes', () => {
});
});
+describe('parseAlertTimestamp', () => {
+ it('parses an offset-less alert timestamp as UTC regardless of the browser
zone', () => {
+ // Expressed against Date.parse with an explicit Z so the expectation
cannot drift with TZ.
+
expect(parseAlertTimestamp('2026-08-23T10:35:38')).toBe(Date.parse('2026-08-23T10:35:38Z'));
+ expect(parseAlertTimestamp('2026-08-23
10:35:38')).toBe(Date.parse('2026-08-23T10:35:38Z'));
+ });
+
+ it('honors an explicit offset instead of assuming UTC', () => {
+
expect(parseAlertTimestamp('2026-08-23T10:35:38Z')).toBe(Date.parse('2026-08-23T10:35:38Z'));
+ expect(parseAlertTimestamp('2026-08-23T10:35:38+08:00')).toBe(
+ Date.parse('2026-08-23T10:35:38+08:00'),
+ );
+ expect(parseAlertTimestamp('2026-08-23T10:35:38-05:00')).toBe(
+ Date.parse('2026-08-23T10:35:38-05:00'),
+ );
+ });
+
+ it('passes Date objects through and reports unusable values as NaN', () => {
+ const date = new Date(Date.parse('2026-08-23T10:35:38Z'));
+ expect(parseAlertTimestamp(date)).toBe(date.getTime());
+ expect(parseAlertTimestamp('not a timestamp')).toBeNaN();
+ expect(parseAlertTimestamp('')).toBeNaN();
+ expect(parseAlertTimestamp(null)).toBeNaN();
+ expect(parseAlertTimestamp(undefined)).toBeNaN();
+ });
+});
+
describe('formatTimeMs', () => {
it.each([8_640_000_000_000_001, -8_640_000_000_000_001, Number.MAX_VALUE,
-Number.MAX_VALUE])(
'uses the placeholder for finite timestamps outside the Date range: %s',
diff --git a/web/src/utils/format.ts b/web/src/utils/format.ts
index 90084a997..b9e96e7c1 100644
--- a/web/src/utils/format.ts
+++ b/web/src/utils/format.ts
@@ -49,6 +49,24 @@ export function formatTimeMs(value: number | string | null |
undefined): string
return `${formatDateTime(date)}.${pad(date.getMilliseconds(), 3)}`;
}
+/**
+ * Epoch milliseconds of a backend alert timestamp, parsed the way the backend
stamps it.
+ *
+ * Alert APIs serialize UTC `LocalDateTime` values without an offset, so a
plain
+ * `new Date(value)` would read them in the browser's zone. An explicit offset
(`Z` or
+ * `±hh:mm`) is honored; unusable input yields `NaN` so callers can treat it
as absent.
+ */
+export function parseAlertTimestamp(date: string | Date | null | undefined):
number {
+ if (date === null || date === undefined || (typeof date === 'string' &&
!date.trim())) {
+ return Number.NaN;
+ }
+ const utcDate =
+ typeof date === 'string' && !/(?:Z|[+-]\d{2}:?\d{2})$/i.test(date.trim())
+ ? new Date(`${date}Z`)
+ : new Date(date);
+ return utcDate.getTime();
+}
+
/**
* Format a UTC timestamp for alert events in the viewer's timezone. Alert APIs
* serialize UTC LocalDateTime values without an offset, so normal Date parsing