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[] = [];