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 aaaa957c9 fix(alert): match padded instance ids and render alert 
windows and placeholders (#4464)
aaaa957c9 is described below

commit aaaa957c92e7dfc31845391bf785ca5b8817745b
Author: 0 <[email protected]>
AuthorDate: Mon Sep 21 12:16:56 2026 +0800

    fix(alert): match padded instance ids and render alert windows and 
placeholders (#4464)
    
    Three alert-surface fixes:
    
    - instance ids are stored verbatim, so a padded value never matched the 
scope
      filter. Trim before comparing in 
MybatisPlusAlertStateRepository.findActive, and
      use the same StringUtils.hasText judgment in NativeAlertProcessor's 
reconcile
      filter so a blank instance_id keeps meaning "every instance" on both paths
      instead of only on one.
    - maintenance window timestamps (startsAt / endsAt / recurrenceUntil) were
      interpolated as raw strings; they now go through formatUtcDateTime like 
the rest
      of the page.
    - the template preview rendered an untrimmed template while delivery trims 
it, so
      ${ title } previewed as the title but was sent literally. Preview now 
trims the
      same way.
    
    Maintainer edit on top of the contribution: the NativeAlertProcessor 
judgment was
    aligned with the repository (hasText rather than a null check) plus a 
regression
    test in NativeAlertProcessorTest covering a blank-instance rule that is 
missing
    from a successful collection scope.
---
 .../ops/alert/MybatisPlusAlertStateRepository.java |  2 +-
 .../studio/ops/alert/NativeAlertProcessor.java     |  3 +-
 .../alert/MybatisPlusAlertStateRepositoryTest.java | 29 ++++++++++++++++++
 .../studio/ops/alert/NativeAlertProcessorTest.java | 35 ++++++++++++++++++++++
 .../pages/ops/__tests__/SystemAlertsPage.test.tsx  | 32 ++++++++++++++++++++
 web/src/pages/ops/systemAlerts.tsx                 |  8 +++--
 web/src/utils/alertTemplatePreview.test.ts         | 24 +++++++++++++++
 web/src/utils/alertTemplatePreview.ts              |  6 ++--
 8 files changed, 132 insertions(+), 7 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertStateRepository.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertStateRepository.java
index 0e2f94d01..3d07e690d 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertStateRepository.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertStateRepository.java
@@ -105,7 +105,7 @@ public class MybatisPlusAlertStateRepository implements 
AlertStateRepository {
                 .filter(rule -> ruleDomain(rule) == scope.domain())
                 .filter(rule -> 
metricKeys.contains(StringUtils.trimWhitespace(rule.getMetric())))
                 .filter(rule -> !StringUtils.hasText(rule.getInstanceId())
-                        || scope.instanceId().equals(rule.getInstanceId()))
+                        || 
scope.instanceId().equals(StringUtils.trimWhitespace(rule.getInstanceId())))
                 .collect(Collectors.toMap(AlertRuleVO::getId, rule -> rule, 
(left, right) -> left));
         if (scopedRules.isEmpty()) {
             return List.of();
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertProcessor.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertProcessor.java
index e4dacd3a8..5aca1d1ec 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertProcessor.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertProcessor.java
@@ -103,7 +103,8 @@ public class NativeAlertProcessor {
                 .filter(rule -> rule.getId() != null)
                 .filter(AlertRuleVO::isEnabled)
                 .filter(rule -> 
scope.metricKeys().contains(StringUtils.trimWhitespace(rule.getMetric())))
-                .filter(rule -> rule.getInstanceId() == null || 
scope.instanceId().equals(rule.getInstanceId()))
+                .filter(rule -> !StringUtils.hasText(rule.getInstanceId())
+                        || 
scope.instanceId().equals(StringUtils.trimWhitespace(rule.getInstanceId())))
                 .toList();
         if (rules.isEmpty()) {
             return;
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertStateRepositoryTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertStateRepositoryTest.java
index 36dbd7f60..086a40b9b 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertStateRepositoryTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertStateRepositoryTest.java
@@ -126,6 +126,35 @@ class MybatisPlusAlertStateRepositoryTest {
         });
     }
 
+    @Test
+    void findsActiveStatesForRulesWithPaddedStoredInstanceIdsTest() {
+        // NativeAlertProcessor keeps rules whose stored instanceId has 
surrounding whitespace
+        // (legacy rows) by trimming before the scope comparison, exactly like 
the metric filter
+        // above; findActive must apply the same normalization or a genuinely 
firing rule reports
+        // no active state and the stale FIRING/ACKED row is never reconciled.
+        RmqAlertStateMapper mapper = mock(RmqAlertStateMapper.class);
+        RmqAlertState state = new RmqAlertState();
+        state.setRuleId(6L);
+        state.setFingerprint("fingerprint");
+        state.setStatus(AlertStateStatus.FIRING.name());
+        state.setConsecutiveHits(2);
+        state.setCurrentValue(10D);
+        when(mapper.selectList(any())).thenReturn(List.of(state));
+        RmqSystemAlertMapper alertMapper = mock(RmqSystemAlertMapper.class);
+        when(alertMapper.selectList(any())).thenReturn(List.of(
+                alert(6L, "fingerprint", "local", 
"{\"consumerGroup\":\"orders\"}")));
+        MybatisPlusAlertStateRepository repository = new 
MybatisPlusAlertStateRepository(mapper, alertMapper);
+        AlertRuleVO rule = 
AlertRuleVO.builder().id(6L).domain(AlertDomain.BUSINESS).enabled(true)
+                .instanceId(" local ").metric("consumer.lag.total").build();
+
+        List<ActiveAlertState> active = repository.findActive(new 
MetricCollectionScope(AlertDomain.BUSINESS,
+                "local", Set.of("consumer.lag.total")), List.of(rule));
+
+        assertThat(active).singleElement().satisfies(item -> {
+            assertThat(item.key()).isEqualTo(new AlertStateKey(6L, 
"fingerprint"));
+            
assertThat(item.state().status()).isEqualTo(AlertStateStatus.FIRING);
+        });
+    }
     @Test
     void findsActiveStatesWithOneLatestAlertMetadataQueryTest() {
         RmqAlertStateMapper mapper = mock(RmqAlertStateMapper.class);
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NativeAlertProcessorTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NativeAlertProcessorTest.java
index 552a78a34..6124bf166 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NativeAlertProcessorTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NativeAlertProcessorTest.java
@@ -452,6 +452,41 @@ class NativeAlertProcessorTest {
         verify(outbox).enqueue(any(SystemAlertVO.class), eq(rule), 
eq(oldSample.labels()));
     }
 
+    @Test
+    void 
resolvesActiveStateForBlankInstanceIdRuleMissingFromCollectionScopeTest() {
+        AlertService service = mock(AlertService.class);
+        // A blank instance_id is stored verbatim and means "every instance"; 
the repository side
+        // already reads it through StringUtils.hasText, so this filter must 
not fall back to a
+        // null check or such a rule can neither fire nor resolve.
+        AlertRuleVO rule = rule("   ", "orders", 1);
+        
when(service.listRules(AlertDomain.BUSINESS)).thenReturn(List.of(rule));
+        MetricSample oldSample = sample("orders");
+        AlertStateKey oldKey = new AlertStateKey(rule.getId(),
+                AlertFingerprint.of(rule.getId(), oldSample.instanceId(), 
oldSample.labels()));
+        AlertRuleState firing = new AlertRuleState(AlertStateStatus.FIRING, 1, 
20D,
+                oldSample.collectedAt().minusSeconds(60), 
oldSample.collectedAt().minusSeconds(60),
+                oldSample.collectedAt().minusSeconds(60), null);
+        ActiveAlertState active = new ActiveAlertState(oldKey, firing, 
oldSample.instanceId(), oldSample.labels());
+        AlertStateRepository states = mock(AlertStateRepository.class);
+        when(states.findActive(any(MetricCollectionScope.class), 
eq(List.of(rule)))).thenReturn(List.of(active));
+        when(states.save(eq(oldKey), 
any(AlertRuleState.class))).thenReturn(true);
+        AlertRepository alerts = mock(AlertRepository.class);
+        when(alerts.saveAlert(any(SystemAlertVO.class))).thenAnswer(invocation 
-> invocation.getArgument(0));
+        NotificationOutboxService outbox = 
mock(NotificationOutboxService.class);
+
+        NativeAlertProcessor processor = new NativeAlertProcessor(service,
+                new NativeAlertEvaluationService(new AlertRuleEvaluator(), new 
AlertStateMachine(), states,
+                        mock(MetricSnapshotRepository.class), alerts, outbox, 
suppression()),
+                new AlertStateMachine(), states, alerts, outbox, 
suppression(), mockTxManager());
+        processor.processSuccessfulCollection(new 
MetricCollectionScope(AlertDomain.BUSINESS, "local",
+                        java.util.Set.of("consumer.lag.total")), List.of());
+
+        org.mockito.ArgumentCaptor<AlertRuleState> state = 
org.mockito.ArgumentCaptor.forClass(AlertRuleState.class);
+        verify(states).save(eq(oldKey), state.capture());
+        
assertThat(state.getValue().status()).isEqualTo(AlertStateStatus.RESOLVED);
+        verify(outbox).enqueue(any(SystemAlertVO.class), eq(rule), 
eq(oldSample.labels()));
+    }
+
     @Test
     void keepsActiveFingerprintWhenItAppearsInSuccessfulCollectionScopeTest() {
         AlertService service = mock(AlertService.class);
diff --git a/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx 
b/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
index 0bab9a680..11a566d7e 100644
--- a/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
+++ b/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
@@ -497,6 +497,38 @@ describe('SystemAlertsPage', () => {
     });
   });
 
+  it('renders maintenance window boundaries as UTC instants instead of raw API 
strings', async () => {
+    vi.mocked(listAlertSilencesPage).mockResolvedValue({
+      items: [
+        {
+          id: 21,
+          domain: 'CLUSTER',
+          instanceId: 'local',
+          startsAt: '2026-08-10T01:00',
+          endsAt: '2026-08-10T02:00',
+          recurrence: 'WEEKLY',
+          timeZone: 'Asia/Shanghai',
+          recurrenceDays: [1],
+          recurrenceUntil: '2026-10-01T00:00',
+          createdBy: 'admin',
+        },
+      ],
+      total: 1,
+      page: 1,
+      size: 10,
+    });
+    const user = userEvent.setup();
+    renderPage();
+
+    await user.click(await screen.findByRole('button', { name: '维护窗口' }));
+
+    const row = await screen.findByText(/CLUSTER.*local/);
+    expect(row).toHaveTextContent(formatUtcDateTime('2026-08-10T01:00'));
+    expect(row).toHaveTextContent(formatUtcDateTime('2026-08-10T02:00'));
+    expect(row).toHaveTextContent(formatUtcDateTime('2026-10-01T00:00'));
+    expect(row).not.toHaveTextContent('2026-08-10T01:00');
+    expect(row).not.toHaveTextContent('2026-10-01T00:00');
+  });
   it('creates a bounded weekly maintenance schedule in an IANA time zone', 
async () => {
     vi.mocked(listAlertSilencesPage).mockResolvedValue({
       items: [],
diff --git a/web/src/pages/ops/systemAlerts.tsx 
b/web/src/pages/ops/systemAlerts.tsx
index 95b64eb81..b9cdd7c81 100644
--- a/web/src/pages/ops/systemAlerts.tsx
+++ b/web/src/pages/ops/systemAlerts.tsx
@@ -975,8 +975,8 @@ const SystemAlertsPage = () => {
                     </Tag>
                   )}
                   {silence.domain ?? t('common.all')} ·{' '}
-                  {silence.instanceId ?? t('sysAlerts.allInstances')} · 
{silence.startsAt} -{' '}
-                  {silence.endsAt}
+                  {silence.instanceId ?? t('sysAlerts.allInstances')} ·{' '}
+                  {formatUtcDateTime(silence.startsAt)} - 
{formatUtcDateTime(silence.endsAt)}
                   {silence.labels && Object.keys(silence.labels).length > 0
                     ? ` · ${Object.entries(silence.labels)
                         .map(([key, value]) => `${key}=${value}`)
@@ -984,7 +984,9 @@ const SystemAlertsPage = () => {
                     : ''}
                   {silence.recurrence && silence.recurrence !== 'ONCE'
                     ? ` · ${silence.timeZone} · ${t('sysAlerts.repeatsUntil', {
-                        time: silence.recurrenceUntil ?? '',
+                        time: silence.recurrenceUntil
+                          ? formatUtcDateTime(silence.recurrenceUntil)
+                          : '',
                       })}`
                     : ''}
                 </Text>
diff --git a/web/src/utils/alertTemplatePreview.test.ts 
b/web/src/utils/alertTemplatePreview.test.ts
index d6b711f69..fa19aaa3f 100644
--- a/web/src/utils/alertTemplatePreview.test.ts
+++ b/web/src/utils/alertTemplatePreview.test.ts
@@ -24,6 +24,30 @@ import {
 } from './alertTemplatePreview';
 
 describe('alert template preview', () => {
+  it('preserves padded placeholders just as the notification renderer does', 
() => {
+    const preview = previewAlertNotificationTemplate(
+      '${title} | ${ title } | ${title } | ${ title}',
+      { title: 'Broker unavailable' },
+    );
+
+    expect(preview.rendered).toBe('Broker unavailable | ${ title } | ${title } 
| ${ title}');
+    expect(preview.usedVariables).toEqual(['title']);
+    expect(preview.unknownVariables).toHaveLength(3);
+    expect(preview.status).toBe('attention');
+  });
+
+  it('trims a custom notification while retaining its editor length', () => {
+    const template = '  ${title}\nDetails\n  ';
+    const preview = previewAlertNotificationTemplate(template, { title: 
'Alert' });
+
+    expect(preview.rendered).toBe('Alert\nDetails');
+    expect(preview.template).toBe(template);
+    expect(preview.length).toBe(template.length);
+    expect(preview.tokens.map((token) => token.resolved ?? 
token.text).join('')).toBe(
+      preview.rendered,
+    );
+  });
+
   it('renders known variables with stable sample values', () => {
     const preview = previewAlertNotificationTemplate(
       '[${level}] ${ruleName}: ${metric}=${value} > 
${threshold}${thresholdUnit}',
diff --git a/web/src/utils/alertTemplatePreview.ts 
b/web/src/utils/alertTemplatePreview.ts
index d651f9a7d..27a2b63a9 100644
--- a/web/src/utils/alertTemplatePreview.ts
+++ b/web/src/utils/alertTemplatePreview.ts
@@ -177,7 +177,9 @@ const parseTemplate = (
   VARIABLE_PATTERN.lastIndex = 0;
   while ((match = VARIABLE_PATTERN.exec(template)) !== null) {
     const [placeholder, rawVariable] = match;
-    const variable = rawVariable.trim();
+    // Match the server's placeholder spelling exactly; whitespace inside a
+    // placeholder is literal text, not an alias for a documented variable.
+    const variable = rawVariable;
     appendTextToken(tokens, template.slice(cursor, match.index));
 
     if (!knownVariables.has(variable)) {
@@ -246,7 +248,7 @@ export function previewAlertNotificationTemplate(
   const template = normalizeTemplate(templateInput);
   const maxLength = options.maxLength ?? DEFAULT_MAX_LENGTH;
   const values = buildAlertTemplatePreviewContext(context);
-  const parsed = parseTemplate(template, values);
+  const parsed = parseTemplate(template.trim() ? template.trim() : template, 
values);
   const unusedContextVariables = 
collectUnusedContextVariables(parsed.usedVariables, values);
   const issues: AlertTemplatePreviewIssue[] = [];
 

Reply via email to