You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@rocketmq.apache.org by li...@apache.org on 2026/08/27 09:27:57 UTC

(rocketmq-dashboard) branch rocketmq-studio updated: feat(alert): add native cluster alert collection (#2533)

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 358e6b670 feat(alert): add native cluster alert collection (#2533)
358e6b670 is described below

commit 358e6b670591e88d0652979adc01d48fefe12bc7
Author: aias00 <li...@apache.org>
AuthorDate: Thu Aug 27 17:27:40 2026 +0800

    feat(alert): add native cluster alert collection (#2533)
---
 docs/studio-native-alerting-design.md              | 360 ++++++++
 server/pom.xml                                     |   4 +
 server/scripts/native-alert-e2e.sh                 | 185 ++++
 .../metrics/AlertCollectionLease.java}             |   8 +-
 .../studio/cluster/metrics/AlertingProperties.java |  37 +
 .../metrics/BusinessMetricsCollector.java}         |  13 +-
 .../metrics/ClusterMetricsCollector.java}          |  13 +-
 .../studio/cluster/metrics/CollectorScheduler.java | 180 ++++
 .../cluster/metrics/CollectorStatusController.java |  40 +
 .../metrics/CollectorStatusVO.java}                |   8 +-
 .../metrics/MetricAvailability.java}               |  12 +-
 .../studio/cluster/metrics/MetricSample.java       |  68 ++
 .../metrics/MetricSnapshotRepository.java}         |  13 +-
 .../metrics/MybatisPlusAlertCollectionLease.java   |  76 ++
 .../MybatisPlusMetricSnapshotRepository.java       | 116 +++
 .../ApacheRocketMqBusinessMetricsCollector.java    | 146 +++
 .../ApacheRocketMqClusterMetricsCollector.java     | 170 ++++
 .../ApacheRocketMqDlqMetricsCollector.java         |  76 ++
 .../ApacheRocketMqProxyMetricsCollector.java       | 131 +++
 .../CloudRocketMqBusinessMetricsCollector.java     | 118 +++
 .../CloudRocketMqClusterMetricsCollector.java      |  84 ++
 .../studio/instance/group/ConsumerGroupVO.java     |   4 +
 .../studio/ops/alert/AlertCorrelationScope.java    |  49 +
 .../alert/AlertDomain.java}                        |   9 +-
 .../alert/AlertEvaluationResult.java}              |   9 +-
 .../studio/ops/alert/AlertFingerprint.java         |  44 +
 .../alert/AlertNotificationSuppressionService.java |  65 ++
 .../ops/alert/AlertNotificationTemplate.java       |  73 ++
 .../rocketmq/studio/ops/alert/AlertRepository.java |  13 +-
 .../studio/ops/alert/AlertRuleController.java      |  64 +-
 .../studio/ops/alert/AlertRuleDuration.java        |  65 ++
 .../studio/ops/alert/AlertRuleEvaluator.java       |  64 ++
 .../alert/AlertRuleQuery.java}                     |   8 +-
 .../studio/ops/alert/AlertRuleRequestDTO.java      |  30 +-
 ...{SystemAlertVO.java => AlertRuleRuntimeVO.java} |  24 +-
 .../ops/alert/AlertRuleSemanticFingerprint.java    |  87 ++
 .../{SystemAlertVO.java => AlertRuleState.java}    |  25 +-
 .../alert/AlertRuleTestResultVO.java}              |  15 +-
 .../studio/ops/alert/AlertRuleTransferDTO.java     |  29 +
 .../studio/ops/alert/AlertRuleTransferService.java |  86 ++
 .../rocketmq/studio/ops/alert/AlertRuleVO.java     |  19 +
 .../studio/ops/alert/AlertSchemaMigration.java     | 215 +++++
 .../rocketmq/studio/ops/alert/AlertService.java    | 317 ++++++-
 .../studio/ops/alert/AlertSilenceController.java   |  53 ++
 .../alert/AlertSilenceRepository.java}             |  12 +-
 .../studio/ops/alert/AlertSilenceService.java      | 124 +++
 .../{SystemAlertVO.java => AlertSilenceVO.java}    |  17 +-
 .../alert/AlertStateKey.java}                      |  15 +-
 .../studio/ops/alert/AlertStateMachine.java        |  95 ++
 ...rtRepository.java => AlertStateRepository.java} |  42 +-
 .../alert/AlertStateStatus.java}                   |  12 +-
 .../alert/AlertStateTransition.java}               |  12 +-
 .../alert/AlertStateUpdate.java}                   |   7 +-
 ...roller.java => ClusterAlertRuleController.java} |  68 +-
 ...stemAlertVO.java => CreateAlertSilenceDTO.java} |  29 +-
 .../ops/alert/MybatisPlusAlertRepository.java      | 177 +++-
 .../alert/MybatisPlusAlertSilenceRepository.java   |  96 ++
 .../ops/alert/MybatisPlusAlertStateRepository.java | 127 +++
 .../alert/NativeAlertMetricCatalogController.java  |  29 +
 .../ops/alert/NativeAlertMetricCatalogService.java |  81 ++
 .../studio/ops/alert/NativeAlertMetricInfo.java    |  12 +
 .../studio/ops/alert/NativeAlertProcessor.java     | 146 +++
 .../studio/ops/alert/NativeAlertRulePolicy.java    |  91 ++
 .../ops/alert/NativeAlertRuleScopeMatcher.java     |  45 +
 .../ops/alert/NativeAlertRuleTestService.java      |  64 ++
 .../NotificationDeliveryBulkRetryResult.java}      |  13 +-
 .../ops/alert/NotificationDeliveryPageVO.java      |  32 +
 .../studio/ops/alert/NotificationDeliveryVO.java   |  23 +
 .../ops/alert/NotificationOutboxService.java       | 438 +++++++++
 .../studio/ops/alert/NotificationOutboxStatus.java |  11 +
 .../studio/ops/alert/SystemAlertController.java    |  53 +-
 .../{SystemAlertVO.java => SystemAlertQuery.java}  |  25 +-
 .../rocketmq/studio/ops/alert/SystemAlertVO.java   |  14 +
 .../persistence/MybatisPlusSettingsRepository.java |   4 +
 ...stemAlert.java => RmqAlertCollectionLease.java} |  21 +-
 .../entity/RmqAlertNotificationOutbox.java         |  31 +
 .../studio/persistence/entity/RmqAlertRule.java    |  18 +
 .../{RmqSystemAlert.java => RmqAlertSilence.java}  |  27 +-
 .../{RmqSystemAlert.java => RmqAlertState.java}    |  28 +-
 ...{RmqSystemAlert.java => RmqMetricSnapshot.java} |  27 +-
 .../studio/persistence/entity/RmqSystemAlert.java  |  26 +-
 ...per.java => RmqAlertCollectionLeaseMapper.java} |  13 +-
 .../mapper/RmqAlertNotificationOutboxMapper.java   |  67 ++
 .../persistence/mapper/RmqAlertRuleMapper.java     |   5 +
 ...tRuleMapper.java => RmqAlertSilenceMapper.java} |   4 +-
 .../persistence/mapper/RmqAlertStateMapper.java    |  40 +
 ...uleMapper.java => RmqMetricSnapshotMapper.java} |   4 +-
 .../provider/apache/RocketMQAdminClientImpl.java   |   7 +-
 .../provider/apache/RocketMQMetadataProvider.java  |   7 +-
 .../studio/settings/GeneralSettingsUpdateDTO.java  |   5 +
 .../studio/settings/GeneralSettingsVO.java         |  11 +
 .../studio/settings/SettingsController.java        |   8 +
 .../rocketmq/studio/settings/SettingsService.java  |   6 +
 server/src/main/resources/application.yml          |  15 +
 server/src/main/resources/db/schema.sql            | 116 ++-
 .../cluster/metrics/CollectorSchedulerTest.java    | 154 ++++
 .../metrics/CollectorStatusControllerTest.java}    |  33 +-
 .../studio/cluster/metrics/MetricSampleTest.java   |  43 +
 ...ApacheRocketMqBusinessMetricsCollectorTest.java | 145 +++
 .../ApacheRocketMqClusterMetricsCollectorTest.java | 106 +++
 .../ApacheRocketMqDlqMetricsCollectorTest.java     |  90 ++
 .../ApacheRocketMqProxyMetricsCollectorTest.java   |  99 ++
 .../CloudRocketMqBusinessMetricsCollectorTest.java |  70 ++
 .../CloudRocketMqClusterMetricsCollectorTest.java  |  79 ++
 .../studio/ops/alert/AlertFingerprintTest.java}    |  36 +-
 .../AlertNotificationSuppressionServiceTest.java   |  95 ++
 .../ops/alert/AlertNotificationTemplateTest.java   |  43 +
 .../studio/ops/alert/AlertRuleControllerTest.java  | 125 ++-
 .../studio/ops/alert/AlertRuleDurationTest.java    |  21 +
 .../studio/ops/alert/AlertRuleEvaluatorTest.java   |  91 ++
 .../studio/ops/alert/AlertRuleRequestDTOTest.java  |   7 +-
 .../alert/AlertRuleSemanticFingerprintTest.java    |  80 ++
 .../ops/alert/AlertRuleTransferServiceTest.java    | 138 +++
 .../studio/ops/alert/AlertSchemaMigrationTest.java |  61 ++
 .../ops/alert/AlertServiceDefaultRulesTest.java    |  12 +-
 .../studio/ops/alert/AlertServiceTest.java         | 407 +++++++--
 .../studio/ops/alert/AlertSilenceServiceTest.java  | 119 +++
 .../studio/ops/alert/AlertStateMachineTest.java    | 113 +++
 .../ops/alert/ClusterAlertRuleControllerTest.java  | 156 ++++
 .../ops/alert/MybatisPlusAlertRepositoryTest.java  | 134 ++-
 .../alert/MybatisPlusAlertStateRepositoryTest.java |  53 ++
 .../alert/NativeAlertMetricCatalogServiceTest.java |  44 +
 .../studio/ops/alert/NativeAlertProcessorTest.java | 326 +++++++
 .../ops/alert/NativeAlertRulePolicyTest.java       |  99 ++
 .../ops/alert/NativeAlertRuleScopeMatcherTest.java |  65 ++
 .../ops/alert/NativeAlertRuleTestServiceTest.java  |  89 ++
 .../ops/alert/NotificationOutboxServiceTest.java   | 397 +++++++++
 .../ops/alert/SystemAlertControllerTest.java       | 104 ++-
 .../studio/settings/SettingsControllerTest.java    |  46 +-
 .../studio/settings/SettingsServiceTest.java       |  77 +-
 web/src/App.tsx                                    |  13 +-
 web/src/api/alertRules.test.ts                     |  10 +-
 web/src/api/client.ts                              |   2 +-
 web/src/api/ops.test.ts                            | 149 +++-
 web/src/api/ops.ts                                 | 299 ++++++-
 web/src/api/settings.ts                            |  15 +-
 web/src/components/PageHeader.tsx                  |  11 +-
 web/src/i18n/translations.ts                       | 446 +++++++++-
 web/src/layouts/MainLayout.tsx                     |  30 +-
 .../pages/instance/__tests__/MessagePage.test.tsx  | 243 +----
 web/src/pages/ops/__tests__/AlertsPage.test.tsx    | 398 ++++++++-
 .../__tests__/NotificationDeliveriesPage.test.tsx  |  76 ++
 .../pages/ops/__tests__/SystemAlertsPage.test.tsx  | 354 +++++++-
 web/src/pages/ops/alerts.tsx                       | 991 +++++++++++++++++----
 web/src/pages/ops/notificationDeliveries.tsx       | 364 ++++++++
 web/src/pages/ops/systemAlerts.tsx                 | 898 ++++++++++++++++---
 web/src/pages/settings/GeneralSettingsTab.tsx      |  78 +-
 .../settings/__tests__/GeneralSettingsTab.test.tsx |  27 +
 web/src/services/opsService.test.ts                |  22 +
 web/src/services/opsService.ts                     | 272 +++++-
 web/src/utils/format.test.ts                       |   9 +
 web/src/utils/format.ts                            |  41 +
 152 files changed, 12868 insertions(+), 1262 deletions(-)

diff --git a/docs/studio-native-alerting-design.md b/docs/studio-native-alerting-design.md
new file mode 100644
index 000000000..92f826b18
--- /dev/null
+++ b/docs/studio-native-alerting-design.md
@@ -0,0 +1,360 @@
+# Studio Native Alerting Design
+
+## Status
+
+The native alerting execution path is implemented. Native Apache collection (including NameServer, Broker, and Proxy availability), snapshots, scheduling, evaluation, event lifecycle,
+acknowledgement, multi-replica collection lease, rule test-runs, label-scoped silence management, and
+the DingTalk, SMS webhook, and SMTP email outbox delivery channels are in place. Provider capability catalogs are available for Apache,
+Aliyun, and Tencent; cloud consumer-lag and managed-instance lifecycle collection are available. This design does
+not require Prometheus or Alertmanager for rule evaluation or event creation.
+
+### Implemented and Deferred Scope
+
+| Area | Current implementation | Explicitly deferred |
+| --- | --- | --- |
+| Notification routing | Per-rule channel selection for DingTalk, configured SMS webhook, and SMTP email. Delivery uses the durable outbox. | Independent channel and notification-policy CRUD, generic per-rule Webhook endpoints. |
+| Notification frequency | `FIRING`, periodic `REMINDER` (per rule `reminderInterval`), and `RESOLVED` deliveries; retries have bounded exponential backoff. | A separate `cooldownSeconds` policy. |
+| Collector health | Per-instance bounded collection, timeout, database lease, and delivery audit history. | Producer failure-rate, thread-pool rejection, and cloud Broker-level health metrics. |
+| Validation | Unit and controller tests plus `server/scripts/native-alert-e2e.sh` for a real RocketMQ, MySQL, SMTP, and webhook receiver environment. | A CI-owned shared RocketMQ/SMTP/webhook environment; production SLO dashboards for collector or delivery failure rate. |
+
+## Goals
+
+- Keep the current three user-facing menus: Business Alerts, Cluster Alerts, and Alert Events.
+- Collect RocketMQ operational and business metrics directly from Studio instance providers.
+- Reuse one rule evaluator, event lifecycle, silence model, notification pipeline, authorization model, and audit trail.
+- Support Apache RocketMQ first, with explicit capability discovery for Proxy and cloud instances.
+- Keep Prometheus rule YAML export as optional interoperability, not as the execution path.
+
+## Non-Goals
+
+- Replace a dedicated time-series database for long-term metric retention or arbitrary analytics.
+- Implement general PromQL or a generic monitoring DSL.
+- Assume every provider can expose every metric.
+- Automatically enable notifications when upgrading existing installations.
+
+## Product Model
+
+The UI remains split by audience and resource type while all alerts share one event stream.
+
+| Menu | Route | Rule domain | Typical resources |
+| --- | --- | --- | --- |
+| Business Alerts | `/ops/business-alerts` | `BUSINESS` | Topic, Consumer Group, queue, DLQ, producer flow |
+| Cluster Alerts | `/ops/cluster-alerts` | `CLUSTER` | NameServer, Broker, Proxy, instance connectivity |
+| Alert Events | `/ops/system-alerts` | Both | Active, recovered, acknowledged alert events |
+
+Business Alerts own message-flow risk: consumer lag, consumption delay, DLQ growth, and topic backlog. Cluster Alerts own runtime health: NameServer or Broker availability, disk pressure, JVM pressure, Broker send-queue pressure, Proxy availability, and collector connectivity. Producer failure rate and thread-pool rejection are planned metrics, not current capabilities.
+
+Alert Events are not a third rule domain. They are the shared lifecycle record emitted by either rule domain.
+
+## Architecture
+
+```text
+Apache Admin / Proxy API / Cloud API
+                |
+                v
+ClusterMetricsCollector      BusinessMetricsCollector
+                |                     |
+                +----- MetricSample --+
+                              |
+                              v
+                    AlertRuleEvaluator
+                              |
+                              v
+                     AlertEventService
+                       |            |
+                       v            v
+                 Event history   NotificationOutbox
+                                      |
+                                      v
+                     DingTalk / SMS webhook / Email
+```
+
+The collectors know only how to obtain metrics. The evaluator knows only typed metric samples and structured rules. Notification senders know only event payloads and channel configuration.
+
+### Package Boundaries
+
+```text
+server/.../cluster/metrics/
+  ClusterMetricsCollector
+  BusinessMetricsCollector
+  CollectorScheduler
+  MetricCatalogService
+  MetricSnapshotRepository
+  collectors/
+    ApacheRocketMqClusterMetricsCollector
+    ApacheRocketMqBusinessMetricsCollector
+    ProxyMetricsCollector
+    AliyunMetricsCollector
+    TencentMetricsCollector
+
+server/.../ops/alert/
+  AlertRuleService
+  AlertRuleEvaluator
+  AlertStateMachine
+  AlertEventService
+  AlertSilenceService
+  AlertNotificationService
+  NotificationOutboxWorker
+  notification/
+    WebhookNotificationSender
+    DingTalkNotificationSender
+    EmailNotificationSender
+```
+
+`ClusterMetricsCollector` is a neutral collection name. It does not imply that every sample is a cluster-health sample; it is the Studio component responsible for collecting metrics from a managed cluster instance. `BusinessMetricsCollector` is separate because its target resources and collection costs differ.
+
+## Metric Contract
+
+Every collector produces the same typed sample:
+
+```java
+record MetricSample(
+    String metricKey,
+    AlertDomain domain,
+    String instanceId,
+    String clusterId,
+    Map<String, String> labels,
+    Double value,
+    MetricAvailability availability,
+    Instant collectedAt
+) {}
+```
+
+`MetricAvailability` is one of `AVAILABLE`, `UNAVAILABLE`, `UNSUPPORTED`, or `STALE`.
+
+- `UNAVAILABLE` means a collection attempt failed or timed out. It must never be converted to zero.
+- `UNSUPPORTED` means the selected provider cannot expose this metric.
+- `STALE` means the last sample is older than the configured freshness limit.
+- Only an explicit availability rule, such as Broker unavailable, may alert on a non-`AVAILABLE` value.
+
+### Initial Metric Catalog
+
+| Domain | Metric key | Scope | Source |
+| --- | --- | --- | --- |
+| Cluster | `broker.availability` | Broker | Admin client route/runtime query |
+| Cluster | `nameserver.availability` | NameServer | NameServer connection check |
+| Cluster | `broker.disk.usage_ratio` | Broker | Broker runtime/config data |
+| Cluster | `broker.jvm.heap.usage_ratio` | Broker | Broker runtime data |
+| Cluster | `broker.send_queue.usage_ratio` | Broker | Broker runtime send queue size/capacity |
+| Cluster | `proxy.availability` | Proxy | TCP probe of the gRPC listener discovered for the selected instance |
+| Cluster | `cloud.instance.availability` | Managed cloud instance | Aliyun/Tencent control-plane instance status |
+| Business | `consumer.lag.total` | Consumer Group / Topic | Consumer progress (Apache, Aliyun, Tencent) |
+| Business | `consumer.lag.max_queue` | Consumer Group / Queue | Consumer progress (Apache, Aliyun, Tencent) |
+| Business | `consumer.delay.seconds` | Consumer Group | Apache broker consume stats; emitted only when a consumption timestamp is available |
+| Business | `topic.backlog.total` | Topic / Consumer Group | Topic and consumer offsets; each sample is scoped to one Topic and consumer group to avoid cross-group double counting |
+| Business | `dlq.message.count` | Consumer Group / DLQ | DLQ provider |
+
+The metric catalog is capability-aware. The rule editor must query the selected instance capability and hide unsupported metrics instead of allowing rules that can never run.
+
+## Rule Model
+
+Rules are structured. Studio does not parse or execute free-form PromQL.
+
+```text
+StudioAlertRule
+  id
+  domain: BUSINESS | CLUSTER
+  name
+  enabled
+  scopeType: INSTANCE | CLUSTER | BROKER | PROXY | TOPIC | CONSUMER_GROUP | QUEUE | DLQ
+  metricKey
+  selectorJson
+  aggregation: LAST | MAX | MIN | AVG | SUM
+  windowSeconds
+  operator: GT | GTE | LT | LTE | EQ | NE | UNAVAILABLE
+  threshold
+  consecutiveSamples
+  severity: INFO | WARNING | CRITICAL
+```
+
+`cooldownSeconds` and `notificationPolicyId` are future rule-model fields. Current notification routing uses the enabled channels configured in General Settings.
+
+`selectorJson` contains the resource boundary, for example `instanceId`, `clusterName`, `brokerName`, `topic`, `consumerGroup`, and `queueId`. A rule must always be constrained to an instance. Wildcards are only valid for resource labels beneath that instance.
+
+### Example Rules
+
+```text
+Business: order-consumer lag high
+metricKey=consumer.lag.total
+selector={instanceId=local, consumerGroup=order-consumer}
+aggregation=MAX, windowSeconds=300, operator=GT, threshold=100000
+consecutiveSamples=3, severity=WARNING
+
+Cluster: broker disk pressure
+metricKey=broker.disk.usage_ratio
+selector={instanceId=local, brokerName=broker-a}
+aggregation=LAST, operator=GTE, threshold=0.85
+consecutiveSamples=2, severity=CRITICAL
+```
+
+## Evaluation and Event Lifecycle
+
+The evaluator runs after a collector writes a new sample. It computes an aggregation over the bounded local snapshot window, then advances an alert state keyed by a stable fingerprint.
+
+```text
+OK -- condition met --> PENDING -- consecutive samples met --> FIRING
+FIRING -- condition clear --> RESOLVED
+FIRING -- user acknowledges --> ACKED
+PENDING/FIRING -- silence matches --> state unchanged, notification suppressed
+```
+
+The fingerprint is `sha256(ruleId + instanceId + sorted(labels))`. A single rule therefore creates independent events for different Brokers, Topics, queues, or consumer groups.
+
+Current delivery emits on `FIRING`, periodic `REMINDER` transitions controlled by the rule's `reminderInterval`, and `RESOLVED`. A separate `cooldownSeconds` policy remains future work. A value recovery always emits a `RESOLVED` event.
+
+## Persistence
+
+The existing `rmq_alert_rule` and `rmq_system_alert` tables can be evolved, but the event table must represent a lifecycle rather than a standalone local message.
+
+```text
+rmq_alert_rule
+  domain, scope_type, metric_key, selector_json,
+  aggregation, window_seconds, consecutive_samples,
+  operator, threshold, severity
+
+rmq_metric_snapshot
+  instance_id, metric_key, labels_hash, labels_json,
+  value, availability, collected_at
+
+rmq_alert_state
+  rule_id, fingerprint, status, consecutive_hits,
+  current_value, labels_json, first_pending_at, fired_at, resolved_at, version
+
+rmq_system_alert
+  rule_id, fingerprint, transition, domain, severity,
+  title, description, current_value, threshold, labels_json,
+  occurred_at, acknowledged_by, acknowledged_at
+
+rmq_alert_silence
+  selector_json, starts_at, ends_at, created_by, reason
+
+rmq_notification_outbox
+  event_id, channel, state, attempt_count,
+  next_attempt_at, last_error, delivered_at
+```
+
+Snapshots have short retention, initially 24 hours. Studio is not a replacement for a time-series database.
+
+## Collection and Scheduling
+
+`CollectorScheduler` creates per-instance jobs at a default 30-second interval. Each job has a bounded timeout and the scheduler uses bounded concurrency, so one slow remote instance does not indefinitely delay every other instance. Collection failure records an unavailable sample and health diagnostic; it does not block other instances.
+
+For multi-replica Studio deployment, the scheduler uses a database lease:
+
+```text
+rmq_metric_collector_lease
+  collector_name, owner_id, expires_at
+```
+
+Only the active lease holder collects and evaluates. Notification outbox rows use claimant-bound state transitions so a stale worker cannot overwrite a newer claimant. Delivery is at-least-once: receivers should use the event and channel identity to deduplicate a request that times out after reaching the remote service.
+
+## Notifications and Silences
+
+The supported notification channels are DingTalk, the SMS webhook configured in General Settings, and Email. A real event creates outbox rows for enabled supported channels. Independent notification-channel and notification-policy CRUD are future work.
+
+Email delivery uses Spring's standard SMTP configuration. Configure `STUDIO_ALERTING_SMTP_HOST`,
+`STUDIO_ALERTING_SMTP_PORT`, `STUDIO_ALERTING_SMTP_USERNAME`, `STUDIO_ALERTING_SMTP_PASSWORD`,
+`STUDIO_ALERTING_SMTP_AUTH`, and `STUDIO_ALERTING_SMTP_STARTTLS`; recipient addresses are configured in
+General Settings. Missing SMTP configuration leaves the outbox row retryable and records the delivery failure.
+
+```text
+PENDING -> SENDING -> DELIVERED
+                  -> RETRY_WAIT -> FAILED
+```
+
+Retries use bounded exponential backoff. Channel configuration is encrypted at rest and only write-only secrets are returned by APIs. A test-send action uses the same sender implementation but does not create an alert event.
+
+Silences match `domain`, rule ID, instance ID, and optional resource labels. They suppress delivery but do not hide active state from the Alert Events page.
+
+## APIs
+
+The two rule menus keep separate endpoints so their domain cannot be accidentally changed by a client. Both delegate to the same `AlertRuleService`.
+
+```text
+GET/POST/PUT /api/business-alert-rules
+GET/POST/PUT /api/cluster-alert-rules
+POST         /api/business-alert-rules/{id}/test
+POST         /api/cluster-alert-rules/{id}/test
+
+GET          /api/system-alerts?domain=&status=&severity=&instanceId=&page=
+POST         /api/system-alerts/{id}/acknowledge
+
+GET/POST     /api/alert-silences
+DELETE       /api/alert-silences/{id}
+GET          /api/alert-collector-status
+GET          /api/alert-metric-catalog?instanceId=&domain=
+```
+
+Existing `/api/alert-rules/export` remains as a compatibility endpoint for users who deliberately export rules to Prometheus. It must not be used by the native evaluator.
+
+## Authorization and Audit
+
+- Readers can list rules, events, collector status, and notification delivery results with secrets redacted.
+- Administrators can mutate rules, silences, and acknowledgements. Independent channel and policy administration is future work.
+- Every mutation and every notification result is written to operation audit history.
+- Event acknowledgement captures the user and timestamp.
+
+## UI Behavior
+
+Business Alerts and Cluster Alerts use separate forms and metric catalogs. The Alert Events page combines both sources and exposes filters for domain, status, severity, instance, resource labels, and time range.
+
+An event row displays its domain badge, current value, threshold, resource identity, state transition timeline, acknowledgement, silence state, and notification delivery results.
+
+## Migration
+
+1. Add new nullable fields and backfill existing rule records to `BUSINESS` or `CLUSTER` where the metric is unambiguous.
+2. Retain rules that cannot be structurally mapped as export-only Prometheus compatibility rules.
+3. Migrate existing `rmq_system_alert` rows into `rmq_alert_event` as historical events without inventing a firing state.
+4. Do not automatically enable notification channels after migration.
+5. Keep legacy read APIs for one release cycle, then redirect callers to the domain-specific rule APIs and unified event API.
+
+## Delivery Plan
+
+1. Introduce the metric contract, catalog, Apache collectors, snapshots, and single-node scheduler.
+2. Implement rule evaluation, state transitions, Alert Events lifecycle, acknowledgement, and three initial rules: Broker unavailable, Broker disk pressure, Consumer lag.
+3. Implement DingTalk, SMS webhook, and Email through the outbox worker, plus delivery history and rule-level `reminderInterval`. Generic per-rule Webhook delivery and a separate cooldown policy are future work.
+4. Add silences, multi-replica lease handling, rule test-run, and capability-aware forms. These are complete,
+   including label-scoped silences and instance-specific metric capability forms.
+5. Add Proxy and cloud collectors, additional business rules, and optional Prometheus YAML export compatibility.
+   Proxy availability, Apache consumer delay, Aliyun/Tencent consumer-lag and topic-backlog, and managed cloud-instance lifecycle collection are complete;
+   broker-level cloud health metrics remain future work. The existing Prometheus YAML export is compatibility-only; richer mapping remains future work.
+
+## Real Environment Verification
+
+`server/scripts/native-alert-e2e.sh` verifies the native lifecycle against an isolated, already-initialized MySQL database and a real RocketMQ NameServer. It creates a temporary unavailable instance, applies a label-free silence, verifies that the `FIRING` event is persisted while both notification rows remain pending, waits for the silence to expire, then changes the same instance to a reachable NameServer. The script requires the following evidence before succeeding:
+
+1. `FIRING` and `RESOLVED` events for the same rule and instance.
+2. Delivered Email and SMS-webhook outbox rows for both lifecycle transitions.
+3. A webhook capture response containing both `FIRING` and `RESOLVED`.
+
+The Email assertions stop at the durable Outbox's `DELIVERED` state, which confirms successful handoff to the configured SMTP endpoint. The script intentionally does not require a Mailpit-specific message-inspection API or assert final mailbox storage.
+
+The script intentionally requires an isolated MySQL database with `rmq_studio_user` and `rmq_settings` already initialized, plus an administrator account supplied through environment variables. It never uses an operator's development database or deletes any existing records. Its required environment, receiver contract, and invocation guidance are documented in the script header.
+
+For example, after building the server jar, run it against a dedicated validation database:
+
+```bash
+export E2E_DB_JDBC_URL='jdbc:mysql://127.0.0.1:3306/rocketmq_alert_e2e?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC'
+export E2E_MYSQL_DATABASE=rocketmq_alert_e2e
+export E2E_ADMIN_USERNAME=e2e-admin
+export E2E_ADMIN_PASSWORD='***'
+export E2E_NAMESRV_ADDR=127.0.0.1:9876
+export E2E_SMTP_HOST=127.0.0.1
+export E2E_EMAIL_RECIPIENT=native-alert-e2e@example.test
+# Use a routable private address, not 127.0.0.1: notification webhooks reject loopback
+# targets to prevent SSRF. Replace this address with the host address of the receiver.
+export E2E_WEBHOOK_URL='http://192.168.1.10:18090/alerts'
+export E2E_WEBHOOK_ASSERT_URL='http://192.168.1.10:18090/received'
+server/scripts/native-alert-e2e.sh
+```
+
+The webhook receiver must accept the configured `POST` endpoint and expose captured request bodies from the assertion URL. This keeps the production delivery client unchanged while allowing the script to verify both lifecycle payloads.
+
+## Acceptance Criteria
+
+- Studio can fire and recover a Broker unavailable or Consumer lag alert with no Prometheus service configured.
+- A rule never treats failed collection as zero.
+- A business rule and a cluster rule share the same event, acknowledgement, silence, notification, and audit pipeline.
+- The three existing menus remain distinct and usable.
+- Notifications are claimant-safe, retryable, auditable, and do not expose secrets; external delivery is at-least-once.
+- Unsupported provider metrics cannot be selected in the corresponding rule editor.
diff --git a/server/pom.xml b/server/pom.xml
index 622dcea09..10855c3cc 100644
--- a/server/pom.xml
+++ b/server/pom.xml
@@ -27,6 +27,10 @@
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-web</artifactId>
         </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-mail</artifactId>
+        </dependency>
         <dependency>
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-validation</artifactId>
diff --git a/server/scripts/native-alert-e2e.sh b/server/scripts/native-alert-e2e.sh
new file mode 100755
index 000000000..3bc6bb150
--- /dev/null
+++ b/server/scripts/native-alert-e2e.sh
@@ -0,0 +1,185 @@
+#!/usr/bin/env bash
+#
+# Validates native alerting against real dependencies. It requires an isolated, already
+# initialized MySQL database, a RocketMQ NameServer, an SMTP endpoint, and a webhook receiver
+# whose assertion endpoint returns captured JSON payloads.
+#
+# Required environment:
+#   E2E_DB_JDBC_URL       JDBC URL for an isolated initialized MySQL database
+#   E2E_MYSQL_DATABASE    Database name in E2E_DB_JDBC_URL
+#   E2E_ADMIN_USERNAME    Studio administrator already present in that database
+#   E2E_ADMIN_PASSWORD    Password for E2E_ADMIN_USERNAME
+#   E2E_NAMESRV_ADDR      Reachable NameServer address, for example 127.0.0.1:9876
+#   E2E_WEBHOOK_URL       Non-loopback receiver URL saved as the SMS webhook setting
+#   E2E_WEBHOOK_ASSERT_URL GET endpoint returning captured webhook payloads
+#   E2E_EMAIL_RECIPIENT   Recipient accepted by the SMTP sink
+#   E2E_SMTP_HOST         SMTP host
+#
+# Optional environment:
+#   E2E_DB_USERNAME=root E2E_DB_PASSWORD=studio123 E2E_MYSQL_HOST=127.0.0.1
+#   E2E_MYSQL_PORT=3306 E2E_SMTP_PORT=1025 E2E_PORT=18083 E2E_SILENCE_SECONDS=10
+#   E2E_STUDIO_JAR=.../server/target/rocketmq-studio-1.0.0.jar
+#
+# The script retains its e2e-native-alert-* records as database evidence. It does not
+# use an operator's development database and it stops the temporary Studio process.
+
+set -euo pipefail
+
+required=(E2E_DB_JDBC_URL E2E_MYSQL_DATABASE E2E_ADMIN_USERNAME E2E_ADMIN_PASSWORD \
+  E2E_NAMESRV_ADDR E2E_WEBHOOK_URL E2E_WEBHOOK_ASSERT_URL E2E_EMAIL_RECIPIENT E2E_SMTP_HOST)
+for name in "${required[@]}"; do
+  [[ -n "${!name:-}" ]] || { echo "Missing required environment variable: $name" >&2; exit 2; }
+done
+for command in curl jq mysql; do
+  command -v "$command" >/dev/null || { echo "Required command is unavailable: $command" >&2; exit 2; }
+done
+
+SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
+SERVER_DIR=$(cd "$SCRIPT_DIR/.." && pwd)
+JAVA_BIN="${JAVA_HOME:+$JAVA_HOME/bin/}java"
+STUDIO_JAR=${E2E_STUDIO_JAR:-"$SERVER_DIR/target/rocketmq-studio-1.0.0.jar"}
+PORT=${E2E_PORT:-18083}
+SMTP_PORT=${E2E_SMTP_PORT:-1025}
+SILENCE_SECONDS=${E2E_SILENCE_SECONDS:-10}
+MYSQL_HOST=${E2E_MYSQL_HOST:-127.0.0.1}
+MYSQL_PORT=${E2E_MYSQL_PORT:-3306}
+DB_USERNAME=${E2E_DB_USERNAME:-root}
+DB_PASSWORD=${E2E_DB_PASSWORD:-studio123}
+RUN_ID="e2e-native-alert-$(date -u +%Y%m%d%H%M%S)-$$"
+INSTANCE_ID="$RUN_ID-instance"
+RULE_NAME="$RUN_ID-rule"
+COOKIE=$(mktemp "${TMPDIR:-/tmp}/rocketmq-studio-e2e.cookie.XXXXXX")
+RUN_DIR=$(mktemp -d "${TMPDIR:-/tmp}/rocketmq-studio-e2e.XXXXXX")
+APP_LOG="$RUN_DIR/studio.log"
+APP_PID=
+
+[[ "$SILENCE_SECONDS" =~ ^[0-9]+$ && "$SILENCE_SECONDS" -ge 5 ]] \
+  || { echo 'E2E_SILENCE_SECONDS must be an integer of at least 5' >&2; exit 2; }
+
+cleanup() {
+  if [[ -n "$APP_PID" ]]; then
+    kill "$APP_PID" 2>/dev/null || true
+    wait "$APP_PID" 2>/dev/null || true
+  fi
+}
+trap cleanup EXIT
+
+[[ -f "$STUDIO_JAR" ]] || { echo "Studio jar not found: $STUDIO_JAR" >&2; exit 2; }
+
+mysql_query() {
+  mysql -N -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$DB_USERNAME" -p"$DB_PASSWORD" \
+    "$E2E_MYSQL_DATABASE" -e "$1" | tr -d '[:space:]'
+}
+
+wait_for() {
+  local description=$1
+  local attempts=$2
+  local predicate=$3
+  for _ in $(seq 1 "$attempts"); do
+    "$predicate" && return 0
+    sleep 2
+  done
+  echo "Timed out waiting for $description" >&2
+  return 1
+}
+
+utc_after_seconds() {
+  local seconds=$1
+  if date -u -d "+${seconds} seconds" +%Y-%m-%dT%H:%M:%SZ >/dev/null 2>&1; then
+    date -u -d "+${seconds} seconds" +%Y-%m-%dT%H:%M:%SZ
+  else
+    date -u -v+"${seconds}"S +%Y-%m-%dT%H:%M:%SZ
+  fi
+}
+
+api_post() {
+  curl -fsS -b "$COOKIE" -H 'Content-Type: application/json' -d "$2" \
+    "http://127.0.0.1:${PORT}$1"
+}
+
+studio_started() {
+  [[ $(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${PORT}/api/auth/status") == 200 ]]
+}
+
+firing_exists() {
+  [[ $(mysql_query "SELECT COUNT(*) FROM rmq_system_alert WHERE instance_id='${INSTANCE_ID}' AND transition='FIRING'") -ge 1 ]]
+}
+
+silenced_rows_pending() {
+  [[ $(mysql_query "SELECT COUNT(*) FROM rmq_alert_notification_outbox WHERE alert_id IN (SELECT id FROM rmq_system_alert WHERE instance_id='${INSTANCE_ID}' AND transition='FIRING') AND status='PENDING'") -eq 2 ]]
+}
+
+firing_rows_delivered() {
+  [[ $(mysql_query "SELECT COUNT(*) FROM rmq_alert_notification_outbox WHERE alert_id IN (SELECT id FROM rmq_system_alert WHERE instance_id='${INSTANCE_ID}' AND transition='FIRING') AND status='DELIVERED'") -eq 2 ]]
+}
+
+resolved_exists() {
+  [[ $(mysql_query "SELECT COUNT(*) FROM rmq_system_alert WHERE instance_id='${INSTANCE_ID}' AND transition='RESOLVED'") -ge 1 ]]
+}
+
+resolved_rows_delivered() {
+  [[ $(mysql_query "SELECT COUNT(*) FROM rmq_alert_notification_outbox WHERE alert_id IN (SELECT id FROM rmq_system_alert WHERE instance_id='${INSTANCE_ID}' AND transition='RESOLVED') AND status='DELIVERED'") -eq 2 ]]
+}
+
+echo "Starting Studio from $STUDIO_JAR; logs: $APP_LOG"
+(
+  cd "$RUN_DIR"
+  SPRING_PROFILES_ACTIVE=default \
+  SPRING_DATASOURCE_URL="$E2E_DB_JDBC_URL" \
+  SPRING_DATASOURCE_USERNAME="$DB_USERNAME" \
+  SPRING_DATASOURCE_PASSWORD="$DB_PASSWORD" \
+  STUDIO_AUTH_SESSION_COOKIE_SECURE=false \
+  STUDIO_ALERTING_COLLECTION_INTERVAL=PT2S \
+  STUDIO_ALERTING_COLLECTION_TIMEOUT=PT1S \
+  STUDIO_ALERTING_COLLECTION_PARALLELISM=4 \
+  STUDIO_ALERTING_NOTIFICATION_DISPATCH_INTERVAL=PT1S \
+  STUDIO_ALERTING_SMTP_HOST="$E2E_SMTP_HOST" \
+  STUDIO_ALERTING_SMTP_PORT="$SMTP_PORT" \
+  STUDIO_ALERTING_SMTP_AUTH=false \
+  STUDIO_ALERTING_SMTP_STARTTLS=false \
+  exec "$JAVA_BIN" -jar "$STUDIO_JAR" --server.port="$PORT" >"$APP_LOG" 2>&1
+) &
+APP_PID=$!
+
+wait_for "Studio startup" 45 studio_started
+curl -fsS -c "$COOKIE" -H 'Content-Type: application/json' \
+  -d "$(jq -n --arg username "$E2E_ADMIN_USERNAME" --arg password "$E2E_ADMIN_PASSWORD" '{username:$username,password:$password}')" \
+  "http://127.0.0.1:${PORT}/api/auth/login" >/dev/null
+
+# Configure both real delivery paths through the public settings API. LLM is outside this
+# scenario, so leave its base URL blank rather than depending on external DNS/network access.
+api_post /api/settings/general/save "$(jq -n --arg webhook "$E2E_WEBHOOK_URL" --arg recipient "$E2E_EMAIL_RECIPIENT" \
+  '{theme:"light",compact:false,desktopNotify:false,notifySound:false,sessionTimeout:30,requireLogin:true,
+    llmProvider:"openai",model:"gpt-4o-mini",baseUrl:"",emailRecipients:$recipient,smsWebhook:$webhook}')" >/dev/null
+api_post /api/instances/create "$(jq -n --arg name "$INSTANCE_ID" \
+  '{name:$name,type:"DIRECT",endpoint:"127.0.0.1:19879",vendor:"APACHE"}')" >/dev/null
+RULE=$(api_post /api/cluster-alert-rules/create "$(jq -n --arg name "$RULE_NAME" --arg instance "$INSTANCE_ID" \
+  '{name:$name,metric:"nameserver.availability",operator:"UNAVAILABLE",threshold:0,duration:"1s",aggregation:"LAST",
+    channels:["email","sms"],enabled:true,severity:"critical",instanceId:$instance,consecutiveSamples:1}')")
+RULE_ID=$(jq -er '.data.id' <<<"$RULE")
+
+# The silence must initially defer both channel rows, then its normal expiry releases them.
+SILENCE=$(api_post /api/alert-silences "$(jq -n --arg instance "$INSTANCE_ID" --argjson ruleId "$RULE_ID" \
+  --arg start "$(utc_after_seconds 0)" --arg end "$(utc_after_seconds "$SILENCE_SECONDS")" \
+  '{domain:"CLUSTER",ruleId:$ruleId,instanceId:$instance,startsAt:$start,endsAt:$end,reason:"native alert e2e"}')")
+SILENCE_ID=$(jq -er '.data.id' <<<"$SILENCE")
+
+wait_for "FIRING event" 30 firing_exists
+wait_for "silenced notification rows" 8 silenced_rows_pending
+echo "Silence $SILENCE_ID deferred FIRING delivery."
+wait_for "silence expiry and FIRING delivery" 30 firing_rows_delivered
+
+api_post /api/instances/update "$(jq -n --arg id "$INSTANCE_ID" --arg endpoint "$E2E_NAMESRV_ADDR" \
+  '{instanceId:$id,name:$id,type:"DIRECT",endpoint:$endpoint}')" >/dev/null
+wait_for "RESOLVED event" 30 resolved_exists
+wait_for "RESOLVED delivery" 30 resolved_rows_delivered
+
+# DELIVERED confirms the SMTP sender handed both lifecycle messages to its configured endpoint.
+# The script deliberately does not depend on a Mailpit-specific message-inspection API.
+WEBHOOK_PAYLOADS=$(curl -fsS "$E2E_WEBHOOK_ASSERT_URL")
+grep -q "$RULE_NAME" <<<"$WEBHOOK_PAYLOADS" || { echo 'Webhook capture has no payload for this run' >&2; exit 1; }
+grep -q 'FIRING' <<<"$WEBHOOK_PAYLOADS" || { echo 'Webhook capture has no FIRING payload' >&2; exit 1; }
+grep -q 'RESOLVED' <<<"$WEBHOOK_PAYLOADS" || { echo 'Webhook capture has no RESOLVED payload' >&2; exit 1; }
+
+echo "PASS: silence suppression, FIRING/RESOLVED state transitions, SMTP handoff, and webhook delivery verified."
+echo "Evidence is retained in $E2E_MYSQL_DATABASE for instance $INSTANCE_ID; Studio log: $APP_LOG"
diff --git a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/AlertCollectionLease.java
similarity index 76%
copy from server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
copy to server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/AlertCollectionLease.java
index a3b47d7d5..2669a92b3 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/AlertCollectionLease.java
@@ -14,10 +14,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.rocketmq.studio.persistence.mapper;
+package org.apache.rocketmq.studio.cluster.metrics;
 
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import org.apache.rocketmq.studio.persistence.entity.RmqAlertRule;
-
-public interface RmqAlertRuleMapper extends BaseMapper<RmqAlertRule> {
+public interface AlertCollectionLease {
+    boolean tryAcquire();
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/AlertingProperties.java b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/AlertingProperties.java
new file mode 100644
index 000000000..b75dd0f26
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/AlertingProperties.java
@@ -0,0 +1,37 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.cluster.metrics;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@Data
+@ConfigurationProperties(prefix = "studio.alerting")
+public class AlertingProperties {
+    private String collectionInterval = "PT30S";
+    /** Maximum number of instances collected concurrently during one pass. */
+    private int collectionParallelism = 4;
+    /** Maximum time that one instance may consume during a collection pass. */
+    private String collectionTimeout = "PT15S";
+    /**
+     * A shared database lease prevents multiple Studio replicas from evaluating the same
+     * native samples and emitting duplicate alert events.
+     */
+    private String collectionLeaseDuration = "PT1M";
+    /** Retain short-lived diagnostic samples without allowing the snapshot table to grow indefinitely. */
+    private String snapshotRetention = "PT24H";
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/BusinessMetricsCollector.java
similarity index 70%
copy from server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
copy to server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/BusinessMetricsCollector.java
index a3b47d7d5..327b2dc63 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/BusinessMetricsCollector.java
@@ -14,10 +14,15 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.rocketmq.studio.persistence.mapper;
+package org.apache.rocketmq.studio.cluster.metrics;
 
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import org.apache.rocketmq.studio.persistence.entity.RmqAlertRule;
+import org.apache.rocketmq.studio.instance.InstanceVO;
 
-public interface RmqAlertRuleMapper extends BaseMapper<RmqAlertRule> {
+import java.util.List;
+
+/** Collects business-flow metrics for one Studio-managed instance. */
+public interface BusinessMetricsCollector {
+    boolean supports(InstanceVO instance);
+
+    List<MetricSample> collect(InstanceVO instance);
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/ClusterMetricsCollector.java
similarity index 70%
copy from server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
copy to server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/ClusterMetricsCollector.java
index a3b47d7d5..406b1207d 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/ClusterMetricsCollector.java
@@ -14,10 +14,15 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.rocketmq.studio.persistence.mapper;
+package org.apache.rocketmq.studio.cluster.metrics;
 
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import org.apache.rocketmq.studio.persistence.entity.RmqAlertRule;
+import org.apache.rocketmq.studio.instance.InstanceVO;
 
-public interface RmqAlertRuleMapper extends BaseMapper<RmqAlertRule> {
+import java.util.List;
+
+/** Collects operational metrics for one Studio-managed instance. */
+public interface ClusterMetricsCollector {
+    boolean supports(InstanceVO instance);
+
+    List<MetricSample> collect(InstanceVO instance);
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/CollectorScheduler.java b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/CollectorScheduler.java
new file mode 100644
index 000000000..e20c717f6
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/CollectorScheduler.java
@@ -0,0 +1,180 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.cluster.metrics;
+
+import jakarta.annotation.PreDestroy;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.studio.instance.InstanceRepository;
+import org.apache.rocketmq.studio.instance.InstanceVO;
+import org.apache.rocketmq.studio.ops.alert.NativeAlertProcessor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.time.Duration;
+import java.time.Instant;
+
+/** Runs independent, bounded collection jobs for each configured instance. */
+@Slf4j
+@Component
+@EnableConfigurationProperties(AlertingProperties.class)
+public class CollectorScheduler {
+    private final AlertingProperties properties;
+    private final InstanceRepository instanceRepository;
+    private final List<ClusterMetricsCollector> clusterCollectors;
+    private final List<BusinessMetricsCollector> businessCollectors;
+    private final MetricSnapshotRepository snapshotRepository;
+    private final NativeAlertProcessor alertProcessor;
+    private final AlertCollectionLease collectionLease;
+    private final ExecutorService collectionExecutor;
+
+    @Autowired
+    public CollectorScheduler(AlertingProperties properties, InstanceRepository instanceRepository,
+            List<ClusterMetricsCollector> clusterCollectors, List<BusinessMetricsCollector> businessCollectors,
+            MetricSnapshotRepository snapshotRepository, NativeAlertProcessor alertProcessor,
+            AlertCollectionLease collectionLease) {
+        this(properties, instanceRepository, clusterCollectors, businessCollectors, snapshotRepository, alertProcessor,
+                collectionLease, newCollectionExecutor(properties));
+    }
+
+    CollectorScheduler(AlertingProperties properties, InstanceRepository instanceRepository,
+            List<ClusterMetricsCollector> clusterCollectors, List<BusinessMetricsCollector> businessCollectors,
+            MetricSnapshotRepository snapshotRepository, NativeAlertProcessor alertProcessor,
+            AlertCollectionLease collectionLease, ExecutorService collectionExecutor) {
+        this.properties = properties;
+        this.instanceRepository = instanceRepository;
+        this.clusterCollectors = clusterCollectors;
+        this.businessCollectors = businessCollectors;
+        this.snapshotRepository = snapshotRepository;
+        this.alertProcessor = alertProcessor;
+        this.collectionLease = collectionLease;
+        this.collectionExecutor = collectionExecutor;
+    }
+
+    @Scheduled(fixedDelayString = "${studio.alerting.collection-interval:PT30S}")
+    public void collect() {
+        if (!collectionLease.tryAcquire()) {
+            log.debug("Skipping native alert collection because another Studio replica holds the lease");
+            return;
+        }
+        List<Future<?>> jobs = instanceRepository.findAll().stream()
+                .map(this::submitCollection)
+                .filter(java.util.Objects::nonNull)
+                .toList();
+        Duration timeout = parsePositiveDuration(properties.getCollectionTimeout(), Duration.ofSeconds(15));
+        for (Future<?> job : jobs) {
+            try {
+                job.get(timeout.toMillis(), TimeUnit.MILLISECONDS);
+            } catch (java.util.concurrent.TimeoutException error) {
+                job.cancel(true);
+                log.warn("Native metric collection exceeded {} for one instance and was cancelled", timeout);
+            } catch (InterruptedException error) {
+                Thread.currentThread().interrupt();
+                return;
+            } catch (java.util.concurrent.ExecutionException error) {
+                log.warn("Native metric collection job failed: {}", error.getCause().getMessage());
+            }
+        }
+    }
+
+    private Future<?> submitCollection(InstanceVO instance) {
+        try {
+            return collectionExecutor.submit(() -> {
+                collectClusterMetrics(instance);
+                collectBusinessMetrics(instance);
+            });
+        } catch (java.util.concurrent.RejectedExecutionException error) {
+            log.warn("Skipping native metric collection for instance {} because the collector is saturated", instance.getName());
+            return null;
+        }
+    }
+
+    @Scheduled(fixedDelayString = "${studio.alerting.snapshot-cleanup-interval:PT1H}")
+    public void cleanUpSnapshots() {
+        Duration retention = Duration.parse(properties.getSnapshotRetention());
+        if (retention.isNegative() || retention.isZero()) {
+            return;
+        }
+        snapshotRepository.deleteBefore(Instant.now().minus(retention));
+    }
+
+    private void collectClusterMetrics(InstanceVO instance) {
+        for (ClusterMetricsCollector collector : clusterCollectors) {
+            try {
+                persist(collector.supports(instance) ? collector.collect(instance) : List.of());
+            } catch (RuntimeException error) {
+                log.warn("Native metric collector failed for instance {}: {}", instance.getName(), error.getMessage());
+            }
+        }
+    }
+
+    private void collectBusinessMetrics(InstanceVO instance) {
+        for (BusinessMetricsCollector collector : businessCollectors) {
+            try {
+                persist(collector.supports(instance) ? collector.collect(instance) : List.of());
+            } catch (RuntimeException error) {
+                log.warn("Native metric collector failed for instance {}: {}", instance.getName(), error.getMessage());
+            }
+        }
+    }
+
+    private void persist(List<MetricSample> samples) {
+        if (!samples.isEmpty()) {
+            // Refresh immediately before persisting so a slow remote collection cannot write after lease loss.
+            if (!collectionLease.tryAcquire()) {
+                log.debug("Discarding native metric samples because the collection lease was lost");
+                return;
+            }
+            snapshotRepository.saveAll(samples);
+            alertProcessor.process(samples);
+        }
+    }
+
+    @PreDestroy
+    void stopCollectionExecutor() {
+        collectionExecutor.shutdownNow();
+    }
+
+    private static ExecutorService newCollectionExecutor(AlertingProperties properties) {
+        int parallelism = Math.max(1, properties.getCollectionParallelism());
+        ThreadFactory threadFactory = task -> {
+            Thread thread = new Thread(task, "studio-native-metric-collector");
+            thread.setDaemon(true);
+            return thread;
+        };
+        return new ThreadPoolExecutor(parallelism, parallelism, 0, TimeUnit.MILLISECONDS,
+                new ArrayBlockingQueue<>(parallelism), threadFactory, new ThreadPoolExecutor.AbortPolicy());
+    }
+
+    private static Duration parsePositiveDuration(String value, Duration fallback) {
+        try {
+            Duration duration = Duration.parse(value);
+            return duration.isPositive() ? duration : fallback;
+        } catch (RuntimeException error) {
+            log.warn("Invalid native metric collection timeout '{}'; using {}", value, fallback);
+            return fallback;
+        }
+    }
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/CollectorStatusController.java b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/CollectorStatusController.java
new file mode 100644
index 000000000..57d9f84cb
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/CollectorStatusController.java
@@ -0,0 +1,40 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.cluster.metrics;
+
+import lombok.RequiredArgsConstructor;
+import org.apache.rocketmq.studio.common.domain.Result;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/alert-collector-status")
+@RequiredArgsConstructor
+public class CollectorStatusController {
+    private final AlertingProperties properties;
+    private final List<ClusterMetricsCollector> clusterCollectors;
+    private final List<BusinessMetricsCollector> businessCollectors;
+
+    @GetMapping
+    public Result<CollectorStatusVO> status() {
+        return Result.ok(new CollectorStatusVO(properties.getCollectionInterval(),
+                clusterCollectors.size(), businessCollectors.size()));
+    }
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/CollectorStatusVO.java
similarity index 76%
copy from server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
copy to server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/CollectorStatusVO.java
index a3b47d7d5..10234d975 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/CollectorStatusVO.java
@@ -14,10 +14,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.rocketmq.studio.persistence.mapper;
+package org.apache.rocketmq.studio.cluster.metrics;
 
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import org.apache.rocketmq.studio.persistence.entity.RmqAlertRule;
-
-public interface RmqAlertRuleMapper extends BaseMapper<RmqAlertRule> {
+public record CollectorStatusVO(String collectionInterval,
+        int clusterCollectorCount, int businessCollectorCount) {
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricAvailability.java
similarity index 76%
copy from server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
copy to server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricAvailability.java
index a3b47d7d5..4599a960e 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricAvailability.java
@@ -14,10 +14,12 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.rocketmq.studio.persistence.mapper;
+package org.apache.rocketmq.studio.cluster.metrics;
 
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import org.apache.rocketmq.studio.persistence.entity.RmqAlertRule;
-
-public interface RmqAlertRuleMapper extends BaseMapper<RmqAlertRule> {
+/** The collection state of a native Studio metric sample. */
+public enum MetricAvailability {
+    AVAILABLE,
+    UNAVAILABLE,
+    UNSUPPORTED,
+    STALE
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricSample.java b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricSample.java
new file mode 100644
index 000000000..455093c6e
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricSample.java
@@ -0,0 +1,68 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.cluster.metrics;
+
+import org.apache.rocketmq.studio.ops.alert.AlertDomain;
+
+import java.time.Instant;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * A normalized native metric collected by Studio. A non-available sample intentionally has no
+ * numeric value so callers cannot mistake a failed collection for a healthy zero.
+ */
+public record MetricSample(
+        String metricKey,
+        AlertDomain domain,
+        String instanceId,
+        String clusterId,
+        Map<String, String> labels,
+        Double value,
+        MetricAvailability availability,
+        Instant collectedAt,
+        String unavailableReason) {
+
+    public MetricSample(String metricKey, AlertDomain domain, String instanceId, String clusterId,
+            Map<String, String> labels, Double value, MetricAvailability availability, Instant collectedAt) {
+        this(metricKey, domain, instanceId, clusterId, labels, value, availability, collectedAt, null);
+    }
+
+    public MetricSample {
+        requireText(metricKey, "metricKey");
+        Objects.requireNonNull(domain, "domain is required");
+        requireText(instanceId, "instanceId");
+        labels = labels == null ? Map.of() : Map.copyOf(labels);
+        Objects.requireNonNull(availability, "availability is required");
+        Objects.requireNonNull(collectedAt, "collectedAt is required");
+        if (availability != MetricAvailability.AVAILABLE && value != null) {
+            throw new IllegalArgumentException("Only available metric samples may have a value");
+        }
+        if (availability == MetricAvailability.AVAILABLE && value == null) {
+            throw new IllegalArgumentException("Available metric samples require a value");
+        }
+        if (availability == MetricAvailability.AVAILABLE && unavailableReason != null) {
+            throw new IllegalArgumentException("Available metric samples cannot have an unavailable reason");
+        }
+    }
+
+    private static void requireText(String value, String field) {
+        if (value == null || value.isBlank()) {
+            throw new IllegalArgumentException(field + " is required");
+        }
+    }
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricSnapshotRepository.java
similarity index 72%
copy from server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
copy to server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricSnapshotRepository.java
index a3b47d7d5..ac3b4ff92 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricSnapshotRepository.java
@@ -14,10 +14,15 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.rocketmq.studio.persistence.mapper;
+package org.apache.rocketmq.studio.cluster.metrics;
 
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import org.apache.rocketmq.studio.persistence.entity.RmqAlertRule;
+import java.time.Instant;
+import java.util.List;
 
-public interface RmqAlertRuleMapper extends BaseMapper<RmqAlertRule> {
+public interface MetricSnapshotRepository {
+    void saveAll(List<MetricSample> samples);
+
+    List<MetricSample> findRecent(MetricSample scope, Instant since);
+
+    int deleteBefore(Instant cutoff);
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MybatisPlusAlertCollectionLease.java b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MybatisPlusAlertCollectionLease.java
new file mode 100644
index 000000000..bca58e6b8
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MybatisPlusAlertCollectionLease.java
@@ -0,0 +1,76 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.cluster.metrics;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.studio.persistence.entity.RmqAlertCollectionLease;
+import org.apache.rocketmq.studio.persistence.mapper.RmqAlertCollectionLeaseMapper;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Repository;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.UUID;
+
+/** Coordinates native collection across Studio replicas through a short-lived database lease. */
+@Slf4j
+@Repository
+@RequiredArgsConstructor
+public class MybatisPlusAlertCollectionLease implements AlertCollectionLease {
+    private static final String LEASE_NAME = "native-alert-collection";
+
+    private final AlertingProperties properties;
+    private final RmqAlertCollectionLeaseMapper mapper;
+    private final String holderId = UUID.randomUUID().toString();
+
+    @Override
+    public boolean tryAcquire() {
+        Instant now = Instant.now();
+        Duration duration = parseDuration(properties.getCollectionLeaseDuration());
+        LocalDateTime acquiredAt = LocalDateTime.ofInstant(now, ZoneOffset.UTC);
+        LocalDateTime expiresAt = LocalDateTime.ofInstant(now.plus(duration), ZoneOffset.UTC);
+        if (mapper.acquire(LEASE_NAME, holderId, acquiredAt, expiresAt) > 0) {
+            return true;
+        }
+
+        RmqAlertCollectionLease lease = new RmqAlertCollectionLease();
+        lease.setLeaseName(LEASE_NAME);
+        lease.setHolderId(holderId);
+        lease.setExpiresAt(expiresAt);
+        try {
+            return mapper.insert(lease) > 0;
+        } catch (DuplicateKeyException ignored) {
+            return false;
+        }
+    }
+
+    private static Duration parseDuration(String configured) {
+        try {
+            Duration duration = Duration.parse(configured);
+            if (!duration.isNegative() && !duration.isZero()) {
+                return duration;
+            }
+        } catch (RuntimeException ignored) {
+            // Fall through to a conservative duration so malformed configuration cannot disable coordination.
+        }
+        log.warn("Invalid native alert collection lease duration {}; using PT1M", configured);
+        return Duration.ofMinutes(1);
+    }
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MybatisPlusMetricSnapshotRepository.java b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MybatisPlusMetricSnapshotRepository.java
new file mode 100644
index 000000000..18baf8105
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MybatisPlusMetricSnapshotRepository.java
@@ -0,0 +1,116 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.cluster.metrics;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import org.apache.rocketmq.studio.persistence.entity.RmqMetricSnapshot;
+import org.apache.rocketmq.studio.persistence.mapper.RmqMetricSnapshotMapper;
+import org.springframework.stereotype.Repository;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+@Repository
+@RequiredArgsConstructor
+public class MybatisPlusMetricSnapshotRepository implements MetricSnapshotRepository {
+    private final RmqMetricSnapshotMapper mapper;
+    private final ObjectMapper objectMapper;
+
+    @Override
+    public void saveAll(List<MetricSample> samples) {
+        for (MetricSample sample : samples) {
+            mapper.insert(toEntity(sample));
+        }
+    }
+
+    @Override
+    public int deleteBefore(Instant cutoff) {
+        return Math.toIntExact(mapper.delete(new QueryWrapper<RmqMetricSnapshot>()
+                .lt("collected_at", LocalDateTime.ofInstant(cutoff, ZoneOffset.UTC))));
+    }
+
+    @Override
+    public List<MetricSample> findRecent(MetricSample scope, Instant since) {
+        String labelsJson = serializeLabels(scope.labels());
+        return mapper.selectList(new QueryWrapper<RmqMetricSnapshot>()
+                        .eq("instance_id", scope.instanceId()).eq("metric_key", scope.metricKey())
+                        .eq("domain", scope.domain().name()).eq("labels_hash", sha256(labelsJson))
+                        .eq(scope.clusterId() != null, "cluster_id", scope.clusterId())
+                        .eq("availability", MetricAvailability.AVAILABLE.name())
+                        .ge("collected_at", LocalDateTime.ofInstant(since, ZoneOffset.UTC))
+                        .orderByAsc("collected_at"))
+                .stream().map(this::toSample).toList();
+    }
+
+    private RmqMetricSnapshot toEntity(MetricSample sample) {
+        String labelsJson = serializeLabels(sample.labels());
+        RmqMetricSnapshot entity = new RmqMetricSnapshot();
+        entity.setInstanceId(sample.instanceId());
+        entity.setMetricKey(sample.metricKey());
+        entity.setDomain(sample.domain().name());
+        entity.setClusterId(sample.clusterId());
+        entity.setLabelsHash(sha256(labelsJson));
+        entity.setLabelsJson(labelsJson);
+        entity.setValue(sample.value());
+        entity.setAvailability(sample.availability().name());
+        entity.setCollectedAt(LocalDateTime.ofInstant(sample.collectedAt(), ZoneOffset.UTC));
+        return entity;
+    }
+
+    private MetricSample toSample(RmqMetricSnapshot entity) {
+        try {
+            return new MetricSample(entity.getMetricKey(), org.apache.rocketmq.studio.ops.alert.AlertDomain.valueOf(entity.getDomain()),
+                    entity.getInstanceId(), entity.getClusterId(), objectMapper.readValue(entity.getLabelsJson(), new TypeReference<>() { }),
+                    entity.getValue(), MetricAvailability.valueOf(entity.getAvailability()),
+                    entity.getCollectedAt().toInstant(ZoneOffset.UTC));
+        } catch (JsonProcessingException error) {
+            throw new IllegalStateException("Unable to read metric labels", error);
+        }
+    }
+
+    private String serializeLabels(Map<String, String> labels) {
+        try {
+            return objectMapper.writeValueAsString(new TreeMap<>(labels));
+        } catch (JsonProcessingException error) {
+            throw new IllegalArgumentException("Unable to serialize metric labels", error);
+        }
+    }
+
+    private static String sha256(String value) {
+        try {
+            byte[] digest = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
+            StringBuilder hex = new StringBuilder(digest.length * 2);
+            for (byte item : digest) {
+                hex.append(String.format("%02x", item));
+            }
+            return hex.toString();
+        } catch (NoSuchAlgorithmException impossible) {
+            throw new IllegalStateException("SHA-256 is not available", impossible);
+        }
+    }
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqBusinessMetricsCollector.java b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqBusinessMetricsCollector.java
new file mode 100644
index 000000000..dfeb918a5
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqBusinessMetricsCollector.java
@@ -0,0 +1,146 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.cluster.metrics.collectors;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.studio.cluster.metrics.BusinessMetricsCollector;
+import org.apache.rocketmq.studio.cluster.metrics.MetricAvailability;
+import org.apache.rocketmq.studio.cluster.metrics.MetricSample;
+import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
+import org.apache.rocketmq.studio.instance.InstanceVO;
+import org.apache.rocketmq.studio.instance.group.ConsumerGroupVO;
+import org.apache.rocketmq.studio.instance.group.QueueProgressVO;
+import org.apache.rocketmq.studio.provider.InstanceProvider;
+import org.apache.rocketmq.studio.provider.InstanceProviderRegistry;
+import org.apache.rocketmq.studio.ops.alert.AlertDomain;
+import org.springframework.stereotype.Component;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/** Collects consumer-group lag directly from Studio's Apache instance provider. */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ApacheRocketMqBusinessMetricsCollector implements BusinessMetricsCollector {
+    public static final String CONSUMER_LAG_TOTAL = "consumer.lag.total";
+    public static final String CONSUMER_LAG_MAX_QUEUE = "consumer.lag.max_queue";
+    public static final String CONSUMER_DELAY_SECONDS = "consumer.delay.seconds";
+    public static final String TOPIC_BACKLOG_TOTAL = "topic.backlog.total";
+
+    private final InstanceProviderRegistry providerRegistry;
+
+    @Override
+    public boolean supports(InstanceVO instance) {
+        return instance != null && (instance.getVendor() == null || instance.getVendor() == InstanceVendor.APACHE)
+                && instance.getName() != null;
+    }
+
+    @Override
+    public List<MetricSample> collect(InstanceVO instance) {
+        if (!supports(instance)) {
+            return List.of();
+        }
+        Instant collectedAt = Instant.now();
+        try {
+            InstanceProvider provider = providerRegistry.byInstanceId(instance.getName()).orElseThrow();
+            List<MetricSample> samples = new ArrayList<>();
+            for (ConsumerGroupVO group : provider.listConsumerGroups(instance.getName(), null)) {
+                if (group.getName() == null || group.getName().isBlank()) {
+                    continue;
+                }
+                if (!group.isConsumeStatsAvailable()) {
+                    Map<String, String> labels = Map.of("consumerGroup", group.getName());
+                    samples.add(unavailable(CONSUMER_LAG_TOTAL, instance, labels, collectedAt,
+                            "CONSUMER_STATS_UNAVAILABLE"));
+                    samples.add(unavailable(CONSUMER_LAG_MAX_QUEUE, instance, labels, collectedAt,
+                            "CONSUMER_STATS_UNAVAILABLE"));
+                    samples.add(unavailable(CONSUMER_DELAY_SECONDS, instance, labels, collectedAt,
+                            "CONSUMER_STATS_UNAVAILABLE"));
+                    samples.add(unavailable(TOPIC_BACKLOG_TOTAL, instance, labels, collectedAt,
+                            "CONSUMER_STATS_UNAVAILABLE"));
+                    continue;
+                }
+                samples.add(totalLagSample(instance, group, collectedAt));
+                if (group.isConsumptionTimestampAvailable()) {
+                    samples.add(delaySample(instance, group, collectedAt));
+                }
+                samples.addAll(queueLagSamples(provider, instance, group, collectedAt));
+            }
+            return samples;
+        } catch (RuntimeException error) {
+            log.warn("Failed to collect consumer lag for instance {}: {}", instance.getName(), error.getMessage());
+            return List.of(unavailable(CONSUMER_LAG_TOTAL, instance, Map.of(), collectedAt,
+                    "BUSINESS_METRICS_COLLECTION_FAILED"),
+                    unavailable(CONSUMER_LAG_MAX_QUEUE, instance, Map.of(), collectedAt,
+                            "BUSINESS_METRICS_COLLECTION_FAILED"),
+                    unavailable(CONSUMER_DELAY_SECONDS, instance, Map.of(), collectedAt,
+                            "BUSINESS_METRICS_COLLECTION_FAILED"),
+                    unavailable(TOPIC_BACKLOG_TOTAL, instance, Map.of(), collectedAt,
+                            "BUSINESS_METRICS_COLLECTION_FAILED"));
+        }
+    }
+
+    private static MetricSample totalLagSample(InstanceVO instance, ConsumerGroupVO group, Instant collectedAt) {
+        return new MetricSample(CONSUMER_LAG_TOTAL, AlertDomain.BUSINESS, instance.getName(), group.getClusterId(),
+                Map.of("consumerGroup", group.getName()), (double) Math.max(0, group.getTotalLag()),
+                MetricAvailability.AVAILABLE, collectedAt);
+    }
+
+    private static MetricSample delaySample(InstanceVO instance, ConsumerGroupVO group, Instant collectedAt) {
+        return new MetricSample(CONSUMER_DELAY_SECONDS, AlertDomain.BUSINESS, instance.getName(), group.getClusterId(),
+                Map.of("consumerGroup", group.getName()), (double) Math.max(0, group.getDelaySeconds()),
+                MetricAvailability.AVAILABLE, collectedAt);
+    }
+
+    private static List<MetricSample> queueLagSamples(InstanceProvider provider, InstanceVO instance,
+            ConsumerGroupVO group, Instant collectedAt) {
+        Map<String, String> labels = Map.of("consumerGroup", group.getName());
+        try {
+            List<QueueProgressVO> progress = provider.getGroupProgress(instance.getName(), group.getName());
+            long maxLag = progress.stream().mapToLong(QueueProgressVO::getDiffTotal)
+                    .map(value -> Math.max(0, value)).max().orElse(0);
+            List<MetricSample> samples = new ArrayList<>();
+            samples.add(new MetricSample(CONSUMER_LAG_MAX_QUEUE, AlertDomain.BUSINESS, instance.getName(),
+                    group.getClusterId(), labels, (double) maxLag, MetricAvailability.AVAILABLE, collectedAt));
+            progress.stream().filter(row -> row.getTopic() != null && !row.getTopic().isBlank())
+                    .collect(java.util.stream.Collectors.groupingBy(QueueProgressVO::getTopic,
+                            java.util.stream.Collectors.summingLong(
+                                    row -> Math.max(0, row.getDiffTotal()))))
+                    .forEach((topic, lag) -> samples.add(new MetricSample(TOPIC_BACKLOG_TOTAL, AlertDomain.BUSINESS,
+                            instance.getName(), group.getClusterId(), Map.of("consumerGroup", group.getName(),
+                            "topic", topic), (double) lag, MetricAvailability.AVAILABLE, collectedAt)));
+            return samples;
+        } catch (RuntimeException error) {
+            log.warn("Failed to collect queue lag for group {} on instance {}: {}", group.getName(),
+                    instance.getName(), error.getMessage());
+            return List.of(unavailable(CONSUMER_LAG_MAX_QUEUE, instance, labels, collectedAt,
+                    "CONSUMER_PROGRESS_UNAVAILABLE"),
+                    unavailable(TOPIC_BACKLOG_TOTAL, instance, labels, collectedAt,
+                            "CONSUMER_PROGRESS_UNAVAILABLE"));
+        }
+    }
+
+    private static MetricSample unavailable(String metric, InstanceVO instance, Map<String, String> labels,
+            Instant collectedAt, String reason) {
+        return new MetricSample(metric, AlertDomain.BUSINESS, instance.getName(), null, labels, null,
+                MetricAvailability.UNAVAILABLE, collectedAt, reason);
+    }
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqClusterMetricsCollector.java b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqClusterMetricsCollector.java
new file mode 100644
index 000000000..27580f6ab
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqClusterMetricsCollector.java
@@ -0,0 +1,170 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.cluster.metrics.collectors;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
+import org.apache.rocketmq.remoting.protocol.route.BrokerData;
+import org.apache.rocketmq.remoting.protocol.body.KVTable;
+import org.apache.rocketmq.tools.admin.MQAdminExt;
+import org.apache.rocketmq.studio.cluster.broker.RuntimeAdminClientResolver;
+import org.apache.rocketmq.studio.cluster.metrics.ClusterMetricsCollector;
+import org.apache.rocketmq.studio.cluster.metrics.MetricAvailability;
+import org.apache.rocketmq.studio.cluster.metrics.MetricSample;
+import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
+import org.apache.rocketmq.studio.instance.InstanceVO;
+import org.apache.rocketmq.studio.ops.alert.AlertDomain;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/** Native Apache RocketMQ health collection through Studio's managed admin client. */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ApacheRocketMqClusterMetricsCollector implements ClusterMetricsCollector {
+
+    static final String NAMESERVER_AVAILABILITY = "nameserver.availability";
+    static final String BROKER_AVAILABILITY = "broker.availability";
+    static final String BROKER_DISK_USAGE_RATIO = "broker.disk.usage_ratio";
+    static final String BROKER_JVM_HEAP_USAGE_RATIO = "broker.jvm.heap.usage_ratio";
+    static final String BROKER_SEND_QUEUE_USAGE_RATIO = "broker.send_queue.usage_ratio";
+
+    private final RuntimeAdminClientResolver adminClientResolver;
+
+    @Override
+    public boolean supports(InstanceVO instance) {
+        return instance != null && (instance.getVendor() == null || instance.getVendor() == InstanceVendor.APACHE)
+                && StringUtils.hasText(instance.getName()) && StringUtils.hasText(instance.getEndpoint());
+    }
+
+    @Override
+    public List<MetricSample> collect(InstanceVO instance) {
+        if (!supports(instance)) {
+            return List.of();
+        }
+        Instant collectedAt = Instant.now();
+        try {
+            return adminClientResolver.execute(instance,
+                    admin -> collectFromTopology(instance, admin, admin.examineBrokerClusterInfo(), collectedAt));
+        } catch (RuntimeException error) {
+            log.warn("Failed to collect native metrics for instance {}: {}", instance.getName(), error.getMessage());
+            return List.of(unavailable(NAMESERVER_AVAILABILITY, instance, null, Map.of(), collectedAt));
+        }
+    }
+
+    private List<MetricSample> collectFromTopology(InstanceVO instance, MQAdminExt admin, ClusterInfo topology,
+            Instant collectedAt) {
+        if (topology == null) {
+            return List.of(unavailable(NAMESERVER_AVAILABILITY, instance, null, Map.of(), collectedAt));
+        }
+        List<MetricSample> samples = new ArrayList<>();
+        samples.add(available(NAMESERVER_AVAILABILITY, instance, null, Map.of(), 1D, collectedAt));
+        Map<String, BrokerData> brokers = topology.getBrokerAddrTable() == null
+                ? Map.of() : topology.getBrokerAddrTable();
+        for (Map.Entry<String, BrokerData> entry : brokers.entrySet()) {
+            collectBroker(instance, admin, entry.getKey(), entry.getValue(), collectedAt, samples);
+        }
+        return samples;
+    }
+
+    private void collectBroker(InstanceVO instance, MQAdminExt admin, String brokerName, BrokerData broker,
+            Instant collectedAt,
+            List<MetricSample> samples) {
+        String clusterId = broker == null ? null : broker.getCluster();
+        String address = broker == null || broker.getBrokerAddrs() == null ? null : broker.getBrokerAddrs().get(0L);
+        Map<String, String> labels = StringUtils.hasText(address)
+                ? Map.of("brokerName", brokerName, "brokerAddr", address)
+                : Map.of("brokerName", brokerName);
+        if (!StringUtils.hasText(address)) {
+            samples.add(unavailable(BROKER_AVAILABILITY, instance, clusterId, labels, collectedAt));
+            return;
+        }
+        try {
+            KVTable runtime = admin.fetchBrokerRuntimeStats(address);
+            if (runtime == null || runtime.getTable() == null) {
+                samples.add(unavailable(BROKER_AVAILABILITY, instance, clusterId, labels, collectedAt));
+                return;
+            }
+            samples.add(available(BROKER_AVAILABILITY, instance, clusterId, labels, 1D, collectedAt));
+            parseDiskUsage(runtime.getTable().get("commitLogDiskRatio"))
+                    .ifPresent(value -> samples.add(available(BROKER_DISK_USAGE_RATIO, instance, clusterId,
+                            labels, value, collectedAt)));
+            parseHeapUsage(runtime.getTable().get("jvmMemoryHeapUsed"), runtime.getTable().get("jvmMemoryHeapMax"))
+                    .ifPresent(value -> samples.add(available(BROKER_JVM_HEAP_USAGE_RATIO, instance, clusterId,
+                            labels, value, collectedAt)));
+            parseUsageRatio(runtime.getTable().get("sendThreadPoolQueueSize"),
+                    runtime.getTable().get("sendThreadPoolQueueCapacity"))
+                    .ifPresent(value -> samples.add(available(BROKER_SEND_QUEUE_USAGE_RATIO, instance, clusterId,
+                            labels, value, collectedAt)));
+        } catch (Exception error) {
+            log.warn("Failed to collect runtime metrics for broker {} on instance {}: {}", brokerName,
+                    instance.getName(), error.getMessage());
+            samples.add(unavailable(BROKER_AVAILABILITY, instance, clusterId, labels, collectedAt));
+        }
+    }
+
+    private static java.util.Optional<Double> parseDiskUsage(String raw) {
+        if (!StringUtils.hasText(raw)) {
+            return java.util.Optional.empty();
+        }
+        try {
+            double value = Double.parseDouble(raw.trim());
+            if (!Double.isFinite(value) || value < 0 || value > 100) {
+                return java.util.Optional.empty();
+            }
+            return java.util.Optional.of(value > 1D ? value / 100D : value);
+        } catch (NumberFormatException ignored) {
+            return java.util.Optional.empty();
+        }
+    }
+
+    private static java.util.Optional<Double> parseHeapUsage(String usedRaw, String maxRaw) {
+        return parseUsageRatio(usedRaw, maxRaw);
+    }
+
+    private static java.util.Optional<Double> parseUsageRatio(String usedRaw, String capacityRaw) {
+        try {
+            double used = Double.parseDouble(usedRaw);
+            double capacity = Double.parseDouble(capacityRaw);
+            if (!Double.isFinite(used) || !Double.isFinite(capacity) || used < 0 || capacity <= 0
+                    || used > capacity) {
+                return java.util.Optional.empty();
+            }
+            return java.util.Optional.of(used / capacity);
+        } catch (RuntimeException ignored) {
+            return java.util.Optional.empty();
+        }
+    }
+
+    private static MetricSample available(String key, InstanceVO instance, String clusterId,
+            Map<String, String> labels, double value, Instant collectedAt) {
+        return new MetricSample(key, AlertDomain.CLUSTER, instance.getName(), clusterId, labels, value,
+                MetricAvailability.AVAILABLE, collectedAt);
+    }
+
+    private static MetricSample unavailable(String key, InstanceVO instance, String clusterId,
+            Map<String, String> labels, Instant collectedAt) {
+        return new MetricSample(key, AlertDomain.CLUSTER, instance.getName(), clusterId, labels, null,
+                MetricAvailability.UNAVAILABLE, collectedAt);
+    }
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqDlqMetricsCollector.java b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqDlqMetricsCollector.java
new file mode 100644
index 000000000..6ef6fdfc8
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqDlqMetricsCollector.java
@@ -0,0 +1,76 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.cluster.metrics.collectors;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.studio.cluster.metrics.BusinessMetricsCollector;
+import org.apache.rocketmq.studio.cluster.metrics.MetricAvailability;
+import org.apache.rocketmq.studio.cluster.metrics.MetricSample;
+import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
+import org.apache.rocketmq.studio.instance.InstanceVO;
+import org.apache.rocketmq.studio.instance.dlq.DLQGroupVO;
+import org.apache.rocketmq.studio.instance.dlq.DLQProvider;
+import org.apache.rocketmq.studio.ops.alert.AlertDomain;
+import org.springframework.stereotype.Component;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+
+/** Collects dead-letter message counts directly from the Apache DLQ provider. */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ApacheRocketMqDlqMetricsCollector implements BusinessMetricsCollector {
+    public static final String DLQ_MESSAGE_COUNT = "dlq.message.count";
+
+    private final DLQProvider dlqProvider;
+
+    @Override
+    public boolean supports(InstanceVO instance) {
+        return instance != null && (instance.getVendor() == null || instance.getVendor() == InstanceVendor.APACHE)
+                && instance.getName() != null;
+    }
+
+    @Override
+    public List<MetricSample> collect(InstanceVO instance) {
+        if (!supports(instance)) {
+            return List.of();
+        }
+        Instant collectedAt = Instant.now();
+        try {
+            return dlqProvider.listDLQGroups(instance.getName()).stream()
+                    .filter(group -> group.getGroupName() != null && !group.getGroupName().isBlank())
+                    .map(group -> sample(instance, group, collectedAt)).toList();
+        } catch (RuntimeException error) {
+            log.warn("Failed to collect DLQ metrics for instance {}: {}", instance.getName(), error.getMessage());
+            return List.of(new MetricSample(DLQ_MESSAGE_COUNT, AlertDomain.BUSINESS, instance.getName(), null,
+                    Map.of(), null, MetricAvailability.UNAVAILABLE, collectedAt));
+        }
+    }
+
+    private static MetricSample sample(InstanceVO instance, DLQGroupVO group, Instant collectedAt) {
+        Map<String, String> labels = Map.of("consumerGroup", group.getGroupName());
+        if (!group.isStatsAvailable()) {
+            return new MetricSample(DLQ_MESSAGE_COUNT, AlertDomain.BUSINESS, instance.getName(), null, labels,
+                    null, MetricAvailability.UNAVAILABLE, collectedAt);
+        }
+        return new MetricSample(DLQ_MESSAGE_COUNT, AlertDomain.BUSINESS, instance.getName(), null, labels,
+                (double) Math.max(0, group.getMessageCount()), MetricAvailability.AVAILABLE, collectedAt);
+    }
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqProxyMetricsCollector.java b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqProxyMetricsCollector.java
new file mode 100644
index 000000000..15293254a
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqProxyMetricsCollector.java
@@ -0,0 +1,131 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.cluster.metrics.collectors;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.studio.cluster.broker.ClusterService;
+import org.apache.rocketmq.studio.cluster.broker.ClusterVO;
+import org.apache.rocketmq.studio.cluster.metrics.ClusterMetricsCollector;
+import org.apache.rocketmq.studio.cluster.metrics.MetricAvailability;
+import org.apache.rocketmq.studio.cluster.metrics.MetricSample;
+import org.apache.rocketmq.studio.cluster.proxy.ProxyHealthProbe;
+import org.apache.rocketmq.studio.cluster.proxy.ProxyVO;
+import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
+import org.apache.rocketmq.studio.instance.InstanceVO;
+import org.apache.rocketmq.studio.ops.alert.AlertDomain;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/** Collects TCP reachability for Proxies discovered from the selected Studio instance. */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ApacheRocketMqProxyMetricsCollector implements ClusterMetricsCollector {
+
+    static final String PROXY_AVAILABILITY = "proxy.availability";
+    private static final int PROBE_TIMEOUT_MILLIS = 2_000;
+
+    private final ClusterService clusterService;
+    private final ProxyHealthProbe healthProbe;
+
+    @Override
+    public boolean supports(InstanceVO instance) {
+        return instance != null && (instance.getVendor() == null || instance.getVendor() == InstanceVendor.APACHE)
+                && StringUtils.hasText(instance.getName()) && StringUtils.hasText(instance.getEndpoint());
+    }
+
+    @Override
+    public List<MetricSample> collect(InstanceVO instance) {
+        if (!supports(instance)) {
+            return List.of();
+        }
+        Instant collectedAt = Instant.now();
+        try {
+            List<MetricSample> samples = new ArrayList<>();
+            for (ClusterVO cluster : clusterService.listClusters(instance.getName())) {
+                if (cluster.getProxies() == null) {
+                    continue;
+                }
+                for (ProxyVO proxy : cluster.getProxies()) {
+                    samples.add(collectProxy(instance, cluster, proxy, collectedAt));
+                }
+            }
+            return samples;
+        } catch (RuntimeException error) {
+            log.warn("Failed to discover proxies for instance {}: {}", instance.getName(), error.getMessage());
+            return List.of(unavailable(instance, null, Map.of("proxyAddr", "unknown"), collectedAt));
+        }
+    }
+
+    private MetricSample collectProxy(InstanceVO instance, ClusterVO cluster, ProxyVO proxy, Instant collectedAt) {
+        String proxyAddr = proxy == null ? null : proxy.getAddr();
+        String clusterId = StringUtils.hasText(cluster.getId()) ? cluster.getId() : cluster.getName();
+        Map<String, String> labels = StringUtils.hasText(proxyAddr)
+                ? Map.of("proxyAddr", proxyAddr) : Map.of("proxyAddr", "unknown");
+        HostAndPort target = resolveGrpcTarget(proxy);
+        if (target == null) {
+            return unavailable(instance, clusterId, labels, collectedAt);
+        }
+        try {
+            ProxyHealthProbe.ProbeResult result = healthProbe.probe(target.host(), target.port(), PROBE_TIMEOUT_MILLIS);
+            return result.reachable()
+                    ? available(instance, clusterId, labels, collectedAt)
+                    : unavailable(instance, clusterId, labels, collectedAt);
+        } catch (RuntimeException error) {
+            log.warn("Failed to probe proxy {} for instance {}: {}", proxyAddr, instance.getName(), error.getMessage());
+            return unavailable(instance, clusterId, labels, collectedAt);
+        }
+    }
+
+    private static HostAndPort resolveGrpcTarget(ProxyVO proxy) {
+        if (proxy == null || !StringUtils.hasText(proxy.getAddr()) || proxy.getGrpcPort() < 1
+                || proxy.getGrpcPort() > 65_535) {
+            return null;
+        }
+        String address = proxy.getAddr().trim();
+        int separator = address.lastIndexOf(':');
+        if (separator <= 0 || separator == address.length() - 1) {
+            return null;
+        }
+        String host = address.substring(0, separator);
+        if (host.startsWith("[") && host.endsWith("]")) {
+            host = host.substring(1, host.length() - 1);
+        }
+        return StringUtils.hasText(host) ? new HostAndPort(host, proxy.getGrpcPort()) : null;
+    }
+
+    private static MetricSample available(InstanceVO instance, String clusterId, Map<String, String> labels,
+            Instant collectedAt) {
+        return new MetricSample(PROXY_AVAILABILITY, AlertDomain.CLUSTER, instance.getName(), clusterId, labels, 1D,
+                MetricAvailability.AVAILABLE, collectedAt);
+    }
+
+    private static MetricSample unavailable(InstanceVO instance, String clusterId, Map<String, String> labels,
+            Instant collectedAt) {
+        return new MetricSample(PROXY_AVAILABILITY, AlertDomain.CLUSTER, instance.getName(), clusterId, labels, null,
+                MetricAvailability.UNAVAILABLE, collectedAt);
+    }
+
+    private record HostAndPort(String host, int port) {
+    }
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/CloudRocketMqBusinessMetricsCollector.java b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/CloudRocketMqBusinessMetricsCollector.java
new file mode 100644
index 000000000..875208dc5
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/CloudRocketMqBusinessMetricsCollector.java
@@ -0,0 +1,118 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.cluster.metrics.collectors;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.studio.cluster.metrics.BusinessMetricsCollector;
+import org.apache.rocketmq.studio.cluster.metrics.MetricAvailability;
+import org.apache.rocketmq.studio.cluster.metrics.MetricSample;
+import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
+import org.apache.rocketmq.studio.instance.InstanceVO;
+import org.apache.rocketmq.studio.instance.group.ConsumerGroupVO;
+import org.apache.rocketmq.studio.instance.group.QueueProgressVO;
+import org.apache.rocketmq.studio.ops.alert.AlertDomain;
+import org.apache.rocketmq.studio.provider.InstanceProvider;
+import org.apache.rocketmq.studio.provider.InstanceProviderRegistry;
+import org.springframework.stereotype.Component;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/** Collects cloud consumer lag using the provider's existing consumer progress API. */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class CloudRocketMqBusinessMetricsCollector implements BusinessMetricsCollector {
+    private static final String CONSUMER_LAG_TOTAL = "consumer.lag.total";
+    private static final String CONSUMER_LAG_MAX_QUEUE = "consumer.lag.max_queue";
+    private static final String TOPIC_BACKLOG_TOTAL = "topic.backlog.total";
+
+    private final InstanceProviderRegistry providerRegistry;
+
+    @Override
+    public boolean supports(InstanceVO instance) {
+        return instance != null && (instance.getVendor() == InstanceVendor.ALIYUN
+                || instance.getVendor() == InstanceVendor.TENCENT) && instance.getName() != null;
+    }
+
+    @Override
+    public List<MetricSample> collect(InstanceVO instance) {
+        if (!supports(instance)) {
+            return List.of();
+        }
+        Instant collectedAt = Instant.now();
+        try {
+            InstanceProvider provider = providerRegistry.byInstanceId(instance.getName()).orElseThrow();
+            List<MetricSample> samples = new ArrayList<>();
+            for (ConsumerGroupVO group : provider.listConsumerGroups(instance.getName(), null)) {
+                if (group.getName() != null && !group.getName().isBlank()) {
+                    samples.addAll(collectGroup(provider, instance, group, collectedAt));
+                }
+            }
+            return samples;
+        } catch (RuntimeException error) {
+            log.warn("Failed to collect cloud consumer lag for instance {}: {}", instance.getName(),
+                    error.getMessage());
+            return List.of(unavailable(CONSUMER_LAG_TOTAL, instance, Map.of(), collectedAt),
+                    unavailable(CONSUMER_LAG_MAX_QUEUE, instance, Map.of(), collectedAt),
+                    unavailable(TOPIC_BACKLOG_TOTAL, instance, Map.of(), collectedAt));
+        }
+    }
+
+    private static List<MetricSample> collectGroup(InstanceProvider provider, InstanceVO instance,
+            ConsumerGroupVO group, Instant collectedAt) {
+        Map<String, String> labels = Map.of("consumerGroup", group.getName());
+        try {
+            List<QueueProgressVO> progress = provider.getGroupProgress(instance.getName(), group.getName());
+            double totalLag = progress.stream().mapToDouble(row -> Math.max(0L, row.getDiffTotal())).sum();
+            double maxQueueLag = progress.stream().mapToDouble(row -> Math.max(0L, row.getDiffTotal())).max()
+                    .orElse(0D);
+            List<MetricSample> samples = new ArrayList<>();
+            samples.add(available(CONSUMER_LAG_TOTAL, instance, group.getClusterId(), labels, totalLag, collectedAt));
+            samples.add(available(CONSUMER_LAG_MAX_QUEUE, instance, group.getClusterId(), labels, maxQueueLag,
+                    collectedAt));
+            progress.stream().filter(row -> row.getTopic() != null && !row.getTopic().isBlank())
+                    .collect(java.util.stream.Collectors.groupingBy(QueueProgressVO::getTopic,
+                            java.util.stream.Collectors.summingLong(
+                                    row -> Math.max(0, row.getDiffTotal()))))
+                    .forEach((topic, lag) -> samples.add(available(TOPIC_BACKLOG_TOTAL, instance, group.getClusterId(),
+                            Map.of("consumerGroup", group.getName(), "topic", topic), lag, collectedAt)));
+            return samples;
+        } catch (RuntimeException error) {
+            log.warn("Failed to collect cloud consumer lag for group {} on instance {}: {}", group.getName(),
+                    instance.getName(), error.getMessage());
+            return List.of(unavailable(CONSUMER_LAG_TOTAL, instance, labels, collectedAt),
+                    unavailable(CONSUMER_LAG_MAX_QUEUE, instance, labels, collectedAt),
+                    unavailable(TOPIC_BACKLOG_TOTAL, instance, labels, collectedAt));
+        }
+    }
+
+    private static MetricSample available(String metric, InstanceVO instance, String clusterId,
+            Map<String, String> labels, double value, Instant collectedAt) {
+        return new MetricSample(metric, AlertDomain.BUSINESS, instance.getName(), clusterId, labels, value,
+                MetricAvailability.AVAILABLE, collectedAt);
+    }
+
+    private static MetricSample unavailable(String metric, InstanceVO instance, Map<String, String> labels,
+            Instant collectedAt) {
+        return new MetricSample(metric, AlertDomain.BUSINESS, instance.getName(), null, labels, null,
+                MetricAvailability.UNAVAILABLE, collectedAt);
+    }
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/CloudRocketMqClusterMetricsCollector.java b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/CloudRocketMqClusterMetricsCollector.java
new file mode 100644
index 000000000..ddbec51d7
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/collectors/CloudRocketMqClusterMetricsCollector.java
@@ -0,0 +1,84 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.cluster.metrics.collectors;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.studio.cluster.metrics.ClusterMetricsCollector;
+import org.apache.rocketmq.studio.cluster.metrics.MetricAvailability;
+import org.apache.rocketmq.studio.cluster.metrics.MetricSample;
+import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
+import org.apache.rocketmq.studio.instance.InstanceVO;
+import org.apache.rocketmq.studio.ops.alert.AlertDomain;
+import org.apache.rocketmq.studio.provider.CloudInstanceDetailVO;
+import org.apache.rocketmq.studio.provider.InstanceProviderRegistry;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+import java.time.Instant;
+import java.util.Map;
+
+/** Collects the cloud provider's managed-instance lifecycle status for Aliyun and Tencent. */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class CloudRocketMqClusterMetricsCollector implements ClusterMetricsCollector {
+    static final String CLOUD_INSTANCE_AVAILABILITY = "cloud.instance.availability";
+
+    private final InstanceProviderRegistry providerRegistry;
+
+    @Override
+    public boolean supports(InstanceVO instance) {
+        return instance != null && (instance.getVendor() == InstanceVendor.ALIYUN
+                || instance.getVendor() == InstanceVendor.TENCENT) && StringUtils.hasText(instance.getName())
+                && instance.getCredentialId() != null && StringUtils.hasText(instance.getRegionId())
+                && StringUtils.hasText(instance.getCloudInstanceId());
+    }
+
+    @Override
+    public java.util.List<MetricSample> collect(InstanceVO instance) {
+        if (!supports(instance)) {
+            return java.util.List.of();
+        }
+        Instant collectedAt = Instant.now();
+        Map<String, String> labels = Map.of("cloudInstanceId", instance.getCloudInstanceId());
+        try {
+            CloudInstanceDetailVO detail = providerRegistry.catalogFor(instance.getVendor()).getCloudInstance(
+                    instance.getCredentialId(), instance.getRegionId(), instance.getCloudInstanceId());
+            String status = detail == null ? null : detail.getStatus();
+            Map<String, String> statusLabels = StringUtils.hasText(status)
+                    ? Map.of("cloudInstanceId", instance.getCloudInstanceId(), "cloudStatus", status)
+                    : labels;
+            return java.util.List.of("RUNNING".equalsIgnoreCase(status)
+                    ? available(instance, statusLabels, collectedAt)
+                    : unavailable(instance, statusLabels, collectedAt));
+        } catch (RuntimeException error) {
+            log.warn("Failed to collect cloud instance status for {}: {}", instance.getName(), error.getMessage());
+            return java.util.List.of(unavailable(instance, labels, collectedAt));
+        }
+    }
+
+    private static MetricSample available(InstanceVO instance, Map<String, String> labels, Instant collectedAt) {
+        return new MetricSample(CLOUD_INSTANCE_AVAILABILITY, AlertDomain.CLUSTER, instance.getName(), null, labels,
+                1D, MetricAvailability.AVAILABLE, collectedAt);
+    }
+
+    private static MetricSample unavailable(InstanceVO instance, Map<String, String> labels, Instant collectedAt) {
+        return new MetricSample(CLOUD_INSTANCE_AVAILABILITY, AlertDomain.CLUSTER, instance.getName(), null, labels,
+                null, MetricAvailability.UNAVAILABLE, collectedAt);
+    }
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/instance/group/ConsumerGroupVO.java b/server/src/main/java/org/apache/rocketmq/studio/instance/group/ConsumerGroupVO.java
index b4c74ebfa..11acd4c7f 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/instance/group/ConsumerGroupVO.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/instance/group/ConsumerGroupVO.java
@@ -41,5 +41,9 @@ public class ConsumerGroupVO extends BaseEntity {
     private String deliveryOrderType;
     private int retryMaxTimes;
     private int delaySeconds;
+    /** Whether the provider successfully read broker-side consume stats for this group. */
+    private boolean consumeStatsAvailable;
+    /** Whether broker consume stats include a consumed-message timestamp for delay calculation. */
+    private boolean consumptionTimestampAvailable;
     private List<ConsumerInstanceVO> instances = new ArrayList<>();
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertCorrelationScope.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertCorrelationScope.java
new file mode 100644
index 000000000..25f20cd4f
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertCorrelationScope.java
@@ -0,0 +1,49 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import org.springframework.util.StringUtils;
+
+import java.util.Map;
+import java.util.Set;
+
+/** Shared instance and resource-scope matching for cross-domain alert handling. */
+final class AlertCorrelationScope {
+    private static final Set<String> RESOURCE_LABELS = Set.of(
+            "brokerName", "brokerAddr", "clusterName", "clusterId");
+
+    private AlertCorrelationScope() {
+    }
+
+    static boolean matches(SystemAlertVO source, SystemAlertVO candidate) {
+        if (!StringUtils.hasText(source.getInstanceId())
+                || !source.getInstanceId().trim().equals(candidate.getInstanceId())) {
+            return false;
+        }
+        Map<String, String> sourceLabels = source.getLabels() == null ? Map.of() : source.getLabels();
+        Map<String, String> candidateLabels = candidate.getLabels() == null ? Map.of() : candidate.getLabels();
+        for (String label : RESOURCE_LABELS) {
+            String sourceValue = sourceLabels.get(label);
+            String candidateValue = candidateLabels.get(label);
+            if (StringUtils.hasText(sourceValue) && StringUtils.hasText(candidateValue)
+                    && !sourceValue.trim().equals(candidateValue.trim())) {
+                return false;
+            }
+        }
+        return true;
+    }
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertDomain.java
similarity index 76%
copy from server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
copy to server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertDomain.java
index a3b47d7d5..4c74e7978 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertDomain.java
@@ -14,10 +14,9 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.rocketmq.studio.persistence.mapper;
+package org.apache.rocketmq.studio.ops.alert;
 
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import org.apache.rocketmq.studio.persistence.entity.RmqAlertRule;
-
-public interface RmqAlertRuleMapper extends BaseMapper<RmqAlertRule> {
+public enum AlertDomain {
+    BUSINESS,
+    CLUSTER
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertEvaluationResult.java
similarity index 70%
copy from server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
copy to server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertEvaluationResult.java
index a3b47d7d5..ea3faa89c 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertEvaluationResult.java
@@ -14,10 +14,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.rocketmq.studio.persistence.mapper;
+package org.apache.rocketmq.studio.ops.alert;
 
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import org.apache.rocketmq.studio.persistence.entity.RmqAlertRule;
+import org.apache.rocketmq.studio.cluster.metrics.MetricAvailability;
 
-public interface RmqAlertRuleMapper extends BaseMapper<RmqAlertRule> {
+/** A single rule/sample comparison without persistence or event side effects. */
+public record AlertEvaluationResult(boolean matches, boolean conditionMet, Double currentValue,
+        MetricAvailability availability) {
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertFingerprint.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertFingerprint.java
new file mode 100644
index 000000000..c8e0f05d9
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertFingerprint.java
@@ -0,0 +1,44 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Map;
+import java.util.TreeMap;
+
+public final class AlertFingerprint {
+    private AlertFingerprint() {
+    }
+
+    public static String of(long ruleId, String instanceId, Map<String, String> labels) {
+        StringBuilder input = new StringBuilder().append(ruleId).append('\n').append(instanceId).append('\n');
+        new TreeMap<>(labels == null ? Map.of() : labels).forEach((key, value) -> input.append(key)
+                .append('=').append(value).append('\n'));
+        try {
+            byte[] bytes = MessageDigest.getInstance("SHA-256").digest(input.toString().getBytes(StandardCharsets.UTF_8));
+            StringBuilder fingerprint = new StringBuilder(bytes.length * 2);
+            for (byte value : bytes) {
+                fingerprint.append(String.format("%02x", value));
+            }
+            return fingerprint.toString();
+        } catch (NoSuchAlgorithmException impossible) {
+            throw new IllegalStateException("SHA-256 is not available", impossible);
+        }
+    }
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertNotificationSuppressionService.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertNotificationSuppressionService.java
new file mode 100644
index 000000000..06297f926
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertNotificationSuppressionService.java
@@ -0,0 +1,65 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/** Finds active cluster incidents that make a business notification redundant. */
+@Service
+@RequiredArgsConstructor
+public class AlertNotificationSuppressionService {
+    private static final Duration CORRELATION_WINDOW = Duration.ofMinutes(30);
+
+    private final AlertRepository alertRepository;
+
+    public Optional<SystemAlertVO> findSuppressingClusterAlert(SystemAlertVO event) {
+        if (event.getDomain() != AlertDomain.BUSINESS || event.getTime() == null) {
+            return Optional.empty();
+        }
+        LocalDateTime windowStart = event.getTime().minus(CORRELATION_WINDOW);
+        List<SystemAlertVO> candidates = alertRepository.findAlertsPage(new SystemAlertQuery(null, AlertDomain.CLUSTER,
+                        event.getInstanceId(), null, null, null, windowStart, event.getTime(), 1, 100))
+                .getItems().stream()
+                .filter(candidate -> AlertCorrelationScope.matches(event, candidate))
+                .toList();
+        Map<String, SystemAlertVO> latestByIncident = new HashMap<>();
+        for (SystemAlertVO candidate : candidates) {
+            String incident = candidate.getFingerprint() == null ? String.valueOf(candidate.getId())
+                    : candidate.getFingerprint();
+            latestByIncident.merge(incident, candidate, (left, right) -> later(left, right) ? left : right);
+        }
+        return latestByIncident.values().stream()
+                .filter(candidate -> "FIRING".equalsIgnoreCase(candidate.getTransition()))
+                .max(Comparator.comparing(SystemAlertVO::getTime, Comparator.nullsLast(Comparator.naturalOrder())));
+    }
+
+    private static boolean later(SystemAlertVO left, SystemAlertVO right) {
+        if (left.getTime() == null) {
+            return false;
+        }
+        return right.getTime() == null || !left.getTime().isBefore(right.getTime());
+    }
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertNotificationTemplate.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertNotificationTemplate.java
new file mode 100644
index 000000000..1db1c9d18
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertNotificationTemplate.java
@@ -0,0 +1,73 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+
+/** Renders the fixed, documented placeholders allowed in an alert notification. */
+final class AlertNotificationTemplate {
+    static final String DEFAULT_TEMPLATE = "[${level}] ${title} - ${description}\nLabels: ${labels}";
+    private static final Set<String> RATIO_METRICS = Set.of(
+            "broker.disk.usage_ratio",
+            "broker.jvm.heap.usage_ratio",
+            "broker.send_queue.usage_ratio");
+
+    private AlertNotificationTemplate() {
+    }
+
+    static String render(String template, SystemAlertVO alert, AlertRuleVO rule) {
+        String result = hasText(template) ? template.trim() : DEFAULT_TEMPLATE;
+        for (Map.Entry<String, String> entry : values(alert, rule).entrySet()) {
+            result = result.replace("${" + entry.getKey() + "}", entry.getValue());
+        }
+        return result;
+    }
+
+    private static Map<String, String> values(SystemAlertVO alert, AlertRuleVO rule) {
+        Map<String, String> values = new LinkedHashMap<>();
+        values.put("ruleName", text(rule == null ? null : rule.getName()));
+        values.put("title", text(alert.getTitle()));
+        values.put("description", text(alert.getDescription()));
+        values.put("transition", text(alert.getTransition()));
+        values.put("metric", text(rule == null ? null : rule.getMetric()));
+        values.put("instanceId", text(alert.getInstanceId()));
+        values.put("value", formattedValue(alert, rule));
+        values.put("threshold", rule == null ? "" : String.valueOf(rule.getThreshold()));
+        values.put("thresholdUnit", text(rule == null ? null : rule.getThresholdUnit()));
+        values.put("level", text(alert.getLevel()));
+        values.put("time", alert.getTime() == null ? "" : alert.getTime().toString());
+        values.put("labels", formatLabels(alert.getLabels()));
+        return values;
+    }
+
+    private static String formattedValue(SystemAlertVO alert, AlertRuleVO rule) {
+        if (alert.getCurrentValue() == null) {
+            return "";
+        }
+        if (rule != null && "%".equals(rule.getThresholdUnit()) && RATIO_METRICS.contains(rule.getMetric())) {
+            return String.valueOf(alert.getCurrentValue() * 100);
+        }
+        return String.valueOf(alert.getCurrentValue());
+    }
+
+    private static String formatLabels(Map<String, String> labels) {
+        if (labels == null || labels.isEmpty()) return "";
+        return labels.entrySet().stream().sorted(Map.Entry.comparingByKey())
+                .map(entry -> entry.getKey() + "=" + entry.getValue())
+                .collect(java.util.stream.Collectors.joining(", "));
+    }
+
+    private static boolean hasText(String value) {
+        return value != null && !value.isBlank();
+    }
+
+    private static String text(Object value) {
+        return value == null ? "" : String.valueOf(value);
+    }
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRepository.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRepository.java
index 92ca40b05..45ef061d6 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRepository.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRepository.java
@@ -16,25 +16,28 @@
  */
 package org.apache.rocketmq.studio.ops.alert;
 
+import org.apache.rocketmq.studio.common.domain.PageResult;
 
 import java.util.List;
 import java.util.Optional;
 
-import org.apache.rocketmq.studio.common.domain.PageResult;
-
 public interface AlertRepository {
     List<AlertRuleVO> findAllRules();
 
     PageResult<AlertRuleVO> findRulePage(String search, Boolean enabled, int page, int pageSize);
 
+    PageResult<AlertRuleVO> findRulesPage(AlertRuleQuery query);
+
     Optional<AlertRuleVO> findRuleById(Long id);
 
     List<AlertRuleVO> findRulesByIds(List<Long> ids);
 
-    AlertRuleVO saveRule(AlertRuleVO rule);
+    AlertRuleVO insertRule(AlertRuleVO rule);
 
     boolean replaceRule(AlertRuleVO rule);
 
+    void markRuleTriggered(Long id, String triggeredAt);
+
     boolean deleteRule(Long id);
 
     List<SystemAlertVO> findAlerts(String level);
@@ -43,6 +46,10 @@ public interface AlertRepository {
 
     Optional<SystemAlertVO> findAlertById(Long id);
 
+    PageResult<SystemAlertVO> findAlertsPage(SystemAlertQuery query);
+
+    SystemAlertVO saveAlert(SystemAlertVO alert);
+
     boolean acknowledgeAlert(SystemAlertVO alert);
 
     int deleteAcknowledgedAlerts();
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleController.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleController.java
index 36d75c52c..44b1e0b0f 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleController.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleController.java
@@ -17,6 +17,7 @@
 package org.apache.rocketmq.studio.ops.alert;
 
 import org.apache.rocketmq.studio.common.domain.Result;
+import jakarta.servlet.http.HttpServletRequest;
 import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import jakarta.validation.Valid;
@@ -31,24 +32,43 @@ import org.springframework.web.bind.annotation.RestController;
 import java.util.List;
 
 @RestController
-@RequestMapping("/api/alert-rules")
+@RequestMapping({"/api/alert-rules", "/api/business-alert-rules"})
 @RequiredArgsConstructor
 public class AlertRuleController {
 
     private final AlertService alertService;
+    private final NativeAlertRuleTestService nativeAlertRuleTestService;
+    private final NativeAlertMetricCatalogService metricCatalogService;
+    private final AlertRuleTransferService transferService;
 
     @GetMapping
-    public Result<List<AlertRuleVO>> listRules() {
-        return Result.ok(alertService.listRules());
+    public Result<List<AlertRuleVO>> listRules(HttpServletRequest request) {
+        // Keep the original read endpoint complete for existing API clients while the
+        // domain-specific route powers the Business Alerts page.
+        if (request.getRequestURI().endsWith("/api/alert-rules")) {
+            return Result.ok(alertService.listRules());
+        }
+        return Result.ok(alertService.listRules(AlertDomain.BUSINESS));
     }
 
     @GetMapping("/page")
     public Result<PageResult<AlertRuleVO>> listRulesPage(
+            HttpServletRequest request,
             @RequestParam(required = false) String search,
             @RequestParam(required = false) Boolean enabled,
             @RequestParam(defaultValue = "1") int page,
             @RequestParam(defaultValue = "20") int pageSize) {
-        return Result.ok(alertService.listRules(search, enabled, page, pageSize));
+        // The legacy route keeps the cross-domain pagination contract; the business route
+        // restricts the page to business rules.
+        if (request.getRequestURI().endsWith("/api/alert-rules/page")) {
+            return Result.ok(alertService.listRules(search, enabled, page, pageSize));
+        }
+        return Result.ok(alertService.listRules(AlertDomain.BUSINESS, search, enabled, page, pageSize));
+    }
+
+    @GetMapping("/runtime")
+    public Result<List<AlertRuleRuntimeVO>> listRuntime() {
+        return Result.ok(alertService.listRuleRuntime(AlertDomain.BUSINESS));
     }
 
     @GetMapping("/export")
@@ -56,9 +76,22 @@ public class AlertRuleController {
         return Result.ok(new AlertRulesYamlVO(alertService.exportPrometheusRulesYaml()));
     }
 
+    @GetMapping("/transfer")
+    public Result<AlertRuleTransferDTO> exportTransfer() {
+        return Result.ok(transferService.exportRules(AlertDomain.BUSINESS));
+    }
+
+    @PostMapping("/import")
+    public Result<List<AlertRuleVO>> importRules(@Valid @RequestBody(required = false) AlertRuleTransferDTO transfer) {
+        return Result.ok(transferService.importRules(AlertDomain.BUSINESS, transfer));
+    }
+
     @PostMapping("/create")
     public Result<AlertRuleVO> createRule(@Valid @RequestBody(required = false) AlertRuleRequestDTO rule) {
-        return Result.ok(alertService.createRule(requireAlertRule(rule).toAlertRuleVO()));
+        AlertRuleVO candidate = requireAlertRule(rule).toAlertRuleVO();
+        candidate.setDomain(AlertDomain.BUSINESS);
+        metricCatalogService.validate(candidate);
+        return Result.ok(alertService.createRule(AlertDomain.BUSINESS, candidate));
     }
 
     @PostMapping("/update")
@@ -67,30 +100,41 @@ public class AlertRuleController {
         if (request.getId() == null) {
             throw new BusinessException(400, "id is required");
         }
-        return Result.ok(alertService.updateRule(request.toAlertRuleVO()));
+        AlertRuleVO candidate = request.toAlertRuleVO();
+        candidate.setDomain(AlertDomain.BUSINESS);
+        metricCatalogService.validate(candidate);
+        return Result.ok(alertService.updateRule(AlertDomain.BUSINESS, candidate));
+    }
+
+    @PostMapping("/test")
+    public Result<AlertRuleTestResultVO> testRule(@Valid @RequestBody(required = false) AlertRuleRequestDTO rule) {
+        AlertRuleVO candidate = requireAlertRule(rule).toAlertRuleVO();
+        candidate.setDomain(AlertDomain.BUSINESS);
+        metricCatalogService.validate(candidate);
+        return Result.ok(nativeAlertRuleTestService.test(candidate));
     }
 
     @PostMapping("/toggle")
     public Result<AlertRuleVO> toggleRule(@Valid @RequestBody ToggleAlertRuleDTO request) {
-        return Result.ok(alertService.toggleRule(request.getId(), request.getEnabled()));
+        return Result.ok(alertService.toggleRule(AlertDomain.BUSINESS, request.getId(), request.getEnabled()));
     }
 
     @PostMapping("/delete")
     public Result<Void> deleteRule(@Valid @RequestBody DeleteAlertRuleDTO request) {
-        alertService.deleteRule(request.getId());
+        alertService.deleteRule(AlertDomain.BUSINESS, request.getId());
         return Result.ok();
     }
 
     @PostMapping("/bulk-toggle")
     public Result<AlertRuleBulkResultVO> bulkToggle(
             @Valid @RequestBody BulkToggleAlertRulesDTO request) {
-        return Result.ok(alertService.bulkToggleRules(request.getIds(), request.getEnabled()));
+        return Result.ok(alertService.bulkToggleRules(AlertDomain.BUSINESS, request.getIds(), request.getEnabled()));
     }
 
     @PostMapping("/bulk-delete")
     public Result<AlertRuleBulkResultVO> bulkDelete(
             @Valid @RequestBody BulkDeleteAlertRulesDTO request) {
-        return Result.ok(alertService.bulkDeleteRules(request.getIds()));
+        return Result.ok(alertService.bulkDeleteRules(AlertDomain.BUSINESS, request.getIds()));
     }
 
     private AlertRuleRequestDTO requireAlertRule(AlertRuleRequestDTO rule) {
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleDuration.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleDuration.java
new file mode 100644
index 000000000..fa67e1b43
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleDuration.java
@@ -0,0 +1,65 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.springframework.util.StringUtils;
+
+import java.time.Duration;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/** Parses the compact duration syntax accepted by alert-rule requests. */
+final class AlertRuleDuration {
+    private static final Pattern PART = Pattern.compile("(\\d+)(ms|s|m|h|d|w|y)");
+
+    private AlertRuleDuration() {
+    }
+
+    static Duration parse(String value) {
+        if (!StringUtils.hasText(value)) {
+            return Duration.ZERO;
+        }
+        Matcher matcher = PART.matcher(value.trim());
+        Duration result = Duration.ZERO;
+        int end = 0;
+        while (matcher.find()) {
+            if (matcher.start() != end) {
+                throw invalid(value);
+            }
+            long amount;
+            try {
+                amount = Long.parseLong(matcher.group(1));
+            } catch (NumberFormatException error) {
+                throw invalid(value);
+            }
+            result = result.plus(toDuration(amount, matcher.group(2), value));
+            end = matcher.end();
+        }
+        if (end != value.trim().length()) {
+            throw invalid(value);
+        }
+        return result;
+    }
+
+    private static BusinessException invalid(String value) {
+        return new BusinessException(400, "Invalid alert duration: " + value);
+    }
+
+    private static Duration toDuration(long amount, String unit, String source) {
+        return switch (unit) {
+            case "ms" -> Duration.ofMillis(amount);
+            case "s" -> Duration.ofSeconds(amount);
+            case "m" -> Duration.ofMinutes(amount);
+            case "h" -> Duration.ofHours(amount);
+            case "d" -> Duration.ofDays(amount);
+            case "w" -> Duration.ofDays(Math.multiplyExact(amount, 7));
+            case "y" -> Duration.ofDays(Math.multiplyExact(amount, 365));
+            default -> throw invalid(source);
+        };
+    }
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleEvaluator.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleEvaluator.java
new file mode 100644
index 000000000..0eb6d495e
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleEvaluator.java
@@ -0,0 +1,64 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import org.apache.rocketmq.studio.cluster.metrics.MetricAvailability;
+import org.apache.rocketmq.studio.cluster.metrics.MetricSample;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+/** Evaluates the existing structured rule operators against one native metric sample. */
+@Component
+public class AlertRuleEvaluator {
+    public AlertEvaluationResult evaluate(AlertRuleVO rule, MetricSample sample) {
+        if (rule == null || sample == null
+                || ruleDomain(rule) != sample.domain()
+                || !StringUtils.hasText(rule.getMetric())
+                || !rule.getMetric().trim().equals(sample.metricKey())) {
+            return new AlertEvaluationResult(false, false, null, sample == null ? null : sample.availability());
+        }
+        if (sample.availability() != MetricAvailability.AVAILABLE) {
+            boolean unavailableCondition = sample.availability() == MetricAvailability.UNAVAILABLE
+                    && "UNAVAILABLE".equals(rule.getOperator());
+            return new AlertEvaluationResult(true, unavailableCondition, null, sample.availability());
+        }
+        double value = sample.value();
+        return new AlertEvaluationResult(true, compare(value, rule.getOperator(),
+                AlertRuleSemanticFingerprint.normalizedThreshold(rule)), value,
+                sample.availability());
+    }
+
+    private static AlertDomain ruleDomain(AlertRuleVO rule) {
+        return rule.getDomain() == null ? AlertDomain.BUSINESS : rule.getDomain();
+    }
+
+    private static boolean compare(double value, String operator, double threshold) {
+        if (operator == null) {
+            return false;
+        }
+        return switch (operator.trim()) {
+            case ">" -> value > threshold;
+            case ">=" -> value >= threshold;
+            case "<" -> value < threshold;
+            case "<=" -> value <= threshold;
+            case "==" -> Double.compare(value, threshold) == 0;
+            case "!=" -> Double.compare(value, threshold) != 0;
+            default -> false;
+        };
+    }
+
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleQuery.java
similarity index 76%
copy from server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
copy to server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleQuery.java
index a3b47d7d5..7b8463153 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleQuery.java
@@ -14,10 +14,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.rocketmq.studio.persistence.mapper;
+package org.apache.rocketmq.studio.ops.alert;
 
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import org.apache.rocketmq.studio.persistence.entity.RmqAlertRule;
-
-public interface RmqAlertRuleMapper extends BaseMapper<RmqAlertRule> {
+/** Server-side filters for the paged alert-rule feed. */
+public record AlertRuleQuery(AlertDomain domain, String search, Boolean enabled, int page, int pageSize) {
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleRequestDTO.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleRequestDTO.java
index 52c73c5e6..9fcffdab2 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleRequestDTO.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleRequestDTO.java
@@ -17,7 +17,9 @@
 package org.apache.rocketmq.studio.ops.alert;
 
 import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Min;
 import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
 import lombok.Data;
 
 import java.util.List;
@@ -28,13 +30,20 @@ public class AlertRuleRequestDTO {
     @NotBlank(message = "name is required")
     private String name;
     private String metric;
-    @Pattern(regexp = ">|>=|<|<=|==|!=", message = "operator is invalid")
+    @Pattern(regexp = ">|>=|<|<=|==|!=|UNAVAILABLE", message = "operator is invalid")
     private String operator;
     private double threshold;
     private String thresholdUnit;
     @Pattern(regexp = "(?:[0-9]+(?:ms|s|m|h|d|w|y))+", message = "duration is invalid")
     private String duration;
-    private List<@NotBlank(message = "channel must not be blank") String> channels;
+    @Pattern(regexp = "LAST|MAX|MIN|AVG|SUM", flags = Pattern.Flag.CASE_INSENSITIVE,
+            message = "aggregation is invalid")
+    private String aggregation;
+    @Min(value = 0, message = "windowSeconds must not be negative")
+    private Integer windowSeconds;
+    private List<@NotBlank(message = "channel must not be blank")
+            @Pattern(regexp = "dingtalk|sms|email", flags = Pattern.Flag.CASE_INSENSITIVE,
+                    message = "channel is unsupported") String> channels;
     private boolean enabled;
     private String description;
     private String brokerName;
@@ -42,6 +51,15 @@ public class AlertRuleRequestDTO {
     @Pattern(regexp = "critical|warning|info", flags = Pattern.Flag.CASE_INSENSITIVE,
             message = "severity is invalid")
     private String severity;
+    private String instanceId;
+    private String consumerGroup;
+    private String topic;
+    @Min(value = 1, message = "consecutiveSamples must be at least 1")
+    private Integer consecutiveSamples;
+    @Pattern(regexp = "(?:[0-9]+(?:ms|s|m|h|d|w|y))+", message = "reminderInterval is invalid")
+    private String reminderInterval;
+    @Size(max = 4000, message = "notificationTemplate must not exceed 4000 characters")
+    private String notificationTemplate;
 
     public AlertRuleVO toAlertRuleVO() {
         return AlertRuleVO.builder()
@@ -52,12 +70,20 @@ public class AlertRuleRequestDTO {
                 .threshold(threshold)
                 .thresholdUnit(thresholdUnit)
                 .duration(duration)
+                .aggregation(aggregation == null ? "LAST" : aggregation)
+                .windowSeconds(windowSeconds == null ? 0 : windowSeconds)
                 .channels(normalizeChannels(channels))
                 .enabled(enabled)
                 .description(description)
                 .brokerName(brokerName)
                 .clusterName(clusterName)
                 .severity(severity)
+                .instanceId(instanceId)
+                .consumerGroup(consumerGroup)
+                .topic(topic)
+                .consecutiveSamples(consecutiveSamples == null ? 1 : consecutiveSamples)
+                .reminderInterval(reminderInterval == null ? "30m" : reminderInterval)
+                .notificationTemplate(notificationTemplate)
                 .build();
     }
 
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertVO.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleRuntimeVO.java
similarity index 69%
copy from server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertVO.java
copy to server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleRuntimeVO.java
index db9c06a49..9c03627c5 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertVO.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleRuntimeVO.java
@@ -16,23 +16,19 @@
  */
 package org.apache.rocketmq.studio.ops.alert;
 
-import org.apache.rocketmq.studio.common.domain.enums.AlertLevel;
-import lombok.AllArgsConstructor;
 import lombok.Builder;
-import lombok.Data;
-import lombok.NoArgsConstructor;
+import lombok.Value;
 
 import java.time.LocalDateTime;
 
-@Data
+@Value
 @Builder
-@NoArgsConstructor
-@AllArgsConstructor
-public class SystemAlertVO {
-    private Long id;
-    private AlertLevel level;
-    private String title;
-    private String description;
-    private LocalDateTime time;
-    private boolean acknowledged;
+public class AlertRuleRuntimeVO {
+    Long ruleId;
+    String fingerprint;
+    AlertStateStatus status;
+    int consecutiveHits;
+    Double currentValue;
+    LocalDateTime lastNotifiedAt;
+    LocalDateTime nextReminderAt;
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleSemanticFingerprint.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleSemanticFingerprint.java
new file mode 100644
index 000000000..fa2c06a27
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleSemanticFingerprint.java
@@ -0,0 +1,87 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+
+/** Stable identity for the fields that determine when a rule evaluates to true. */
+final class AlertRuleSemanticFingerprint {
+    private static final java.util.Set<String> RATIO_METRICS = java.util.Set.of(
+            "broker.disk.usage_ratio",
+            "broker.jvm.heap.usage_ratio",
+            "broker.send_queue.usage_ratio");
+
+    private AlertRuleSemanticFingerprint() {
+    }
+
+    static String of(AlertRuleVO rule) {
+        StringBuilder value = new StringBuilder();
+        append(value, rule.getDomain() == null ? AlertDomain.BUSINESS.name() : rule.getDomain().name());
+        append(value, normalize(rule.getInstanceId()));
+        append(value, normalize(rule.getMetric()));
+        append(value, normalizeUpper(rule.getOperator()));
+        append(value, normalizeThreshold(rule));
+        append(value, normalizeLower(rule.getDuration()));
+        append(value, normalizeUpper(defaultIfBlank(rule.getAggregation(), "LAST")));
+        append(value, Integer.toString(Math.max(0, rule.getWindowSeconds())));
+        append(value, normalize(rule.getBrokerName()));
+        append(value, normalize(rule.getClusterName()));
+        append(value, normalize(rule.getConsumerGroup()));
+        append(value, normalize(rule.getTopic()));
+        append(value, Integer.toString(Math.max(1, rule.getConsecutiveSamples())));
+        return sha256(value.toString());
+    }
+
+    private static void append(StringBuilder target, String value) {
+        target.append(value.length()).append(':').append(value);
+    }
+
+    private static String normalize(String value) {
+        return value == null ? "" : value.trim();
+    }
+
+    private static String normalizeUpper(String value) {
+        return normalize(value).toUpperCase(java.util.Locale.ROOT);
+    }
+
+    private static String normalizeLower(String value) {
+        return normalize(value).toLowerCase(java.util.Locale.ROOT);
+    }
+
+    private static String defaultIfBlank(String value, String fallback) {
+        String normalized = normalize(value);
+        return normalized.isEmpty() ? fallback : normalized;
+    }
+
+    static double normalizedThreshold(AlertRuleVO rule) {
+        if ("%".equals(rule.getThresholdUnit()) && RATIO_METRICS.contains(rule.getMetric())) {
+            return rule.getThreshold() / 100D;
+        }
+        return rule.getThreshold();
+    }
+
+    private static String normalizeThreshold(AlertRuleVO rule) {
+        double threshold = normalizedThreshold(rule);
+        if (threshold == 0D) {
+            return "0";
+        }
+        return BigDecimal.valueOf(threshold).stripTrailingZeros().toPlainString();
+    }
+
+    private static String sha256(String value) {
+        try {
+            return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
+                    .digest(value.getBytes(StandardCharsets.UTF_8)));
+        } catch (NoSuchAlgorithmException impossible) {
+            throw new IllegalStateException("SHA-256 is unavailable", impossible);
+        }
+    }
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertVO.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleState.java
similarity index 64%
copy from server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertVO.java
copy to server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleState.java
index db9c06a49..cc7a639d3 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertVO.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleState.java
@@ -16,23 +16,12 @@
  */
 package org.apache.rocketmq.studio.ops.alert;
 
-import org.apache.rocketmq.studio.common.domain.enums.AlertLevel;
-import lombok.AllArgsConstructor;
-import lombok.Builder;
-import lombok.Data;
-import lombok.NoArgsConstructor;
+import java.time.Instant;
 
-import java.time.LocalDateTime;
-
-@Data
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-public class SystemAlertVO {
-    private Long id;
-    private AlertLevel level;
-    private String title;
-    private String description;
-    private LocalDateTime time;
-    private boolean acknowledged;
+/** Persistable state for a rule/resource fingerprint, independent from individual event rows. */
+public record AlertRuleState(AlertStateStatus status, int consecutiveHits, Double currentValue,
+        Instant firstPendingAt, Instant firedAt, Instant lastNotifiedAt, Instant resolvedAt) {
+    public static AlertRuleState initial() {
+        return new AlertRuleState(AlertStateStatus.OK, 0, null, null, null, null, null);
+    }
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleTestResultVO.java
similarity index 69%
copy from server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
copy to server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleTestResultVO.java
index a3b47d7d5..ed9d817ae 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleTestResultVO.java
@@ -14,10 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.rocketmq.studio.persistence.mapper;
+package org.apache.rocketmq.studio.ops.alert;
 
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import org.apache.rocketmq.studio.persistence.entity.RmqAlertRule;
+import lombok.Builder;
 
-public interface RmqAlertRuleMapper extends BaseMapper<RmqAlertRule> {
+import java.util.List;
+import java.util.Map;
+
+@Builder
+public record AlertRuleTestResultVO(List<Sample> samples) {
+    @Builder
+    public record Sample(Map<String, String> labels, String availability, Double currentValue, boolean conditionMet,
+            String unavailableReason) {
+    }
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleTransferDTO.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleTransferDTO.java
new file mode 100644
index 000000000..0faa53229
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleTransferDTO.java
@@ -0,0 +1,29 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+import java.util.List;
+
+/** Portable native-alert rule document. IDs and runtime state are deliberately excluded. */
+@Data
+public class AlertRuleTransferDTO {
+    public static final int VERSION = 1;
+
+    @NotNull(message = "version is required")
+    private Integer version;
+
+    @NotNull(message = "domain is required")
+    private AlertDomain domain;
+
+    @NotEmpty(message = "rules must not be empty")
+    private List<@Valid AlertRuleRequestDTO> rules;
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleTransferService.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleTransferService.java
new file mode 100644
index 000000000..c40549154
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleTransferService.java
@@ -0,0 +1,86 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import lombok.RequiredArgsConstructor;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+public class AlertRuleTransferService {
+    private static final int MAX_RULES_PER_IMPORT = 200;
+
+    private final AlertService alertService;
+    private final NativeAlertMetricCatalogService metricCatalogService;
+
+    public AlertRuleTransferDTO exportRules(AlertDomain domain) {
+        AlertRuleTransferDTO transfer = new AlertRuleTransferDTO();
+        transfer.setVersion(AlertRuleTransferDTO.VERSION);
+        transfer.setDomain(domain);
+        transfer.setRules(alertService.listRules(domain).stream().map(this::toRequest).toList());
+        return transfer;
+    }
+
+    @Transactional
+    public List<AlertRuleVO> importRules(AlertDomain domain, AlertRuleTransferDTO transfer) {
+        validateEnvelope(domain, transfer);
+        List<AlertRuleVO> candidates = transfer.getRules().stream().map(request -> {
+            AlertRuleVO candidate = request.toAlertRuleVO();
+            candidate.setId(null);
+            candidate.setDomain(domain);
+            metricCatalogService.validate(candidate);
+            return candidate;
+        }).toList();
+        return candidates.stream().map(candidate -> alertService.createRule(domain, candidate)).toList();
+    }
+
+    private static void validateEnvelope(AlertDomain domain, AlertRuleTransferDTO transfer) {
+        if (transfer == null) {
+            throw new BusinessException(400, "Alert rule import document is required");
+        }
+        if (!Integer.valueOf(AlertRuleTransferDTO.VERSION).equals(transfer.getVersion())) {
+            throw new BusinessException(400, "Unsupported alert rule import version");
+        }
+        if (transfer.getDomain() != domain) {
+            throw new BusinessException(400, "Alert rule import domain does not match this page");
+        }
+        if (transfer.getRules() == null || transfer.getRules().isEmpty()
+                || transfer.getRules().size() > MAX_RULES_PER_IMPORT) {
+            throw new BusinessException(400, "Alert rule import must contain between 1 and "
+                    + MAX_RULES_PER_IMPORT + " rules");
+        }
+    }
+
+    private AlertRuleRequestDTO toRequest(AlertRuleVO rule) {
+        AlertRuleRequestDTO request = new AlertRuleRequestDTO();
+        request.setName(rule.getName());
+        request.setMetric(rule.getMetric());
+        request.setOperator(rule.getOperator());
+        request.setThreshold(rule.getThreshold());
+        request.setThresholdUnit(rule.getThresholdUnit());
+        request.setDuration(rule.getDuration());
+        request.setAggregation(rule.getAggregation());
+        request.setWindowSeconds(rule.getWindowSeconds());
+        request.setChannels(rule.getChannels());
+        request.setEnabled(rule.isEnabled());
+        request.setDescription(rule.getDescription());
+        request.setBrokerName(rule.getBrokerName());
+        request.setClusterName(rule.getClusterName());
+        request.setSeverity(rule.getSeverity());
+        request.setInstanceId(rule.getInstanceId());
+        request.setConsumerGroup(rule.getConsumerGroup());
+        request.setTopic(rule.getTopic());
+        request.setConsecutiveSamples(rule.getConsecutiveSamples());
+        request.setReminderInterval(rule.getReminderInterval());
+        request.setNotificationTemplate(rule.getNotificationTemplate());
+        return request;
+    }
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleVO.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleVO.java
index 1ad72a1db..49fd1ae66 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleVO.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleVO.java
@@ -29,12 +29,18 @@ import java.util.List;
 @AllArgsConstructor
 public class AlertRuleVO {
     private Long id;
+    @Builder.Default
+    private AlertDomain domain = AlertDomain.BUSINESS;
     private String name;
     private String metric;
     private String operator;
     private double threshold;
     private String thresholdUnit;
     private String duration;
+    @Builder.Default
+    private String aggregation = "LAST";
+    @Builder.Default
+    private int windowSeconds = 0;
     private List<String> channels;
     private boolean enabled;
     private String lastTriggered;
@@ -45,4 +51,17 @@ public class AlertRuleVO {
     private String clusterName;
     /** Alert severity: critical, warning, info */
     private String severity;
+    /** Studio instance ID required when evaluating a rule against native metric samples. */
+    private String instanceId;
+    /** Optional consumer group selector for consumer-lag metrics. */
+    private String consumerGroup;
+    /** Optional topic selector for topic backlog metrics. */
+    private String topic;
+    /** Number of consecutive matching native samples required before firing. */
+    @Builder.Default
+    private int consecutiveSamples = 1;
+    @Builder.Default
+    private String reminderInterval = "30m";
+    /** Optional per-rule notification body template. */
+    private String notificationTemplate;
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSchemaMigration.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSchemaMigration.java
new file mode 100644
index 000000000..ac2b18b90
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSchemaMigration.java
@@ -0,0 +1,215 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.ApplicationArguments;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.stereotype.Component;
+
+import javax.sql.DataSource;
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.List;
+
+/** Adds native-alerting fields to alert tables created by earlier Studio builds. */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class AlertSchemaMigration implements ApplicationRunner {
+    private static final List<Table> TABLES = List.of(
+            new Table("rmq_metric_snapshot", "CREATE TABLE rmq_metric_snapshot ("
+                    + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
+                    + "gmt_create DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
+                    + "gmt_modified DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
+                    + "instance_id VARCHAR(128) NOT NULL, "
+                    + "metric_key VARCHAR(128) NOT NULL, domain VARCHAR(16) NOT NULL, cluster_id VARCHAR(128), "
+                    + "labels_hash CHAR(64) NOT NULL, labels_json TEXT NOT NULL, `value` DOUBLE, "
+                    + "availability VARCHAR(16) NOT NULL, collected_at DATETIME NOT NULL)"),
+            new Table("rmq_alert_collection_lease", "CREATE TABLE rmq_alert_collection_lease ("
+                    + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
+                    + "gmt_create DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
+                    + "gmt_modified DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
+                    + "lease_name VARCHAR(128) NOT NULL, "
+                    + "holder_id VARCHAR(64) NOT NULL, expires_at DATETIME NOT NULL, "
+                    + "CONSTRAINT uk_alert_collection_lease_name UNIQUE (lease_name))"),
+            new Table("rmq_alert_state", "CREATE TABLE rmq_alert_state ("
+                    + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
+                    + "gmt_create DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
+                    + "gmt_modified DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
+                    + "rule_id BIGINT NOT NULL, fingerprint CHAR(64) NOT NULL, "
+                    + "status VARCHAR(16) NOT NULL, consecutive_hits INT NOT NULL DEFAULT 0, current_value DOUBLE, "
+                    + "first_pending_at DATETIME, fired_at DATETIME, last_notified_at DATETIME, resolved_at DATETIME, version INT NOT NULL DEFAULT 0, "
+                    + "CONSTRAINT uk_alert_state_rule_fingerprint UNIQUE (rule_id, fingerprint))"),
+            new Table("rmq_alert_silence", "CREATE TABLE rmq_alert_silence ("
+                    + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
+                    + "gmt_create DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
+                    + "gmt_modified DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
+                    + "domain VARCHAR(16), rule_id BIGINT, instance_id VARCHAR(128), "
+                    + "labels_json TEXT, starts_at DATETIME NOT NULL, ends_at DATETIME NOT NULL, reason VARCHAR(512), "
+                    + "created_by VARCHAR(128) NOT NULL)"),
+            new Table("rmq_alert_notification_outbox", "CREATE TABLE rmq_alert_notification_outbox ("
+                    + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
+                    + "gmt_create DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
+                    + "gmt_modified DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
+                    + "alert_id BIGINT NOT NULL, channel VARCHAR(32) NOT NULL, "
+                    + "status VARCHAR(16) NOT NULL, attempt_count INT NOT NULL DEFAULT 0, next_attempt_at DATETIME NOT NULL, "
+                    + "sending_started_at DATETIME, claim_token VARCHAR(64), last_error VARCHAR(1000), delivered_at DATETIME, "
+                    + "CONSTRAINT uk_alert_notification_outbox UNIQUE (alert_id, channel))"));
+    private static final List<Column> COLUMNS = List.of(
+            new Column("rmq_alert_rule", "aggregation", "VARCHAR(16) NOT NULL DEFAULT 'LAST'"),
+            new Column("rmq_alert_rule", "window_seconds", "INT NOT NULL DEFAULT 0"),
+            new Column("rmq_alert_rule", "domain", "VARCHAR(16) NOT NULL DEFAULT 'BUSINESS'"),
+            new Column("rmq_alert_rule", "instance_id", "VARCHAR(128)"),
+            new Column("rmq_alert_rule", "consumer_group", "VARCHAR(255)"),
+            new Column("rmq_alert_rule", "topic", "VARCHAR(255)"),
+            new Column("rmq_alert_rule", "consecutive_samples", "INT NOT NULL DEFAULT 1"),
+            new Column("rmq_alert_rule", "reminder_interval", "VARCHAR(32) NOT NULL DEFAULT '30m'"),
+            new Column("rmq_alert_rule", "notification_template", "TEXT"),
+            new Column("rmq_alert_rule", "semantic_fingerprint", "CHAR(64)"),
+            new Column("rmq_alert_state", "last_notified_at", "DATETIME"),
+            new Column("rmq_system_alert", "acknowledged_by", "VARCHAR(128)"),
+            new Column("rmq_system_alert", "acknowledged_at", "DATETIME"),
+            new Column("rmq_system_alert", "domain", "VARCHAR(16)"),
+            new Column("rmq_system_alert", "rule_id", "BIGINT"),
+            new Column("rmq_system_alert", "fingerprint", "CHAR(64)"),
+            new Column("rmq_system_alert", "transition", "VARCHAR(16)"),
+            new Column("rmq_system_alert", "instance_id", "VARCHAR(128)"),
+            new Column("rmq_system_alert", "current_value", "DOUBLE"),
+            new Column("rmq_system_alert", "notification_suppressed", "TINYINT(1) NOT NULL DEFAULT 0"),
+            new Column("rmq_system_alert", "suppression_cause_alert_id", "BIGINT"),
+            new Column("rmq_system_alert", "suppression_reason", "VARCHAR(512)"),
+            new Column("rmq_system_alert", "labels_json", "TEXT"),
+            new Column("rmq_alert_notification_outbox", "sending_started_at", "DATETIME"),
+            new Column("rmq_alert_notification_outbox", "claim_token", "VARCHAR(64)"),
+            new Column("rmq_alert_notification_outbox", "message_content", "TEXT"));
+    private static final List<Index> INDEXES = List.of(
+            new Index("rmq_metric_snapshot", "idx_metric_snapshot_lookup", "instance_id, metric_key, collected_at"),
+            new Index("rmq_metric_snapshot", "idx_metric_snapshot_retention", "collected_at"),
+            new Index("rmq_alert_silence", "idx_alert_silence_active", "starts_at, ends_at"),
+            new Index("rmq_alert_silence", "idx_alert_silence_scope", "domain, rule_id, instance_id"),
+            new Index("rmq_alert_notification_outbox", "idx_alert_notification_ready", "status, next_attempt_at"),
+            new Index("rmq_alert_rule", "uk_alert_rule_semantic_fingerprint", "semantic_fingerprint", true),
+            new Index("rmq_system_alert", "idx_system_alert_domain_time", "domain, time"),
+            new Index("rmq_system_alert", "idx_system_alert_feed", "domain, instance_id, transition, time"));
+
+    private final DataSource dataSource;
+
+    @Override
+    public void run(ApplicationArguments args) throws Exception {
+        try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) {
+            DatabaseMetaData metadata = connection.getMetaData();
+            for (Table table : TABLES) {
+                ensureTable(metadata, connection.getCatalog(), statement, table);
+            }
+            for (Column column : COLUMNS) {
+                ensureColumn(metadata, connection.getCatalog(), statement, column);
+            }
+            for (Index index : INDEXES) {
+                ensureIndex(metadata, connection.getCatalog(), statement, index);
+            }
+        }
+    }
+
+    private static void ensureTable(DatabaseMetaData metadata, String catalog, Statement statement, Table table)
+            throws Exception {
+        if (hasTable(metadata, catalog, table.name())) {
+            return;
+        }
+        try {
+            log.info("Creating native alerting table {}", table.name());
+            statement.executeUpdate(table.definition());
+        } catch (SQLException failure) {
+            if (!hasTable(metadata, catalog, table.name())) {
+                throw failure;
+            }
+        }
+    }
+
+    private static void ensureColumn(DatabaseMetaData metadata, String catalog, Statement statement, Column column)
+            throws Exception {
+        if (hasColumn(metadata, catalog, column.table(), column.name())) {
+            return;
+        }
+        try {
+            log.info("Adding native alerting column {}.{}", column.table(), column.name());
+            statement.executeUpdate("ALTER TABLE " + column.table() + " ADD COLUMN " + column.name()
+                    + " " + column.definition());
+        } catch (SQLException failure) {
+            if (!hasColumn(metadata, catalog, column.table(), column.name())) {
+                throw failure;
+            }
+        }
+    }
+
+    private static void ensureIndex(DatabaseMetaData metadata, String catalog, Statement statement, Index index)
+            throws Exception {
+        if (hasIndex(metadata, catalog, index.table(), index.name())) {
+            return;
+        }
+        try {
+            log.info("Adding native alerting index {}.{}", index.table(), index.name());
+            statement.executeUpdate("CREATE " + (index.unique() ? "UNIQUE " : "") + "INDEX " + index.name() + " ON " + index.table()
+                    + " (" + index.columns() + ")");
+        } catch (SQLException failure) {
+            if (!hasIndex(metadata, catalog, index.table(), index.name())) {
+                throw failure;
+            }
+        }
+    }
+
+    private static boolean hasColumn(DatabaseMetaData metadata, String catalog, String table, String column)
+            throws Exception {
+        try (ResultSet columns = metadata.getColumns(catalog, null, table, column)) {
+            return columns.next();
+        }
+    }
+
+    private static boolean hasTable(DatabaseMetaData metadata, String catalog, String table) throws Exception {
+        try (ResultSet tables = metadata.getTables(catalog, null, table, new String[] {"TABLE"})) {
+            return tables.next();
+        }
+    }
+
+    private static boolean hasIndex(DatabaseMetaData metadata, String catalog, String table, String index)
+            throws Exception {
+        try (ResultSet indexes = metadata.getIndexInfo(catalog, null, table, false, false)) {
+            while (indexes.next()) {
+                if (index.equalsIgnoreCase(indexes.getString("INDEX_NAME"))) {
+                    return true;
+                }
+            }
+            return false;
+        }
+    }
+
+    private record Column(String table, String name, String definition) {
+    }
+
+    private record Table(String name, String definition) {
+    }
+
+    private record Index(String table, String name, String columns, boolean unique) {
+        private Index(String table, String name, String columns) {
+            this(table, name, columns, false);
+        }
+    }
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
index d8cd43fa6..6b69fbe21 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
@@ -19,10 +19,13 @@ package org.apache.rocketmq.studio.ops.alert;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.apache.rocketmq.studio.audit.OperationAuditService;
-import org.springframework.util.StringUtils;
+import org.apache.rocketmq.studio.auth.AuthenticatedUserContext;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.util.StringUtils;
 
 import java.util.ArrayList;
 import java.util.LinkedHashMap;
@@ -30,19 +33,23 @@ import java.util.List;
 import java.util.HashSet;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 import java.util.regex.Pattern;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
 
 @Slf4j
 @Service
 @RequiredArgsConstructor
 public class AlertService {
 
-    private static final Set<String> VALID_OPERATORS = Set.of(">", ">=", "<", "<=", "==", "!=");
+    private static final Set<String> VALID_OPERATORS = Set.of(">", ">=", "<", "<=", "==", "!=", "UNAVAILABLE");
     private static final Pattern METRIC_NAME_PATTERN = Pattern.compile("^[a-zA-Z_:][a-zA-Z0-9_:]*$");
     private static final Pattern DURATION_PATTERN = Pattern.compile("^\\d+(ms|s|m|h|d|w|y)$");
 
     private final AlertRepository alertRepository;
+    private final AlertStateRepository alertStateRepository;
     private final AlertRuleAssetService alertRuleAssetService;
     private final OperationAuditService operationAuditService;
 
@@ -61,9 +68,30 @@ public class AlertService {
         return alertRepository.findRulePage(normalizedSearch, enabled, page, pageSize);
     }
 
+    public List<AlertRuleVO> listRules(AlertDomain domain) {
+        return listRules().stream()
+                .filter(rule -> domain == resolveDomain(rule))
+                .toList();
+    }
+
+    public PageResult<AlertRuleVO> listRules(AlertDomain domain, String search, Boolean enabled, int page,
+            int pageSize) {
+        requireDomain(domain);
+        if (page < 1 || pageSize < 1 || pageSize > 100) {
+            throw new BusinessException(400, "Invalid page or pageSize");
+        }
+        return alertRepository.findRulesPage(new AlertRuleQuery(domain,
+                hasText(search) ? search.trim() : null, enabled, page, pageSize));
+    }
+
+    public List<AlertRuleRuntimeVO> listRuleRuntime(AlertDomain domain) {
+        return alertStateRepository.findRuntimeByRuleIds(listRules(domain));
+    }
+
     public String exportPrometheusRulesYaml() {
         List<AlertRuleVO> rules = alertRepository.findAllRules().stream()
                 .filter(AlertRuleVO::isEnabled)
+                .filter(rule -> resolveDomain(rule) == AlertDomain.BUSINESS)
                 .toList();
         List<PrometheusAlertRule> prometheusRules = rules.isEmpty()
                 ? defaultPrometheusRules()
@@ -105,17 +133,32 @@ public class AlertService {
         if (rule == null) {
             throw new BusinessException(400, "Alert rule request is required");
         }
+        if (rule.getId() != null) {
+            throw new BusinessException(400, "Alert rule ID must not be provided when creating a rule");
+        }
         if (!hasText(rule.getName())) {
             throw new BusinessException(400, "Alert rule name is required");
         }
         rule.setName(rule.getName().trim());
+        NativeAlertRulePolicy.validate(rule);
+        rejectDuplicateSemanticRule(rule, null);
         log.info("Creating alert rule: {}", rule.getName());
-        AlertRuleVO saved = alertRepository.saveRule(rule);
+        AlertRuleVO saved = saveNewRule(rule);
         auditRule("CREATE_ALERT_RULE", saved, null);
         return saved;
     }
 
+    public AlertRuleVO createRule(AlertDomain domain, AlertRuleVO rule) {
+        requireDomain(domain);
+        if (rule == null) {
+            return createRule(null);
+        }
+        rule.setDomain(domain);
+        return createRule(rule);
+    }
+
 
+    @Transactional
     public AlertRuleVO updateRule(AlertRuleVO rule) {
         if (rule == null) {
             throw new BusinessException(400, "Alert rule request is required");
@@ -127,41 +170,121 @@ public class AlertService {
         Long id = rule.getId();
         log.info("Updating alert rule: {}", id);
         validateRuleId(id);
-        if (!alertRepository.replaceRule(rule)) {
+        NativeAlertRulePolicy.validate(rule);
+        rejectDuplicateSemanticRule(rule, id);
+        if (!replaceRuleWithoutDuplicate(rule)) {
             throw ruleNotFound(id);
         }
+        alertStateRepository.deleteByRuleId(id);
         auditRule("UPDATE_ALERT_RULE", rule, null);
         return rule;
     }
 
+    @Transactional
+    public AlertRuleVO updateRule(AlertDomain domain, AlertRuleVO rule) {
+        requireDomain(domain);
+        validateRuleId(rule == null ? null : rule.getId());
+        AlertRuleVO existing = alertRepository.findRuleById(rule.getId())
+                .orElseThrow(() -> ruleNotFound(rule.getId()));
+        if (resolveDomain(existing) != domain) {
+            throw new BusinessException(404, "Alert rule not found: " + rule.getId());
+        }
+        rule.setDomain(domain);
+        return updateRule(rule);
+    }
+
+    private AlertDomain resolveDomain(AlertRuleVO rule) {
+        return rule.getDomain() == null ? AlertDomain.BUSINESS : rule.getDomain();
+    }
+
+    private void rejectDuplicateSemanticRule(AlertRuleVO rule, Long excludedId) {
+        String fingerprint = AlertRuleSemanticFingerprint.of(rule);
+        boolean duplicate = alertRepository.findAllRules().stream()
+                .filter(candidate -> !Objects.equals(candidate.getId(), excludedId))
+                .anyMatch(candidate -> AlertRuleSemanticFingerprint.of(candidate).equals(fingerprint));
+        if (duplicate) {
+            throw new BusinessException(409, "An alert rule with the same evaluation conditions already exists");
+        }
+    }
+
+    private AlertRuleVO saveNewRule(AlertRuleVO rule) {
+        try {
+            return alertRepository.insertRule(rule);
+        } catch (DuplicateKeyException duplicate) {
+            throw new BusinessException(409, "An alert rule with the same evaluation conditions already exists");
+        }
+    }
+
+    private boolean replaceRuleWithoutDuplicate(AlertRuleVO rule) {
+        try {
+            return alertRepository.replaceRule(rule);
+        } catch (DuplicateKeyException duplicate) {
+            throw new BusinessException(409, "An alert rule with the same evaluation conditions already exists");
+        }
+    }
+
+    private void requireDomain(AlertDomain domain) {
+        if (domain == null) {
+            throw new BusinessException(400, "Alert domain is required");
+        }
+    }
+
 
+    @Transactional
     public AlertRuleVO toggleRule(Long id, boolean enabled) {
         log.info("Toggling alert rule id={}, enabled={}", id, enabled);
         validateRuleId(id);
         AlertRuleVO rule = alertRepository.findRuleById(id)
                 .orElseThrow(() -> new org.apache.rocketmq.studio.common.exception.BusinessException(404, "Alert rule not found: " + id));
         rule.setEnabled(enabled);
-        AlertRuleVO saved = alertRepository.saveRule(rule);
-        auditRule("TOGGLE_ALERT_RULE", saved, "enabled=" + enabled);
-        return saved;
+        if (!alertRepository.replaceRule(rule)) {
+            throw ruleNotFound(id);
+        }
+        alertStateRepository.deleteByRuleId(id);
+        auditRule("TOGGLE_ALERT_RULE", rule, "enabled=" + enabled);
+        return rule;
     }
 
+    @Transactional
+    public AlertRuleVO toggleRule(AlertDomain domain, Long id, boolean enabled) {
+        requireDomain(domain);
+        AlertRuleVO rule = findRuleInDomain(domain, id);
+        rule.setEnabled(enabled);
+        if (!alertRepository.replaceRule(rule)) {
+            throw ruleNotFound(id);
+        }
+        alertStateRepository.deleteByRuleId(id);
+        auditRule("TOGGLE_ALERT_RULE", rule, "enabled=" + enabled);
+        return rule;
+    }
 
+
+    @Transactional
     public void deleteRule(Long id) {
         log.info("Deleting alert rule id={}", id);
         validateRuleId(id);
         if (!alertRepository.deleteRule(id)) {
             throw ruleNotFound(id);
         }
+        alertStateRepository.deleteByRuleId(id);
         recordAudit("DELETE_ALERT_RULE", "ALERT_RULE", String.valueOf(id), null, null);
     }
 
+    @Transactional
+    public void deleteRule(AlertDomain domain, Long id) {
+        requireDomain(domain);
+        findRuleInDomain(domain, id);
+        if (!alertRepository.deleteRule(id)) {
+            throw ruleNotFound(id);
+        }
+        alertStateRepository.deleteByRuleId(id);
+        recordAudit("DELETE_ALERT_RULE", "ALERT_RULE", String.valueOf(id), null, null);
+    }
+
+    @Transactional
     public AlertRuleBulkResultVO bulkToggleRules(List<Long> ids, boolean enabled) {
         List<Long> normalizedIds = normalizeBulkIds(ids);
-        Map<Long, AlertRuleVO> rulesById = new LinkedHashMap<>();
-        for (AlertRuleVO rule : alertRepository.findRulesByIds(normalizedIds)) {
-            rulesById.put(rule.getId(), rule);
-        }
+        Map<Long, AlertRuleVO> rulesById = rulesById(normalizedIds);
         List<Long> succeeded = new ArrayList<>();
         Map<Long, String> failures = new LinkedHashMap<>();
         List<AlertRuleVO> updated = new ArrayList<>();
@@ -179,6 +302,7 @@ public class AlertService {
                     failures.put(id, "Alert rule not found");
                     continue;
                 }
+                alertStateRepository.deleteByRuleId(id);
                 auditRule("TOGGLE_ALERT_RULE", rule, "enabled=" + enabled + ", bulk=true");
                 succeeded.add(id);
                 updated.add(rule);
@@ -190,6 +314,14 @@ public class AlertService {
                 .succeededIds(succeeded).failures(failures).updatedRules(updated).build();
     }
 
+    @Transactional
+    public AlertRuleBulkResultVO bulkToggleRules(AlertDomain domain, List<Long> ids, boolean enabled) {
+        requireDomain(domain);
+        return bulkUpdateRules(domain, ids, rule -> rule.setEnabled(enabled),
+                "TOGGLE_ALERT_RULE", "enabled=" + enabled + ", bulk=true");
+    }
+
+    @Transactional
     public AlertRuleBulkResultVO bulkDeleteRules(List<Long> ids) {
         List<Long> normalizedIds = normalizeBulkIds(ids);
         List<Long> succeeded = new ArrayList<>();
@@ -200,6 +332,36 @@ public class AlertService {
                     failures.put(id, "Alert rule not found");
                     continue;
                 }
+                alertStateRepository.deleteByRuleId(id);
+                recordAudit("DELETE_ALERT_RULE", "ALERT_RULE", String.valueOf(id), null, "bulk=true");
+                succeeded.add(id);
+            } catch (RuntimeException failure) {
+                failures.put(id, failure.getMessage() == null ? "Delete failed" : failure.getMessage());
+            }
+        }
+        return AlertRuleBulkResultVO.builder()
+                .succeededIds(succeeded).failures(failures).updatedRules(List.of()).build();
+    }
+
+    @Transactional
+    public AlertRuleBulkResultVO bulkDeleteRules(AlertDomain domain, List<Long> ids) {
+        requireDomain(domain);
+        List<Long> normalizedIds = normalizeBulkIds(ids);
+        Map<Long, AlertRuleVO> rulesById = rulesById(normalizedIds);
+        List<Long> succeeded = new ArrayList<>();
+        Map<Long, String> failures = new LinkedHashMap<>();
+        for (Long id : normalizedIds) {
+            AlertRuleVO rule = rulesById.get(id);
+            if (rule == null || resolveDomain(rule) != domain) {
+                failures.put(id, "Alert rule not found");
+                continue;
+            }
+            try {
+                if (!alertRepository.deleteRule(id)) {
+                    failures.put(id, "Alert rule not found");
+                    continue;
+                }
+                alertStateRepository.deleteByRuleId(id);
                 recordAudit("DELETE_ALERT_RULE", "ALERT_RULE", String.valueOf(id), null, "bulk=true");
                 succeeded.add(id);
             } catch (RuntimeException failure) {
@@ -210,6 +372,54 @@ public class AlertService {
                 .succeededIds(succeeded).failures(failures).updatedRules(List.of()).build();
     }
 
+    private AlertRuleBulkResultVO bulkUpdateRules(AlertDomain domain, List<Long> ids,
+            java.util.function.Consumer<AlertRuleVO> update, String auditOperation, String auditDetail) {
+        List<Long> normalizedIds = normalizeBulkIds(ids);
+        Map<Long, AlertRuleVO> rulesById = rulesById(normalizedIds);
+        List<Long> succeeded = new ArrayList<>();
+        Map<Long, String> failures = new LinkedHashMap<>();
+        List<AlertRuleVO> updated = new ArrayList<>();
+        for (Long id : normalizedIds) {
+            AlertRuleVO rule = rulesById.get(id);
+            if (rule == null || resolveDomain(rule) != domain) {
+                failures.put(id, "Alert rule not found");
+                continue;
+            }
+            try {
+                update.accept(rule);
+                if (!alertRepository.replaceRule(rule)) {
+                    failures.put(id, "Alert rule not found");
+                    continue;
+                }
+                alertStateRepository.deleteByRuleId(id);
+                auditRule(auditOperation, rule, auditDetail);
+                succeeded.add(id);
+                updated.add(rule);
+            } catch (RuntimeException failure) {
+                failures.put(id, failure.getMessage() == null ? "Update failed" : failure.getMessage());
+            }
+        }
+        return AlertRuleBulkResultVO.builder()
+                .succeededIds(succeeded).failures(failures).updatedRules(updated).build();
+    }
+
+    private AlertRuleVO findRuleInDomain(AlertDomain domain, Long id) {
+        validateRuleId(id);
+        return alertRepository.findRuleById(id)
+                .filter(rule -> resolveDomain(rule) == domain)
+                .orElseThrow(() -> ruleNotFound(id));
+    }
+
+    private Map<Long, AlertRuleVO> rulesById(List<Long> ids) {
+        Map<Long, AlertRuleVO> rulesById = new LinkedHashMap<>();
+        for (AlertRuleVO rule : alertRepository.findRulesByIds(ids)) {
+            if (rule.getId() != null) {
+                rulesById.put(rule.getId(), rule);
+            }
+        }
+        return rulesById;
+    }
+
     private List<Long> normalizeBulkIds(List<Long> ids) {
         if (ids == null || ids.isEmpty()) {
             throw new BusinessException(400, "ids are required");
@@ -248,6 +458,75 @@ public class AlertService {
         return alertRepository.findAlerts(normalizedLevel, page, pageSize);
     }
 
+    public List<SystemAlertVO> listAlerts(String level, AlertDomain domain, String instanceId, String transition) {
+        return listAlerts(level).stream()
+                .filter(alert -> domain == null || domain == alert.getDomain())
+                .filter(alert -> !hasText(instanceId) || instanceId.trim().equals(alert.getInstanceId()))
+                .filter(alert -> !hasText(transition) || transition.trim().equalsIgnoreCase(alert.getTransition()))
+                .toList();
+    }
+
+    public PageResult<SystemAlertVO> listAlerts(String level, AlertDomain domain, String instanceId,
+            String transition, int page, int pageSize) {
+        return listAlerts(level, domain, instanceId, transition, null, null, null, null, page, pageSize);
+    }
+
+    public PageResult<SystemAlertVO> listAlerts(String level, AlertDomain domain, String instanceId,
+            String transition, String labelKey, String labelValue, LocalDateTime from, LocalDateTime to,
+            int page, int pageSize) {
+        return listAlerts(level, domain, instanceId, transition, labelKey, labelValue, from, to, page, pageSize, null);
+    }
+
+    public PageResult<SystemAlertVO> listAlerts(String level, AlertDomain domain, String instanceId,
+            String transition, String labelKey, String labelValue, LocalDateTime from, LocalDateTime to,
+            int page, int pageSize, Boolean notificationSuppressed) {
+        if (page < 1 || pageSize < 1 || pageSize > 100) {
+            throw new BusinessException(400, "Invalid page or pageSize");
+        }
+        if (from != null && to != null && from.isAfter(to)) {
+            throw new BusinessException(400, "from must not be after to");
+        }
+        if (hasText(labelKey) != hasText(labelValue)) {
+            throw new BusinessException(400, "labelKey and labelValue must be provided together");
+        }
+        return alertRepository.findAlertsPage(new SystemAlertQuery(level, domain, instanceId, transition,
+                hasText(labelKey) ? labelKey.trim() : null, hasText(labelValue) ? labelValue.trim() : null,
+                from, to, page, pageSize, notificationSuppressed));
+    }
+
+    public List<SystemAlertVO> findRelatedAlerts(Long id) {
+        if (id == null) {
+            throw new BusinessException(400, "System alert ID is required");
+        }
+        SystemAlertVO source = alertRepository.findAlertById(id)
+                .orElseThrow(() -> new BusinessException(404, "System alert not found: " + id));
+        AlertDomain relatedDomain = (source.getDomain() == null ? AlertDomain.BUSINESS : source.getDomain())
+                == AlertDomain.BUSINESS
+                ? AlertDomain.CLUSTER : AlertDomain.BUSINESS;
+        List<SystemAlertVO> explicitCauses = source.getSuppressionCauseAlertId() == null
+                ? List.of()
+                : alertRepository.findAlertById(source.getSuppressionCauseAlertId())
+                        .stream()
+                        .filter(candidate -> candidate.getDomain() == relatedDomain)
+                        .filter(candidate -> AlertCorrelationScope.matches(source, candidate))
+                        .toList();
+        if (!hasText(source.getInstanceId()) || source.getTime() == null) {
+            return explicitCauses;
+        }
+        LocalDateTime from = source.getTime().minusMinutes(30);
+        LocalDateTime to = source.getTime().plusMinutes(30);
+        List<SystemAlertVO> windowMatches = alertRepository.findAlertsPage(new SystemAlertQuery(null, relatedDomain, source.getInstanceId(),
+                        "FIRING", null, null, from, to, 1, 100))
+                .getItems().stream()
+                .filter(candidate -> !Objects.equals(candidate.getId(), source.getId()))
+                .filter(candidate -> AlertCorrelationScope.matches(source, candidate))
+                .toList();
+        LinkedHashMap<Long, SystemAlertVO> related = new LinkedHashMap<>();
+        explicitCauses.forEach(candidate -> related.put(candidate.getId(), candidate));
+        windowMatches.forEach(candidate -> related.putIfAbsent(candidate.getId(), candidate));
+        return List.copyOf(related.values());
+    }
+
 
     public SystemAlertVO acknowledgeAlert(Long id) {
         log.info("Acknowledging system alert id={}", id);
@@ -255,17 +534,26 @@ public class AlertService {
             throw new BusinessException(400, "System alert ID is required");
         }
         SystemAlertVO alert = alertRepository.findAlertById(id)
-                .orElseThrow(() -> new org.apache.rocketmq.studio.common.exception.BusinessException(404, "System alert not found: " + id));
+                .orElseThrow(() -> new org.apache.rocketmq.studio.common.exception.BusinessException(404,
+                        "System alert not found: " + id));
         alert.setAcknowledged(true);
+        alert.setAcknowledgedBy(AuthenticatedUserContext.currentUsernameOrSystem());
+        alert.setAcknowledgedAt(LocalDateTime.now(ZoneOffset.UTC));
         if (!alertRepository.acknowledgeAlert(alert)) {
             throw new BusinessException(404, "System alert not found: " + id);
         }
+        if ("FIRING".equalsIgnoreCase(alert.getTransition())
+                && alert.getRuleId() != null && hasText(alert.getFingerprint()) && alert.getTime() != null) {
+            alertStateRepository.acknowledge(new AlertStateKey(alert.getRuleId(), alert.getFingerprint()),
+                    alert.getTime().toInstant(ZoneOffset.UTC));
+        }
         recordAudit("ACKNOWLEDGE_SYSTEM_ALERT", "SYSTEM_ALERT", String.valueOf(alert.getId()), null,
                 "acknowledged=true");
         return alert;
     }
 
 
+    @Transactional
     public int clearAcknowledged() {
         log.info("Clearing acknowledged system alerts");
         int deleted = alertRepository.deleteAcknowledgedAlerts();
@@ -337,6 +625,11 @@ public class AlertService {
     private String expression(AlertRuleVO rule) {
         String metric = validateMetric(rule.getMetric());
         String operator = validateOperator(rule.getOperator());
+        if ("UNAVAILABLE".equals(operator)) {
+            // Prometheus has no availability enum. Its compatible exporter convention is zero for
+            // unavailable targets; native Studio evaluation keeps failed samples value-less.
+            return metric + labelSelector(rule) + " == 0";
+        }
         return metric + labelSelector(rule) + " " + operator + " " + formatThreshold(rule.getThreshold());
     }
 
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceController.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceController.java
new file mode 100644
index 000000000..09db215f6
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceController.java
@@ -0,0 +1,53 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.apache.rocketmq.studio.common.domain.Result;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/alert-silences")
+@RequiredArgsConstructor
+public class AlertSilenceController {
+    private final AlertSilenceService silenceService;
+
+    @GetMapping
+    public Result<List<AlertSilenceVO>> list() {
+        return Result.ok(silenceService.list());
+    }
+
+    @PostMapping
+    public Result<AlertSilenceVO> create(@Valid @RequestBody CreateAlertSilenceDTO request) {
+        return Result.ok(silenceService.create(request));
+    }
+
+    @DeleteMapping("/{id}")
+    public Result<Void> delete(@PathVariable Long id) {
+        silenceService.delete(id);
+        return Result.ok();
+    }
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceRepository.java
similarity index 76%
copy from server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
copy to server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceRepository.java
index a3b47d7d5..e1a9b45f7 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceRepository.java
@@ -14,10 +14,14 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.rocketmq.studio.persistence.mapper;
+package org.apache.rocketmq.studio.ops.alert;
 
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import org.apache.rocketmq.studio.persistence.entity.RmqAlertRule;
+import java.util.List;
 
-public interface RmqAlertRuleMapper extends BaseMapper<RmqAlertRule> {
+public interface AlertSilenceRepository {
+    AlertSilenceVO save(AlertSilenceVO silence);
+
+    List<AlertSilenceVO> findAll();
+
+    boolean deleteById(Long id);
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceService.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceService.java
new file mode 100644
index 000000000..78bb07f3b
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceService.java
@@ -0,0 +1,124 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import lombok.RequiredArgsConstructor;
+import org.apache.rocketmq.studio.audit.OperationAuditService;
+import org.apache.rocketmq.studio.auth.AuthenticatedUserContext;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.List;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+@Service
+@RequiredArgsConstructor
+public class AlertSilenceService {
+    private final AlertSilenceRepository repository;
+    private final OperationAuditService operationAuditService;
+
+    public List<AlertSilenceVO> list() {
+        return repository.findAll();
+    }
+
+    public AlertSilenceVO create(CreateAlertSilenceDTO request) {
+        if (request == null || request.getStartsAt() == null || request.getEndsAt() == null) {
+            throw new BusinessException(400, "Silence start and end times are required");
+        }
+        if (!request.getEndsAt().toInstant().isAfter(request.getStartsAt().toInstant())) {
+            throw new BusinessException(400, "Silence end time must be after start time");
+        }
+        if (request.getReason() != null && request.getReason().length() > 512) {
+            throw new BusinessException(400, "Silence reason must not exceed 512 characters");
+        }
+        AlertSilenceVO silence = AlertSilenceVO.builder().domain(request.getDomain())
+                .ruleId(request.getRuleId()).instanceId(trimToNull(request.getInstanceId()))
+                .labels(normalizeLabels(request.getLabels()))
+                .startsAt(LocalDateTime.ofInstant(request.getStartsAt().toInstant(), ZoneOffset.UTC))
+                .endsAt(LocalDateTime.ofInstant(request.getEndsAt().toInstant(), ZoneOffset.UTC))
+                .reason(trimToNull(request.getReason()))
+                .createdBy(AuthenticatedUserContext.currentUsernameOrSystem()).build();
+        AlertSilenceVO saved = repository.save(silence);
+        operationAuditService.record("CREATE_ALERT_SILENCE", "ALERT_SILENCE", String.valueOf(saved.getId()),
+                saved.getInstanceId(), "ruleId=" + saved.getRuleId(), "SUCCESS", null);
+        return saved;
+    }
+
+    public void delete(Long id) {
+        if (id == null) {
+            throw new BusinessException(400, "Silence ID is required");
+        }
+        if (!repository.deleteById(id)) {
+            throw new BusinessException(404, "Alert silence not found: " + id);
+        }
+        operationAuditService.record("DELETE_ALERT_SILENCE", "ALERT_SILENCE", String.valueOf(id), null, null,
+                "SUCCESS", null);
+    }
+
+    public boolean isActive(AlertRuleVO rule, String instanceId, LocalDateTime now) {
+        return isActive(rule, instanceId, Map.of(), now);
+    }
+
+    public boolean isActive(AlertRuleVO rule, String instanceId, Map<String, String> labels, LocalDateTime now) {
+        return activeUntil(rule, instanceId, labels, now) != null;
+    }
+
+    /**
+     * Returns the end of the currently matching maintenance window, or {@code null} when delivery is allowed.
+     * Overlapping matching windows suppress delivery until the last one ends.
+     */
+    public LocalDateTime activeUntil(AlertRuleVO rule, String instanceId, Map<String, String> labels,
+            LocalDateTime now) {
+        AlertDomain domain = rule.getDomain() == null ? AlertDomain.BUSINESS : rule.getDomain();
+        return repository.findAll().stream()
+                .filter(silence -> matches(silence, rule.getId(), domain, instanceId,
+                        labels == null ? Map.of() : labels, now))
+                .map(AlertSilenceVO::getEndsAt).max(LocalDateTime::compareTo).orElse(null);
+    }
+
+    private static boolean matches(AlertSilenceVO silence, Long ruleId, AlertDomain domain, String instanceId,
+            Map<String, String> labels, LocalDateTime now) {
+        return !now.isBefore(silence.getStartsAt()) && now.isBefore(silence.getEndsAt())
+                && (silence.getDomain() == null || silence.getDomain() == domain)
+                && (silence.getRuleId() == null || silence.getRuleId().equals(ruleId))
+                && (silence.getInstanceId() == null || silence.getInstanceId().equals(instanceId))
+                && (silence.getLabels() == null || labels.entrySet().containsAll(silence.getLabels().entrySet()));
+    }
+
+    private static String trimToNull(String value) {
+        return value == null || value.isBlank() ? null : value.trim();
+    }
+
+    private static Map<String, String> normalizeLabels(Map<String, String> labels) {
+        if (labels == null || labels.isEmpty()) return Map.of();
+        if (labels.size() > 16) throw new BusinessException(400, "At most 16 silence labels are supported");
+        Map<String, String> normalized = new LinkedHashMap<>();
+        labels.forEach((key, value) -> {
+            String normalizedKey = trimToNull(key);
+            String normalizedValue = trimToNull(value);
+            if (normalizedKey == null || normalizedValue == null || normalizedKey.length() > 128
+                    || normalizedValue.length() > 512) {
+                throw new BusinessException(400, "Silence labels must have non-empty bounded keys and values");
+            }
+            normalized.put(normalizedKey, normalizedValue);
+        });
+        return Map.copyOf(normalized);
+    }
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertVO.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceVO.java
similarity index 77%
copy from server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertVO.java
copy to server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceVO.java
index db9c06a49..a08185866 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertVO.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceVO.java
@@ -16,23 +16,26 @@
  */
 package org.apache.rocketmq.studio.ops.alert;
 
-import org.apache.rocketmq.studio.common.domain.enums.AlertLevel;
 import lombok.AllArgsConstructor;
 import lombok.Builder;
 import lombok.Data;
 import lombok.NoArgsConstructor;
 
 import java.time.LocalDateTime;
+import java.util.Map;
 
 @Data
 @Builder
 @NoArgsConstructor
 @AllArgsConstructor
-public class SystemAlertVO {
+public class AlertSilenceVO {
     private Long id;
-    private AlertLevel level;
-    private String title;
-    private String description;
-    private LocalDateTime time;
-    private boolean acknowledged;
+    private AlertDomain domain;
+    private Long ruleId;
+    private String instanceId;
+    private Map<String, String> labels;
+    private LocalDateTime startsAt;
+    private LocalDateTime endsAt;
+    private String reason;
+    private String createdBy;
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertStateKey.java
similarity index 66%
copy from server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
copy to server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertStateKey.java
index a3b47d7d5..0bca7affd 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertStateKey.java
@@ -14,10 +14,15 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.rocketmq.studio.persistence.mapper;
+package org.apache.rocketmq.studio.ops.alert;
 
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import org.apache.rocketmq.studio.persistence.entity.RmqAlertRule;
-
-public interface RmqAlertRuleMapper extends BaseMapper<RmqAlertRule> {
+public record AlertStateKey(Long ruleId, String fingerprint) {
+    public AlertStateKey {
+        if (ruleId == null || ruleId <= 0) {
+            throw new IllegalArgumentException("ruleId is required");
+        }
+        if (fingerprint == null || fingerprint.isBlank()) {
+            throw new IllegalArgumentException("fingerprint is required");
+        }
+    }
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertStateMachine.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertStateMachine.java
new file mode 100644
index 000000000..d8f3ac261
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertStateMachine.java
@@ -0,0 +1,95 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import org.apache.rocketmq.studio.cluster.metrics.MetricAvailability;
+import org.springframework.stereotype.Component;
+
+import java.time.Instant;
+import java.time.Duration;
+
+@Component
+public class AlertStateMachine {
+
+    public AlertStateUpdate advance(AlertRuleState previous, AlertEvaluationResult evaluation,
+            int requiredConsecutiveSamples, Instant now) {
+        return advance(previous, evaluation, requiredConsecutiveSamples, Duration.ZERO, now);
+    }
+
+    public AlertStateUpdate advance(AlertRuleState previous, AlertEvaluationResult evaluation,
+            int requiredConsecutiveSamples, Duration requiredDuration, Instant now) {
+        return advance(previous, evaluation, requiredConsecutiveSamples, requiredDuration, Duration.ZERO, now);
+    }
+
+    public AlertStateUpdate advance(AlertRuleState previous, AlertEvaluationResult evaluation,
+            int requiredConsecutiveSamples, Duration requiredDuration, Duration reminderInterval, Instant now) {
+        if (requiredConsecutiveSamples < 1) {
+            throw new IllegalArgumentException("requiredConsecutiveSamples must be positive");
+        }
+        if (requiredDuration == null || requiredDuration.isNegative()) {
+            throw new IllegalArgumentException("requiredDuration must not be negative");
+        }
+        AlertRuleState state = previous == null ? AlertRuleState.initial() : previous;
+        if (!evaluation.matches()) {
+            return new AlertStateUpdate(state, AlertStateTransition.NONE);
+        }
+        // A regular metric must not clear an alert merely because collection failed. The explicit
+        // UNAVAILABLE condition is the sole opt-in path that turns a failed probe into a firing alert.
+        if (evaluation.availability() != MetricAvailability.AVAILABLE && !evaluation.conditionMet()) {
+            return new AlertStateUpdate(state, AlertStateTransition.NONE);
+        }
+        if (evaluation.conditionMet()) {
+            return advanceHit(state, evaluation.currentValue(), requiredConsecutiveSamples, requiredDuration,
+                    reminderInterval, now);
+        }
+        return advanceClear(state, evaluation.currentValue(), now);
+    }
+
+    private AlertStateUpdate advanceHit(AlertRuleState state, Double value, int required, Duration duration,
+            Duration reminderInterval, Instant now) {
+        if (state.status() == AlertStateStatus.ACKED) {
+            return new AlertStateUpdate(new AlertRuleState(state.status(), state.consecutiveHits(), value,
+                    state.firstPendingAt(), state.firedAt(), state.lastNotifiedAt(), null), AlertStateTransition.NONE);
+        }
+        if (state.status() == AlertStateStatus.FIRING) {
+            Instant lastNotifiedAt = state.lastNotifiedAt();
+            if (!reminderInterval.isZero() && (lastNotifiedAt == null || !now.isBefore(lastNotifiedAt.plus(reminderInterval)))) {
+                return new AlertStateUpdate(new AlertRuleState(AlertStateStatus.FIRING, state.consecutiveHits(), value,
+                        state.firstPendingAt(), state.firedAt(), now, null), AlertStateTransition.REMINDER);
+            }
+            return new AlertStateUpdate(new AlertRuleState(AlertStateStatus.FIRING, state.consecutiveHits(), value,
+                    state.firstPendingAt(), state.firedAt(), lastNotifiedAt, null), AlertStateTransition.NONE);
+        }
+        int hits = state.consecutiveHits() + 1;
+        Instant pendingAt = state.firstPendingAt() == null ? now : state.firstPendingAt();
+        if (hits < required || now.isBefore(pendingAt.plus(duration))) {
+            return new AlertStateUpdate(new AlertRuleState(AlertStateStatus.PENDING, hits, value, pendingAt,
+                    null, null, null), AlertStateTransition.PENDING);
+        }
+        return new AlertStateUpdate(new AlertRuleState(AlertStateStatus.FIRING, hits, value, pendingAt, now,
+                now, null), AlertStateTransition.FIRING);
+    }
+
+    private AlertStateUpdate advanceClear(AlertRuleState state, Double value, Instant now) {
+        if (state.status() == AlertStateStatus.FIRING || state.status() == AlertStateStatus.ACKED) {
+            return new AlertStateUpdate(new AlertRuleState(AlertStateStatus.RESOLVED, 0, value, null,
+                    state.firedAt(), state.lastNotifiedAt(), now), AlertStateTransition.RESOLVED);
+        }
+        return new AlertStateUpdate(new AlertRuleState(AlertStateStatus.OK, 0, value, null, null, null, null),
+                AlertStateTransition.NONE);
+    }
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRepository.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertStateRepository.java
similarity index 54%
copy from server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRepository.java
copy to server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertStateRepository.java
index 92ca40b05..f33674d11 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRepository.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertStateRepository.java
@@ -16,34 +16,28 @@
  */
 package org.apache.rocketmq.studio.ops.alert;
 
-
-import java.util.List;
+import java.time.Instant;
 import java.util.Optional;
+import java.util.List;
 
-import org.apache.rocketmq.studio.common.domain.PageResult;
-
-public interface AlertRepository {
-    List<AlertRuleVO> findAllRules();
-
-    PageResult<AlertRuleVO> findRulePage(String search, Boolean enabled, int page, int pageSize);
-
-    Optional<AlertRuleVO> findRuleById(Long id);
-
-    List<AlertRuleVO> findRulesByIds(List<Long> ids);
-
-    AlertRuleVO saveRule(AlertRuleVO rule);
-
-    boolean replaceRule(AlertRuleVO rule);
-
-    boolean deleteRule(Long id);
-
-    List<SystemAlertVO> findAlerts(String level);
+public interface AlertStateRepository {
+    Optional<AlertRuleState> find(AlertStateKey key);
 
-    PageResult<SystemAlertVO> findAlerts(String level, int page, int pageSize);
+    /**
+     * Persists the next state and returns whether this evaluator won the state transition.
+     * Callers must only emit lifecycle events after a successful write.
+     */
+    boolean save(AlertStateKey key, AlertRuleState state);
 
-    Optional<SystemAlertVO> findAlertById(Long id);
+    /**
+     * Acknowledges the currently firing incident only when it is the same firing
+     * occurrence represented by the alert event being acknowledged.
+     */
+    boolean acknowledge(AlertStateKey key, Instant firedAt);
 
-    boolean acknowledgeAlert(SystemAlertVO alert);
+    void deleteByRuleId(Long ruleId);
 
-    int deleteAcknowledgedAlerts();
+    default List<AlertRuleRuntimeVO> findRuntimeByRuleIds(List<AlertRuleVO> rules) {
+        return List.of();
+    }
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertStateStatus.java
similarity index 76%
copy from server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
copy to server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertStateStatus.java
index a3b47d7d5..8008c2cd7 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertStateStatus.java
@@ -14,10 +14,12 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.rocketmq.studio.persistence.mapper;
+package org.apache.rocketmq.studio.ops.alert;
 
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import org.apache.rocketmq.studio.persistence.entity.RmqAlertRule;
-
-public interface RmqAlertRuleMapper extends BaseMapper<RmqAlertRule> {
+public enum AlertStateStatus {
+    OK,
+    PENDING,
+    FIRING,
+    RESOLVED,
+    ACKED
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertStateTransition.java
similarity index 76%
copy from server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
copy to server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertStateTransition.java
index a3b47d7d5..8f9264335 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertStateTransition.java
@@ -14,10 +14,12 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.rocketmq.studio.persistence.mapper;
+package org.apache.rocketmq.studio.ops.alert;
 
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import org.apache.rocketmq.studio.persistence.entity.RmqAlertRule;
-
-public interface RmqAlertRuleMapper extends BaseMapper<RmqAlertRule> {
+public enum AlertStateTransition {
+    NONE,
+    PENDING,
+    FIRING,
+    REMINDER,
+    RESOLVED
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertStateUpdate.java
similarity index 76%
copy from server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
copy to server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertStateUpdate.java
index a3b47d7d5..83d5973d9 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertStateUpdate.java
@@ -14,10 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.rocketmq.studio.persistence.mapper;
+package org.apache.rocketmq.studio.ops.alert;
 
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import org.apache.rocketmq.studio.persistence.entity.RmqAlertRule;
-
-public interface RmqAlertRuleMapper extends BaseMapper<RmqAlertRule> {
+public record AlertStateUpdate(AlertRuleState state, AlertStateTransition transition) {
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleController.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/ClusterAlertRuleController.java
similarity index 51%
copy from server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleController.java
copy to server/src/main/java/org/apache/rocketmq/studio/ops/alert/ClusterAlertRuleController.java
index 36d75c52c..d8dfb03a9 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleController.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/ClusterAlertRuleController.java
@@ -16,11 +16,11 @@
  */
 package org.apache.rocketmq.studio.ops.alert;
 
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
 import org.apache.rocketmq.studio.common.domain.Result;
 import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
-import jakarta.validation.Valid;
-import lombok.RequiredArgsConstructor;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
@@ -30,16 +30,20 @@ import org.springframework.web.bind.annotation.RestController;
 
 import java.util.List;
 
+/** Domain-limited rule API for the Cluster Alerts menu. */
 @RestController
-@RequestMapping("/api/alert-rules")
+@RequestMapping("/api/cluster-alert-rules")
 @RequiredArgsConstructor
-public class AlertRuleController {
+public class ClusterAlertRuleController {
 
     private final AlertService alertService;
+    private final NativeAlertRuleTestService nativeAlertRuleTestService;
+    private final NativeAlertMetricCatalogService metricCatalogService;
+    private final AlertRuleTransferService transferService;
 
     @GetMapping
     public Result<List<AlertRuleVO>> listRules() {
-        return Result.ok(alertService.listRules());
+        return Result.ok(alertService.listRules(AlertDomain.CLUSTER));
     }
 
     @GetMapping("/page")
@@ -48,52 +52,74 @@ public class AlertRuleController {
             @RequestParam(required = false) Boolean enabled,
             @RequestParam(defaultValue = "1") int page,
             @RequestParam(defaultValue = "20") int pageSize) {
-        return Result.ok(alertService.listRules(search, enabled, page, pageSize));
+        return Result.ok(alertService.listRules(AlertDomain.CLUSTER, search, enabled, page, pageSize));
+    }
+
+    @GetMapping("/runtime")
+    public Result<List<AlertRuleRuntimeVO>> listRuntime() {
+        return Result.ok(alertService.listRuleRuntime(AlertDomain.CLUSTER));
+    }
+
+    @GetMapping("/transfer")
+    public Result<AlertRuleTransferDTO> exportTransfer() {
+        return Result.ok(transferService.exportRules(AlertDomain.CLUSTER));
     }
 
-    @GetMapping("/export")
-    public Result<AlertRulesYamlVO> exportRules() {
-        return Result.ok(new AlertRulesYamlVO(alertService.exportPrometheusRulesYaml()));
+    @PostMapping("/import")
+    public Result<List<AlertRuleVO>> importRules(@Valid @RequestBody(required = false) AlertRuleTransferDTO transfer) {
+        return Result.ok(transferService.importRules(AlertDomain.CLUSTER, transfer));
     }
 
     @PostMapping("/create")
     public Result<AlertRuleVO> createRule(@Valid @RequestBody(required = false) AlertRuleRequestDTO rule) {
-        return Result.ok(alertService.createRule(requireAlertRule(rule).toAlertRuleVO()));
+        AlertRuleVO candidate = requireRule(rule).toAlertRuleVO();
+        candidate.setDomain(AlertDomain.CLUSTER);
+        metricCatalogService.validate(candidate);
+        return Result.ok(alertService.createRule(AlertDomain.CLUSTER, candidate));
     }
 
     @PostMapping("/update")
     public Result<AlertRuleVO> updateRule(@Valid @RequestBody(required = false) AlertRuleRequestDTO rule) {
-        AlertRuleRequestDTO request = requireAlertRule(rule);
+        AlertRuleRequestDTO request = requireRule(rule);
         if (request.getId() == null) {
             throw new BusinessException(400, "id is required");
         }
-        return Result.ok(alertService.updateRule(request.toAlertRuleVO()));
+        AlertRuleVO candidate = request.toAlertRuleVO();
+        candidate.setDomain(AlertDomain.CLUSTER);
+        metricCatalogService.validate(candidate);
+        return Result.ok(alertService.updateRule(AlertDomain.CLUSTER, candidate));
+    }
+
+    @PostMapping("/test")
+    public Result<AlertRuleTestResultVO> testRule(@Valid @RequestBody(required = false) AlertRuleRequestDTO rule) {
+        AlertRuleVO candidate = requireRule(rule).toAlertRuleVO();
+        candidate.setDomain(AlertDomain.CLUSTER);
+        metricCatalogService.validate(candidate);
+        return Result.ok(nativeAlertRuleTestService.test(candidate));
     }
 
     @PostMapping("/toggle")
     public Result<AlertRuleVO> toggleRule(@Valid @RequestBody ToggleAlertRuleDTO request) {
-        return Result.ok(alertService.toggleRule(request.getId(), request.getEnabled()));
+        return Result.ok(alertService.toggleRule(AlertDomain.CLUSTER, request.getId(), request.getEnabled()));
     }
 
     @PostMapping("/delete")
     public Result<Void> deleteRule(@Valid @RequestBody DeleteAlertRuleDTO request) {
-        alertService.deleteRule(request.getId());
+        alertService.deleteRule(AlertDomain.CLUSTER, request.getId());
         return Result.ok();
     }
 
     @PostMapping("/bulk-toggle")
-    public Result<AlertRuleBulkResultVO> bulkToggle(
-            @Valid @RequestBody BulkToggleAlertRulesDTO request) {
-        return Result.ok(alertService.bulkToggleRules(request.getIds(), request.getEnabled()));
+    public Result<AlertRuleBulkResultVO> bulkToggle(@Valid @RequestBody BulkToggleAlertRulesDTO request) {
+        return Result.ok(alertService.bulkToggleRules(AlertDomain.CLUSTER, request.getIds(), request.getEnabled()));
     }
 
     @PostMapping("/bulk-delete")
-    public Result<AlertRuleBulkResultVO> bulkDelete(
-            @Valid @RequestBody BulkDeleteAlertRulesDTO request) {
-        return Result.ok(alertService.bulkDeleteRules(request.getIds()));
+    public Result<AlertRuleBulkResultVO> bulkDelete(@Valid @RequestBody BulkDeleteAlertRulesDTO request) {
+        return Result.ok(alertService.bulkDeleteRules(AlertDomain.CLUSTER, request.getIds()));
     }
 
-    private AlertRuleRequestDTO requireAlertRule(AlertRuleRequestDTO rule) {
+    private AlertRuleRequestDTO requireRule(AlertRuleRequestDTO rule) {
         if (rule == null) {
             throw new BusinessException(400, "Alert rule request is required");
         }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertVO.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/CreateAlertSilenceDTO.java
similarity index 61%
copy from server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertVO.java
copy to server/src/main/java/org/apache/rocketmq/studio/ops/alert/CreateAlertSilenceDTO.java
index db9c06a49..21a0dce20 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertVO.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/CreateAlertSilenceDTO.java
@@ -16,23 +16,22 @@
  */
 package org.apache.rocketmq.studio.ops.alert;
 
-import org.apache.rocketmq.studio.common.domain.enums.AlertLevel;
-import lombok.AllArgsConstructor;
-import lombok.Builder;
+import jakarta.validation.constraints.NotNull;
 import lombok.Data;
-import lombok.NoArgsConstructor;
 
-import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
+import java.util.Map;
 
 @Data
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-public class SystemAlertVO {
-    private Long id;
-    private AlertLevel level;
-    private String title;
-    private String description;
-    private LocalDateTime time;
-    private boolean acknowledged;
+public class CreateAlertSilenceDTO {
+    private AlertDomain domain;
+    private Long ruleId;
+    private String instanceId;
+    /** Optional exact-match labels, such as brokerName, topic, or consumerGroup. */
+    private Map<String, String> labels;
+    @NotNull(message = "startsAt is required")
+    private OffsetDateTime startsAt;
+    @NotNull(message = "endsAt is required")
+    private OffsetDateTime endsAt;
+    private String reason;
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepository.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepository.java
index 8755f4ce6..11c34248e 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepository.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepository.java
@@ -18,11 +18,14 @@ package org.apache.rocketmq.studio.ops.alert;
 
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.apache.rocketmq.studio.common.domain.enums.AlertLevel;
 import org.apache.rocketmq.studio.persistence.entity.RmqAlertRule;
 import org.apache.rocketmq.studio.persistence.entity.RmqSystemAlert;
 import org.apache.rocketmq.studio.persistence.mapper.RmqAlertRuleMapper;
+import org.apache.rocketmq.studio.persistence.mapper.RmqAlertNotificationOutboxMapper;
 import org.apache.rocketmq.studio.persistence.mapper.RmqSystemAlertMapper;
 import org.springframework.stereotype.Repository;
 import org.springframework.transaction.annotation.Transactional;
@@ -33,7 +36,9 @@ import java.time.LocalDateTime;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Locale;
+import java.util.Map;
 import java.util.Optional;
+import java.util.TreeMap;
 import java.util.stream.Collectors;
 
 /**
@@ -43,8 +48,11 @@ import java.util.stream.Collectors;
 @Repository
 public class MybatisPlusAlertRepository implements AlertRepository {
 
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
     private final RmqAlertRuleMapper ruleMapper;
     private final RmqSystemAlertMapper alertMapper;
+    private final RmqAlertNotificationOutboxMapper notificationOutboxMapper;
 
     @Override
     public List<AlertRuleVO> findAllRules() {
@@ -64,6 +72,34 @@ public class MybatisPlusAlertRepository implements AlertRepository {
         return PageResult.of(items, result.getTotal(), page, pageSize);
     }
 
+    private QueryWrapper<RmqAlertRule> ruleQuery(String search, Boolean enabled) {
+        return new QueryWrapper<RmqAlertRule>()
+                .like(StringUtils.hasText(search), "name", search)
+                .eq(enabled != null, "enabled", enabled)
+                .orderByAsc("name", "id");
+    }
+
+    @Override
+    public PageResult<AlertRuleVO> findRulesPage(AlertRuleQuery query) {
+        QueryWrapper<RmqAlertRule> conditions = new QueryWrapper<RmqAlertRule>()
+                .eq(query.enabled() != null, "enabled", query.enabled())
+                .and(StringUtils.hasText(query.search()), wrapper -> wrapper
+                        .like("name", query.search().trim())
+                        .or()
+                        .like("metric", query.search().trim()))
+                .orderByAsc("name")
+                .orderByAsc("id");
+        if (query.domain() == AlertDomain.BUSINESS) {
+            // Rules created before alert domains were introduced are business rules.
+            conditions.and(wrapper -> wrapper.isNull("domain").or().eq("domain", AlertDomain.BUSINESS.name()));
+        } else {
+            conditions.eq("domain", query.domain().name());
+        }
+        Page<RmqAlertRule> result = ruleMapper.selectPage(new Page<>(query.page(), query.pageSize()), conditions);
+        return PageResult.of(result.getRecords().stream().map(MybatisPlusAlertRepository::toRuleVO).toList(),
+                result.getTotal(), query.page(), query.pageSize());
+    }
+
     @Override
     public Optional<AlertRuleVO> findRuleById(Long id) {
         if (id == null) {
@@ -85,23 +121,12 @@ public class MybatisPlusAlertRepository implements AlertRepository {
                 .toList();
     }
 
-    private QueryWrapper<RmqAlertRule> ruleQuery(String search, Boolean enabled) {
-        return new QueryWrapper<RmqAlertRule>()
-                .like(StringUtils.hasText(search), "name", search)
-                .eq(enabled != null, "enabled", enabled)
-                .orderByAsc("name", "id");
-    }
-
     @Override
     @Transactional
-    public AlertRuleVO saveRule(AlertRuleVO rule) {
+    public AlertRuleVO insertRule(AlertRuleVO rule) {
         RmqAlertRule entity = toRuleEntity(rule);
-        if (entity.getId() != null && ruleMapper.selectById(entity.getId()) != null) {
-            ruleMapper.updateById(entity);
-        } else {
-            ruleMapper.insert(entity);
-            rule.setId(entity.getId());
-        }
+        ruleMapper.insert(entity);
+        rule.setId(entity.getId());
         return rule;
     }
 
@@ -113,6 +138,13 @@ public class MybatisPlusAlertRepository implements AlertRepository {
         return ruleMapper.updateById(toRuleEntity(rule)) > 0;
     }
 
+    @Override
+    public void markRuleTriggered(Long id, String triggeredAt) {
+        if (id != null) {
+            ruleMapper.updateLastTriggered(id, triggeredAt);
+        }
+    }
+
     @Override
     public boolean deleteRule(Long id) {
         return id != null && ruleMapper.deleteById(id) > 0;
@@ -151,6 +183,32 @@ public class MybatisPlusAlertRepository implements AlertRepository {
                 .orderByDesc("time", "id");
     }
 
+    @Override
+    public PageResult<SystemAlertVO> findAlertsPage(SystemAlertQuery query) {
+        QueryWrapper<RmqSystemAlert> conditions = new QueryWrapper<RmqSystemAlert>()
+                .eq(StringUtils.hasText(query.level()), "level", normalizeLevel(query.level()))
+                .eq(query.domain() != null, "domain", query.domain() == null ? null : query.domain().name())
+                .eq(StringUtils.hasText(query.instanceId()), "instance_id", trimToNull(query.instanceId()))
+                .eq(StringUtils.hasText(query.transition()), "transition", normalizeTransition(query.transition()))
+                .eq(query.notificationSuppressed() != null, "notification_suppressed", query.notificationSuppressed())
+                .apply(StringUtils.hasText(query.labelKey()),
+                        "JSON_CONTAINS(labels_json, JSON_OBJECT({0}, {1}))", query.labelKey(), query.labelValue())
+                .ge(query.from() != null, "time", query.from())
+                .le(query.to() != null, "time", query.to())
+                .orderByDesc("time");
+        Page<RmqSystemAlert> result = alertMapper.selectPage(new Page<>(query.page(), query.pageSize()), conditions);
+        return PageResult.of(result.getRecords().stream().map(MybatisPlusAlertRepository::toAlertVO).toList(),
+                result.getTotal(), query.page(), query.pageSize());
+    }
+
+    @Override
+    public SystemAlertVO saveAlert(SystemAlertVO alert) {
+        RmqSystemAlert entity = toAlertEntity(alert);
+        alertMapper.insert(entity);
+        alert.setId(entity.getId());
+        return alert;
+    }
+
     @Override
     public boolean acknowledgeAlert(SystemAlertVO alert) {
         return alertMapper.updateById(toAlertEntity(alert)) > 0;
@@ -158,6 +216,7 @@ public class MybatisPlusAlertRepository implements AlertRepository {
 
     @Override
     public int deleteAcknowledgedAlerts() {
+        notificationOutboxMapper.deleteForAcknowledgedAlerts();
         return Math.toIntExact(alertMapper.delete(
                 new QueryWrapper<RmqSystemAlert>().eq("acknowledged", true)));
     }
@@ -167,12 +226,15 @@ public class MybatisPlusAlertRepository implements AlertRepository {
     private static AlertRuleVO toRuleVO(RmqAlertRule entity) {
         AlertRuleVO vo = new AlertRuleVO();
         vo.setId(entity.getId());
+        vo.setDomain(StringUtils.hasText(entity.getDomain()) ? parseDomain(entity.getDomain()) : null);
         vo.setName(entity.getName());
         vo.setMetric(entity.getMetric());
         vo.setOperator(entity.getOperator());
         vo.setThreshold(entity.getThreshold() == null ? 0 : entity.getThreshold());
         vo.setThresholdUnit(entity.getThresholdUnit());
         vo.setDuration(entity.getDuration());
+        vo.setAggregation(StringUtils.hasText(entity.getAggregation()) ? entity.getAggregation() : "LAST");
+        vo.setWindowSeconds(entity.getWindowSeconds() == null ? 0 : entity.getWindowSeconds());
         vo.setChannels(splitCsv(entity.getChannels()));
         vo.setEnabled(Boolean.TRUE.equals(entity.getEnabled()));
         vo.setLastTriggered(entity.getLastTriggered());
@@ -180,18 +242,27 @@ public class MybatisPlusAlertRepository implements AlertRepository {
         vo.setBrokerName(entity.getBrokerName());
         vo.setClusterName(entity.getClusterName());
         vo.setSeverity(entity.getSeverity());
+        vo.setInstanceId(entity.getInstanceId());
+        vo.setConsumerGroup(entity.getConsumerGroup());
+        vo.setTopic(entity.getTopic());
+        vo.setConsecutiveSamples(entity.getConsecutiveSamples() == null ? 1 : entity.getConsecutiveSamples());
+        vo.setReminderInterval(StringUtils.hasText(entity.getReminderInterval()) ? entity.getReminderInterval() : "30m");
+        vo.setNotificationTemplate(entity.getNotificationTemplate());
         return vo;
     }
 
     private static RmqAlertRule toRuleEntity(AlertRuleVO rule) {
         RmqAlertRule entity = new RmqAlertRule();
         entity.setId(rule.getId());
+        entity.setDomain((rule.getDomain() == null ? AlertDomain.BUSINESS : rule.getDomain()).name());
         entity.setName(rule.getName());
         entity.setMetric(rule.getMetric());
         entity.setOperator(rule.getOperator());
         entity.setThreshold(rule.getThreshold());
         entity.setThresholdUnit(rule.getThresholdUnit());
         entity.setDuration(rule.getDuration());
+        entity.setAggregation(StringUtils.hasText(rule.getAggregation()) ? rule.getAggregation().trim().toUpperCase(Locale.ROOT) : "LAST");
+        entity.setWindowSeconds(Math.max(0, rule.getWindowSeconds()));
         entity.setChannels(rule.getChannels() == null
                 ? null
                 : String.join(",", normalizeChannels(rule.getChannels())));
@@ -201,10 +272,28 @@ public class MybatisPlusAlertRepository implements AlertRepository {
         entity.setBrokerName(rule.getBrokerName());
         entity.setClusterName(rule.getClusterName());
         entity.setSeverity(rule.getSeverity());
+        entity.setInstanceId(rule.getInstanceId());
+        entity.setConsumerGroup(rule.getConsumerGroup());
+        entity.setTopic(rule.getTopic());
+        entity.setConsecutiveSamples(Math.max(1, rule.getConsecutiveSamples()));
+        entity.setReminderInterval(StringUtils.hasText(rule.getReminderInterval()) ? rule.getReminderInterval() : "30m");
+        entity.setNotificationTemplate(rule.getNotificationTemplate());
+        entity.setSemanticFingerprint(AlertRuleSemanticFingerprint.of(rule));
         entity.setGmtModified(LocalDateTime.now());
         return entity;
     }
 
+    private static AlertDomain parseDomain(String domain) {
+        if (!StringUtils.hasText(domain)) {
+            return AlertDomain.BUSINESS;
+        }
+        try {
+            return AlertDomain.valueOf(domain.trim().toUpperCase(Locale.ROOT));
+        } catch (IllegalArgumentException ignored) {
+            return AlertDomain.BUSINESS;
+        }
+    }
+
     private static SystemAlertVO toAlertVO(RmqSystemAlert entity) {
         SystemAlertVO vo = new SystemAlertVO();
         vo.setId(entity.getId());
@@ -213,6 +302,18 @@ public class MybatisPlusAlertRepository implements AlertRepository {
         vo.setDescription(entity.getDescription());
         vo.setTime(entity.getTime());
         vo.setAcknowledged(Boolean.TRUE.equals(entity.getAcknowledged()));
+        vo.setAcknowledgedBy(entity.getAcknowledgedBy());
+        vo.setAcknowledgedAt(entity.getAcknowledgedAt());
+        vo.setDomain(parseDomain(entity.getDomain()));
+        vo.setRuleId(entity.getRuleId());
+        vo.setFingerprint(entity.getFingerprint());
+        vo.setTransition(entity.getTransition());
+        vo.setInstanceId(entity.getInstanceId());
+        vo.setCurrentValue(entity.getCurrentValue());
+        vo.setNotificationSuppressed(Boolean.TRUE.equals(entity.getNotificationSuppressed()));
+        vo.setSuppressionCauseAlertId(entity.getSuppressionCauseAlertId());
+        vo.setSuppressionReason(entity.getSuppressionReason());
+        vo.setLabels(readLabels(entity.getLabelsJson()));
         return vo;
     }
 
@@ -224,6 +325,18 @@ public class MybatisPlusAlertRepository implements AlertRepository {
         entity.setDescription(alert.getDescription());
         entity.setTime(alert.getTime());
         entity.setAcknowledged(alert.isAcknowledged());
+        entity.setAcknowledgedBy(alert.getAcknowledgedBy());
+        entity.setAcknowledgedAt(alert.getAcknowledgedAt());
+        entity.setDomain(alert.getDomain() == null ? null : alert.getDomain().name());
+        entity.setRuleId(alert.getRuleId());
+        entity.setFingerprint(alert.getFingerprint());
+        entity.setTransition(alert.getTransition());
+        entity.setInstanceId(alert.getInstanceId());
+        entity.setCurrentValue(alert.getCurrentValue());
+        entity.setNotificationSuppressed(alert.isNotificationSuppressed());
+        entity.setSuppressionCauseAlertId(alert.getSuppressionCauseAlertId());
+        entity.setSuppressionReason(alert.getSuppressionReason());
+        entity.setLabelsJson(writeLabels(alert.getLabels()));
         entity.setGmtModified(LocalDateTime.now());
         return entity;
     }
@@ -239,6 +352,20 @@ public class MybatisPlusAlertRepository implements AlertRepository {
         }
     }
 
+    private static String normalizeLevel(String level) {
+        String normalized = trimToNull(level);
+        return normalized == null ? null : normalized.toLowerCase(Locale.ROOT);
+    }
+
+    private static String normalizeTransition(String transition) {
+        String normalized = trimToNull(transition);
+        return normalized == null ? null : normalized.toUpperCase(Locale.ROOT);
+    }
+
+    private static String trimToNull(String value) {
+        return StringUtils.hasText(value) ? value.trim() : null;
+    }
+
     private static List<String> splitCsv(String value) {
         if (!StringUtils.hasText(value)) {
             return List.of();
@@ -253,4 +380,26 @@ public class MybatisPlusAlertRepository implements AlertRepository {
                 .distinct()
                 .toList();
     }
+
+    private static String writeLabels(Map<String, String> labels) {
+        if (labels == null || labels.isEmpty()) {
+            return null;
+        }
+        try {
+            return OBJECT_MAPPER.writeValueAsString(new TreeMap<>(labels));
+        } catch (Exception error) {
+            throw new IllegalArgumentException("Unable to serialize alert labels", error);
+        }
+    }
+
+    private static Map<String, String> readLabels(String labelsJson) {
+        if (!StringUtils.hasText(labelsJson)) {
+            return Map.of();
+        }
+        try {
+            return OBJECT_MAPPER.readValue(labelsJson, new TypeReference<>() { });
+        } catch (Exception error) {
+            throw new IllegalStateException("Unable to read alert labels", error);
+        }
+    }
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertSilenceRepository.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertSilenceRepository.java
new file mode 100644
index 000000000..18c0284d8
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertSilenceRepository.java
@@ -0,0 +1,96 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import org.apache.rocketmq.studio.persistence.entity.RmqAlertSilence;
+import org.apache.rocketmq.studio.persistence.mapper.RmqAlertSilenceMapper;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+@Repository
+@RequiredArgsConstructor
+public class MybatisPlusAlertSilenceRepository implements AlertSilenceRepository {
+    private final RmqAlertSilenceMapper mapper;
+    private final ObjectMapper objectMapper;
+
+    @Override
+    public AlertSilenceVO save(AlertSilenceVO silence) {
+        RmqAlertSilence entity = toEntity(silence);
+        mapper.insert(entity);
+        silence.setId(entity.getId());
+        return silence;
+    }
+
+    @Override
+    public List<AlertSilenceVO> findAll() {
+        return mapper.selectList(new QueryWrapper<RmqAlertSilence>()
+                        .orderByDesc("ends_at").orderByDesc("id"))
+                .stream().map(this::toVo).toList();
+    }
+
+    @Override
+    public boolean deleteById(Long id) {
+        return mapper.deleteById(id) > 0;
+    }
+
+    private RmqAlertSilence toEntity(AlertSilenceVO silence) {
+        RmqAlertSilence entity = new RmqAlertSilence();
+        entity.setDomain(silence.getDomain() == null ? null : silence.getDomain().name());
+        entity.setRuleId(silence.getRuleId());
+        entity.setInstanceId(silence.getInstanceId());
+        entity.setLabelsJson(writeLabels(silence.getLabels()));
+        entity.setStartsAt(silence.getStartsAt());
+        entity.setEndsAt(silence.getEndsAt());
+        entity.setReason(silence.getReason());
+        entity.setCreatedBy(silence.getCreatedBy());
+        return entity;
+    }
+
+    private AlertSilenceVO toVo(RmqAlertSilence entity) {
+        return AlertSilenceVO.builder().id(entity.getId())
+                .domain(entity.getDomain() == null ? null : AlertDomain.valueOf(entity.getDomain()))
+                .ruleId(entity.getRuleId()).instanceId(entity.getInstanceId())
+                .labels(readLabels(entity.getLabelsJson()))
+                .startsAt(entity.getStartsAt()).endsAt(entity.getEndsAt())
+                .reason(entity.getReason()).createdBy(entity.getCreatedBy()).build();
+    }
+
+    private String writeLabels(Map<String, String> labels) {
+        if (labels == null || labels.isEmpty()) return null;
+        try {
+            return objectMapper.writeValueAsString(new TreeMap<>(labels));
+        } catch (Exception error) {
+            throw new IllegalArgumentException("Unable to serialize alert silence labels", error);
+        }
+    }
+
+    private Map<String, String> readLabels(String labelsJson) {
+        if (labelsJson == null || labelsJson.isBlank()) return Map.of();
+        try {
+            return objectMapper.readValue(labelsJson, new TypeReference<>() { });
+        } catch (Exception error) {
+            throw new IllegalStateException("Unable to read alert silence labels", error);
+        }
+    }
+}
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
new file mode 100644
index 000000000..aa0d85262
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertStateRepository.java
@@ -0,0 +1,127 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import lombok.RequiredArgsConstructor;
+import org.apache.rocketmq.studio.persistence.entity.RmqAlertState;
+import org.apache.rocketmq.studio.persistence.mapper.RmqAlertStateMapper;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Repository;
+
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.Optional;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Repository
+@RequiredArgsConstructor
+public class MybatisPlusAlertStateRepository implements AlertStateRepository {
+    private final RmqAlertStateMapper mapper;
+
+    @Override
+    public Optional<AlertRuleState> find(AlertStateKey key) {
+        return Optional.ofNullable(mapper.selectOne(new QueryWrapper<RmqAlertState>()
+                .eq("rule_id", key.ruleId()).eq("fingerprint", key.fingerprint()).last("LIMIT 1")))
+                .map(MybatisPlusAlertStateRepository::toState);
+    }
+
+    @Override
+    public boolean save(AlertStateKey key, AlertRuleState state) {
+        RmqAlertState entity = mapper.selectOne(new QueryWrapper<RmqAlertState>()
+                .eq("rule_id", key.ruleId()).eq("fingerprint", key.fingerprint()).last("LIMIT 1"));
+        if (entity == null) {
+            entity = new RmqAlertState();
+            entity.setRuleId(key.ruleId());
+            entity.setFingerprint(key.fingerprint());
+            entity.setVersion(0);
+            apply(entity, state);
+            try {
+                return mapper.insert(entity) == 1;
+            } catch (DuplicateKeyException ignored) {
+                return false;
+            }
+        } else {
+            int version = entity.getVersion() == null ? 0 : entity.getVersion();
+            apply(entity, state);
+            // A concurrent acknowledgement wins over this sample. The next collection cycle reloads the state.
+            return mapper.updateIfVersion(entity, version) == 1;
+        }
+    }
+
+    @Override
+    public boolean acknowledge(AlertStateKey key, Instant firedAt) {
+        if (firedAt == null) {
+            return false;
+        }
+        return mapper.acknowledgeFiring(key.ruleId(), key.fingerprint(), toLocal(firedAt),
+                LocalDateTime.now(ZoneOffset.UTC)) > 0;
+    }
+
+    @Override
+    public void deleteByRuleId(Long ruleId) {
+        if (ruleId != null) {
+            mapper.delete(new QueryWrapper<RmqAlertState>().eq("rule_id", ruleId));
+        }
+    }
+
+    @Override
+    public List<AlertRuleRuntimeVO> findRuntimeByRuleIds(List<AlertRuleVO> rules) {
+        Map<Long, AlertRuleVO> byId = rules.stream().filter(rule -> rule.getId() != null)
+                .collect(Collectors.toMap(AlertRuleVO::getId, rule -> rule));
+        if (byId.isEmpty()) return List.of();
+        return mapper.selectList(new QueryWrapper<RmqAlertState>().in("rule_id", byId.keySet())).stream()
+                .map(state -> toRuntime(state, byId.get(state.getRuleId()))).toList();
+    }
+
+    private static AlertRuleRuntimeVO toRuntime(RmqAlertState entity, AlertRuleVO rule) {
+        LocalDateTime next = entity.getLastNotifiedAt() == null || rule == null ? null
+                : entity.getLastNotifiedAt().plus(AlertRuleDuration.parse(rule.getReminderInterval()));
+        return AlertRuleRuntimeVO.builder().ruleId(entity.getRuleId()).fingerprint(entity.getFingerprint())
+                .status(AlertStateStatus.valueOf(entity.getStatus())).consecutiveHits(entity.getConsecutiveHits() == null ? 0 : entity.getConsecutiveHits())
+                .currentValue(entity.getCurrentValue()).lastNotifiedAt(entity.getLastNotifiedAt()).nextReminderAt(next).build();
+    }
+
+    private static void apply(RmqAlertState entity, AlertRuleState state) {
+        entity.setStatus(state.status().name());
+        entity.setConsecutiveHits(state.consecutiveHits());
+        entity.setCurrentValue(state.currentValue());
+        entity.setFirstPendingAt(toLocal(state.firstPendingAt()));
+        entity.setFiredAt(toLocal(state.firedAt()));
+        entity.setLastNotifiedAt(toLocal(state.lastNotifiedAt()));
+        entity.setResolvedAt(toLocal(state.resolvedAt()));
+        entity.setGmtModified(LocalDateTime.now(ZoneOffset.UTC));
+    }
+
+    private static AlertRuleState toState(RmqAlertState entity) {
+        return new AlertRuleState(AlertStateStatus.valueOf(entity.getStatus()),
+                entity.getConsecutiveHits() == null ? 0 : entity.getConsecutiveHits(), entity.getCurrentValue(),
+                toInstant(entity.getFirstPendingAt()), toInstant(entity.getFiredAt()),
+                toInstant(entity.getLastNotifiedAt()), toInstant(entity.getResolvedAt()));
+    }
+
+    private static LocalDateTime toLocal(Instant instant) {
+        return instant == null ? null : LocalDateTime.ofInstant(instant, ZoneOffset.UTC);
+    }
+
+    private static Instant toInstant(LocalDateTime value) {
+        return value == null ? null : value.toInstant(ZoneOffset.UTC);
+    }
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertMetricCatalogController.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertMetricCatalogController.java
new file mode 100644
index 000000000..b04fd8ef7
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertMetricCatalogController.java
@@ -0,0 +1,29 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import lombok.RequiredArgsConstructor;
+import org.apache.rocketmq.studio.common.domain.Result;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/native-alert-metrics")
+@RequiredArgsConstructor
+public class NativeAlertMetricCatalogController {
+    private final NativeAlertMetricCatalogService catalogService;
+
+    @GetMapping
+    public Result<List<NativeAlertMetricInfo>> list(@RequestParam String instanceId,
+            @RequestParam AlertDomain domain) {
+        return Result.ok(catalogService.list(instanceId, domain));
+    }
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertMetricCatalogService.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertMetricCatalogService.java
new file mode 100644
index 000000000..ebb1cce24
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertMetricCatalogService.java
@@ -0,0 +1,81 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import lombok.RequiredArgsConstructor;
+import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.instance.InstanceRepository;
+import org.apache.rocketmq.studio.instance.InstanceVO;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Set;
+
+/** Declares only metrics backed by native collectors for the selected provider. */
+@Service
+@RequiredArgsConstructor
+public class NativeAlertMetricCatalogService {
+    private static final Set<String> NATIVE_METRICS = Set.of("nameserver.availability", "broker.availability",
+            "proxy.availability", "cloud.instance.availability",
+            "broker.disk.usage_ratio", "broker.jvm.heap.usage_ratio", "consumer.lag.total",
+            "broker.send_queue.usage_ratio",
+            "consumer.lag.max_queue", "consumer.delay.seconds", "topic.backlog.total", "dlq.message.count");
+    private static final List<NativeAlertMetricInfo> CLUSTER_APACHE = List.of(
+            new NativeAlertMetricInfo("nameserver.availability", "NameServer availability", "", false),
+            new NativeAlertMetricInfo("broker.availability", "Broker availability", "", false),
+            new NativeAlertMetricInfo("proxy.availability", "Proxy availability", "", false),
+            new NativeAlertMetricInfo("broker.disk.usage_ratio", "Broker disk usage ratio", "ratio", false),
+            new NativeAlertMetricInfo("broker.jvm.heap.usage_ratio", "Broker JVM heap usage ratio", "ratio", false),
+            new NativeAlertMetricInfo("broker.send_queue.usage_ratio", "Broker send queue usage ratio", "ratio", false));
+    private static final List<NativeAlertMetricInfo> BUSINESS_APACHE = List.of(
+            new NativeAlertMetricInfo("consumer.lag.total", "Consumer lag total", "messages", true),
+            new NativeAlertMetricInfo("consumer.lag.max_queue", "Consumer lag max queue", "messages", true),
+            new NativeAlertMetricInfo("consumer.delay.seconds", "Consumer delay", "seconds", true),
+            new NativeAlertMetricInfo("topic.backlog.total", "Topic backlog (per consumer group)", "messages", true),
+            new NativeAlertMetricInfo("dlq.message.count", "DLQ message count", "messages", true));
+    private static final List<NativeAlertMetricInfo> BUSINESS_CLOUD = List.of(
+            new NativeAlertMetricInfo("consumer.lag.total", "Consumer lag total", "messages", true),
+            new NativeAlertMetricInfo("consumer.lag.max_queue", "Consumer lag max queue", "messages", true),
+            new NativeAlertMetricInfo("topic.backlog.total", "Topic backlog (per consumer group)", "messages", true));
+    private static final List<NativeAlertMetricInfo> CLUSTER_CLOUD = List.of(
+            new NativeAlertMetricInfo("cloud.instance.availability", "Cloud instance availability", "", false));
+
+    private final InstanceRepository instanceRepository;
+
+    public List<NativeAlertMetricInfo> list(String instanceId, AlertDomain domain) {
+        if (instanceId == null || instanceId.isBlank()) {
+            throw new BusinessException(400, "instanceId is required");
+        }
+        InstanceVO instance = instanceRepository.findByIdentifier(instanceId.trim())
+                .orElseThrow(() -> new BusinessException(404, "Instance not found: " + instanceId));
+        if (instance.getVendor() == null || instance.getVendor() == InstanceVendor.APACHE) {
+            return domain == AlertDomain.CLUSTER ? CLUSTER_APACHE : BUSINESS_APACHE;
+        }
+        if (domain == AlertDomain.BUSINESS && (instance.getVendor() == InstanceVendor.ALIYUN
+                || instance.getVendor() == InstanceVendor.TENCENT)) {
+            return BUSINESS_CLOUD;
+        }
+        if (domain == AlertDomain.CLUSTER && (instance.getVendor() == InstanceVendor.ALIYUN
+                || instance.getVendor() == InstanceVendor.TENCENT)) {
+            return CLUSTER_CLOUD;
+        }
+        return List.of();
+    }
+
+    public void validate(AlertRuleVO rule) {
+        if (rule == null || rule.getMetric() == null || !NATIVE_METRICS.contains(rule.getMetric())) {
+            return;
+        }
+        boolean supported = list(rule.getInstanceId(), rule.getDomain()).stream()
+                .anyMatch(metric -> metric.key().equals(rule.getMetric()));
+        if (!supported) {
+            throw new BusinessException(400, "Native metric " + rule.getMetric()
+                    + " is not supported by the selected Studio instance");
+        }
+    }
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertMetricInfo.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertMetricInfo.java
new file mode 100644
index 000000000..07d7d2420
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertMetricInfo.java
@@ -0,0 +1,12 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+/** A native metric that can be collected for one managed Studio instance. */
+public record NativeAlertMetricInfo(String key, String label, String thresholdUnit,
+                                    boolean supportsConsumerGroup) {
+}
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
new file mode 100644
index 000000000..2d5b31103
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertProcessor.java
@@ -0,0 +1,146 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import lombok.RequiredArgsConstructor;
+import org.apache.rocketmq.studio.cluster.metrics.MetricSample;
+import org.apache.rocketmq.studio.cluster.metrics.MetricSnapshotRepository;
+import org.apache.rocketmq.studio.cluster.metrics.MetricAvailability;
+import org.apache.rocketmq.studio.common.domain.enums.AlertLevel;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.time.Duration;
+import java.time.ZoneOffset;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.TreeMap;
+import java.util.EnumMap;
+
+/** Applies native samples to persisted rule state and emits only lifecycle transitions. */
+@Component
+@RequiredArgsConstructor
+public class NativeAlertProcessor {
+    private final AlertService alertService;
+    private final AlertRuleEvaluator evaluator;
+    private final AlertStateMachine stateMachine;
+    private final AlertStateRepository stateRepository;
+    private final MetricSnapshotRepository snapshotRepository;
+    private final AlertRepository alertRepository;
+    private final NotificationOutboxService notificationOutboxService;
+    private final AlertNotificationSuppressionService notificationSuppressionService;
+
+    @Transactional
+    public void process(List<MetricSample> samples) {
+        Map<AlertDomain, List<AlertRuleVO>> rulesByDomain = new EnumMap<>(AlertDomain.class);
+        for (MetricSample sample : samples) {
+            for (AlertRuleVO rule : rulesByDomain.computeIfAbsent(sample.domain(), alertService::listRules)) {
+                if (rule.getId() == null) {
+                    continue;
+                }
+                if (!rule.isEnabled()) {
+                    continue;
+                }
+                if (!NativeAlertRuleScopeMatcher.matches(rule, sample)) {
+                    continue;
+                }
+                MetricSample evaluatedSample = aggregate(rule, sample);
+                AlertEvaluationResult evaluation = evaluator.evaluate(rule, evaluatedSample);
+                if (!evaluation.matches()) {
+                    continue;
+                }
+                AlertStateKey key = new AlertStateKey(rule.getId(),
+                        AlertFingerprint.of(rule.getId(), sample.instanceId(), sample.labels()));
+                AlertStateUpdate update = stateMachine.advance(stateRepository.find(key).orElse(null), evaluation,
+                        Math.max(1, rule.getConsecutiveSamples()), AlertRuleDuration.parse(rule.getDuration()),
+                        AlertRuleDuration.parse(rule.getReminderInterval()), sample.collectedAt());
+                if (!stateRepository.save(key, update.state())) {
+                    continue;
+                }
+                if (update.transition() == AlertStateTransition.FIRING || update.transition() == AlertStateTransition.REMINDER
+                        || update.transition() == AlertStateTransition.RESOLVED) {
+                    LocalDateTime eventTime = LocalDateTime.ofInstant(sample.collectedAt(), ZoneOffset.UTC);
+                    SystemAlertVO event = SystemAlertVO.builder().level(level(rule.getSeverity()))
+                            .title(rule.getName()).description(update.transition() + " " + sample.metricKey()
+                                    + " on " + sample.instanceId()).time(eventTime)
+                            .acknowledged(false).domain(sample.domain()).ruleId(rule.getId())
+                            .fingerprint(key.fingerprint()).transition(update.transition().name())
+                            .instanceId(sample.instanceId()).currentValue(update.state().currentValue())
+                            .labels(Map.copyOf(new TreeMap<>(sample.labels()))).build();
+                    boolean suppressNotification = shouldSuppress(sample.domain(), update.transition());
+                    if (suppressNotification) {
+                        Optional<SystemAlertVO> cause = notificationSuppressionService.findSuppressingClusterAlert(event);
+                        if (cause.isPresent()) {
+                            event.setNotificationSuppressed(true);
+                            event.setSuppressionCauseAlertId(cause.get().getId());
+                            event.setSuppressionReason("Suppressed by active cluster incident #" + cause.get().getId()
+                                    + ": " + cause.get().getTitle());
+                        }
+                    }
+                    SystemAlertVO savedEvent = alertRepository.saveAlert(event);
+                    if (savedEvent != null) {
+                        event = savedEvent;
+                    }
+                    if (update.transition() == AlertStateTransition.FIRING) {
+                        alertRepository.markRuleTriggered(rule.getId(), eventTime.toString());
+                    }
+                    if (!event.isNotificationSuppressed()) {
+                        notificationOutboxService.enqueue(event, rule, sample.labels());
+                    }
+                }
+            }
+        }
+    }
+
+    private static boolean shouldSuppress(AlertDomain domain, AlertStateTransition transition) {
+        return domain == AlertDomain.BUSINESS
+                && (transition == AlertStateTransition.FIRING || transition == AlertStateTransition.REMINDER);
+    }
+
+    private MetricSample aggregate(AlertRuleVO rule, MetricSample sample) {
+        if (sample.availability() != MetricAvailability.AVAILABLE || rule.getWindowSeconds() <= 0) {
+            return sample;
+        }
+        List<MetricSample> window = snapshotRepository.findRecent(sample,
+                sample.collectedAt().minus(Duration.ofSeconds(rule.getWindowSeconds())));
+        if (window.isEmpty()) {
+            return sample;
+        }
+        double value = switch (rule.getAggregation() == null ? "LAST" : rule.getAggregation().toUpperCase()) {
+            case "MAX" -> window.stream().mapToDouble(item -> item.value()).max().orElse(sample.value());
+            case "MIN" -> window.stream().mapToDouble(item -> item.value()).min().orElse(sample.value());
+            case "AVG" -> window.stream().mapToDouble(item -> item.value()).average().orElse(sample.value());
+            case "SUM" -> window.stream().mapToDouble(item -> item.value()).sum();
+            default -> window.get(window.size() - 1).value();
+        };
+        return new MetricSample(sample.metricKey(), sample.domain(), sample.instanceId(), sample.clusterId(), sample.labels(),
+                value, MetricAvailability.AVAILABLE, sample.collectedAt());
+    }
+
+    private static AlertLevel level(String severity) {
+        if ("critical".equalsIgnoreCase(severity)) {
+            return AlertLevel.error;
+        }
+        if ("warning".equalsIgnoreCase(severity)) {
+            return AlertLevel.warning;
+        }
+        return AlertLevel.info;
+    }
+
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertRulePolicy.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertRulePolicy.java
new file mode 100644
index 000000000..cce2e7c58
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertRulePolicy.java
@@ -0,0 +1,91 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.springframework.util.StringUtils;
+
+import java.util.Map;
+import java.util.Set;
+
+/** Enforces the scope required to safely evaluate Studio-native metric rules. */
+final class NativeAlertRulePolicy {
+    private static final Map<String, AlertDomain> NATIVE_METRICS = Map.ofEntries(
+            Map.entry("nameserver.availability", AlertDomain.CLUSTER),
+            Map.entry("broker.availability", AlertDomain.CLUSTER),
+            Map.entry("proxy.availability", AlertDomain.CLUSTER),
+            Map.entry("cloud.instance.availability", AlertDomain.CLUSTER),
+            Map.entry("broker.disk.usage_ratio", AlertDomain.CLUSTER),
+            Map.entry("broker.jvm.heap.usage_ratio", AlertDomain.CLUSTER),
+            Map.entry("broker.send_queue.usage_ratio", AlertDomain.CLUSTER),
+            Map.entry("consumer.lag.total", AlertDomain.BUSINESS),
+            Map.entry("consumer.lag.max_queue", AlertDomain.BUSINESS),
+            Map.entry("consumer.delay.seconds", AlertDomain.BUSINESS),
+            Map.entry("topic.backlog.total", AlertDomain.BUSINESS),
+            Map.entry("dlq.message.count", AlertDomain.BUSINESS));
+    private static final Set<String> GROUP_SCOPED_METRICS = Set.of(
+            "consumer.lag.total", "consumer.lag.max_queue", "consumer.delay.seconds", "topic.backlog.total",
+            "dlq.message.count");
+    private static final Set<String> TOPIC_SCOPED_METRICS = Set.of("topic.backlog.total");
+    private static final Set<String> AVAILABILITY_METRICS = Set.of(
+            "nameserver.availability", "broker.availability", "proxy.availability", "cloud.instance.availability");
+    private static final Set<String> NOTIFICATION_CHANNELS = Set.of("dingtalk", "sms", "email");
+
+    private NativeAlertRulePolicy() {
+    }
+
+    static void validate(AlertRuleVO rule) {
+        validateChannels(rule);
+        if (!StringUtils.hasText(rule.getMetric())) {
+            return;
+        }
+        AlertDomain metricDomain = NATIVE_METRICS.get(rule.getMetric());
+        if (metricDomain == null) {
+            return;
+        }
+        if (rule.getDomain() != metricDomain) {
+            throw new BusinessException(400, "Native metric " + rule.getMetric()
+                    + " belongs to the " + metricDomain + " alert domain");
+        }
+        if (!StringUtils.hasText(rule.getInstanceId())) {
+            throw new BusinessException(400, "instanceId is required for native alert rules");
+        }
+        if ("UNAVAILABLE".equals(rule.getOperator()) && !AVAILABILITY_METRICS.contains(rule.getMetric())) {
+            throw new BusinessException(400, "UNAVAILABLE is only supported for native availability metrics");
+        }
+        if (StringUtils.hasText(rule.getConsumerGroup()) && !GROUP_SCOPED_METRICS.contains(rule.getMetric())) {
+            throw new BusinessException(400, "consumerGroup is not supported for metric " + rule.getMetric());
+        }
+        if (StringUtils.hasText(rule.getTopic()) && !TOPIC_SCOPED_METRICS.contains(rule.getMetric())) {
+            throw new BusinessException(400, "topic is not supported for metric " + rule.getMetric());
+        }
+        if (rule.getConsecutiveSamples() < 1) {
+            throw new BusinessException(400, "consecutiveSamples must be at least 1");
+        }
+    }
+
+    private static void validateChannels(AlertRuleVO rule) {
+        if (rule.getChannels() == null) {
+            return;
+        }
+        for (String channel : rule.getChannels()) {
+            if (!StringUtils.hasText(channel) || !NOTIFICATION_CHANNELS.contains(channel.trim().toLowerCase())) {
+                throw new BusinessException(400, "Unsupported notification channel: " + channel);
+            }
+        }
+    }
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertRuleScopeMatcher.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertRuleScopeMatcher.java
new file mode 100644
index 000000000..cc8017c5f
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertRuleScopeMatcher.java
@@ -0,0 +1,45 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import org.apache.rocketmq.studio.cluster.metrics.MetricSample;
+import org.springframework.util.StringUtils;
+
+/** Matches native samples against the instance and optional resource selectors on a rule. */
+final class NativeAlertRuleScopeMatcher {
+    private NativeAlertRuleScopeMatcher() {
+    }
+
+    static boolean matches(AlertRuleVO rule, MetricSample sample) {
+        if (!StringUtils.hasText(rule.getInstanceId())
+                || !rule.getInstanceId().trim().equals(sample.instanceId())) {
+            return false;
+        }
+        String consumerGroup = sample.labels() == null ? null : sample.labels().get("consumerGroup");
+        String brokerName = sample.labels() == null ? null : sample.labels().get("brokerName");
+        String topic = sample.labels() == null ? null : sample.labels().get("topic");
+        return matchesSelector(rule.getConsumerGroup(), consumerGroup)
+                && matchesSelector(rule.getTopic(), topic)
+                && matchesSelector(rule.getBrokerName(), brokerName)
+                && matchesSelector(rule.getClusterName(), sample.clusterId());
+    }
+
+    private static boolean matchesSelector(String selector, String value) {
+        return !StringUtils.hasText(selector) || "*".equals(selector.trim())
+                || selector.trim().equals(value);
+    }
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertRuleTestService.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertRuleTestService.java
new file mode 100644
index 000000000..786a06da4
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertRuleTestService.java
@@ -0,0 +1,64 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import lombok.RequiredArgsConstructor;
+import org.apache.rocketmq.studio.cluster.metrics.BusinessMetricsCollector;
+import org.apache.rocketmq.studio.cluster.metrics.ClusterMetricsCollector;
+import org.apache.rocketmq.studio.cluster.metrics.MetricSample;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.instance.InstanceRepository;
+import org.apache.rocketmq.studio.instance.InstanceVO;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/** Executes a native rule once without persisting a snapshot, state, or event. */
+@Service
+@RequiredArgsConstructor
+public class NativeAlertRuleTestService {
+    private final InstanceRepository instanceRepository;
+    private final List<ClusterMetricsCollector> clusterCollectors;
+    private final List<BusinessMetricsCollector> businessCollectors;
+    private final AlertRuleEvaluator evaluator;
+
+    public AlertRuleTestResultVO test(AlertRuleVO rule) {
+        NativeAlertRulePolicy.validate(rule);
+        InstanceVO instance = instanceRepository.findByIdentifier(rule.getInstanceId())
+                .orElseThrow(() -> new BusinessException(404, "Instance not found: " + rule.getInstanceId()));
+        List<MetricSample> samples = new ArrayList<>();
+        if (rule.getDomain() == AlertDomain.CLUSTER) {
+            clusterCollectors.stream().filter(collector -> collector.supports(instance))
+                    .forEach(collector -> samples.addAll(collector.collect(instance)));
+        } else {
+            businessCollectors.stream().filter(collector -> collector.supports(instance))
+                    .forEach(collector -> samples.addAll(collector.collect(instance)));
+        }
+        return AlertRuleTestResultVO.builder().samples(samples.stream()
+                .filter(sample -> rule.getMetric().equals(sample.metricKey()))
+                .filter(sample -> NativeAlertRuleScopeMatcher.matches(rule, sample))
+                .map(sample -> {
+                    AlertEvaluationResult evaluation = evaluator.evaluate(rule, sample);
+                    return AlertRuleTestResultVO.Sample.builder().labels(sample.labels())
+                            .availability(sample.availability().name()).currentValue(evaluation.currentValue())
+                            .conditionMet(evaluation.conditionMet())
+                            .unavailableReason(sample.unavailableReason()).build();
+                }).toList()).build();
+    }
+
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationDeliveryBulkRetryResult.java
similarity index 76%
copy from server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
copy to server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationDeliveryBulkRetryResult.java
index a3b47d7d5..ae6fe9529 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationDeliveryBulkRetryResult.java
@@ -14,10 +14,15 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.rocketmq.studio.persistence.mapper;
+package org.apache.rocketmq.studio.ops.alert;
 
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import org.apache.rocketmq.studio.persistence.entity.RmqAlertRule;
+import lombok.Value;
 
-public interface RmqAlertRuleMapper extends BaseMapper<RmqAlertRule> {
+import java.util.List;
+import java.util.Map;
+
+@Value
+public class NotificationDeliveryBulkRetryResult {
+    List<Long> succeededIds;
+    Map<Long, String> failures;
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationDeliveryPageVO.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationDeliveryPageVO.java
new file mode 100644
index 000000000..c61aefe32
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationDeliveryPageVO.java
@@ -0,0 +1,32 @@
+/*
+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.
+*/
+package org.apache.rocketmq.studio.ops.alert;
+
+import lombok.Builder;
+import lombok.Value;
+
+import java.time.LocalDateTime;
+
+/** One notification delivery together with the alert event that produced it. */
+@Value
+@Builder
+public class NotificationDeliveryPageVO {
+    Long id;
+    Long alertId;
+    String channel;
+    NotificationOutboxStatus status;
+    int attemptCount;
+    LocalDateTime nextAttemptAt;
+    String lastError;
+    LocalDateTime deliveredAt;
+    LocalDateTime createdAt;
+    String messageContent;
+    String alertTitle;
+    AlertDomain alertDomain;
+    String transition;
+    String instanceId;
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationDeliveryVO.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationDeliveryVO.java
new file mode 100644
index 000000000..f68b0afb0
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationDeliveryVO.java
@@ -0,0 +1,23 @@
+/*
+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.
+*/
+package org.apache.rocketmq.studio.ops.alert;
+
+import lombok.Builder;
+import lombok.Value;
+import java.time.LocalDateTime;
+
+@Value
+@Builder
+public class NotificationDeliveryVO {
+    Long id;
+    String channel;
+    NotificationOutboxStatus status;
+    int attemptCount;
+    LocalDateTime nextAttemptAt;
+    String lastError;
+    LocalDateTime deliveredAt;
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxService.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxService.java
new file mode 100644
index 000000000..26acbe785
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxService.java
@@ -0,0 +1,438 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.studio.audit.OperationAuditService;
+import org.apache.rocketmq.studio.common.domain.PageResult;
+import org.apache.rocketmq.studio.common.util.NoRedirectClientHttpRequestFactory;
+import org.apache.rocketmq.studio.common.util.UrlHostGuard;
+import org.apache.rocketmq.studio.persistence.entity.RmqAlertNotificationOutbox;
+import org.apache.rocketmq.studio.persistence.mapper.RmqAlertNotificationOutboxMapper;
+import org.apache.rocketmq.studio.settings.GeneralSettingsVO;
+import org.apache.rocketmq.studio.settings.SettingsRepository;
+import org.springframework.http.ResponseEntity;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.mail.SimpleMailMessage;
+import org.springframework.mail.javamail.JavaMailSender;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+import org.springframework.web.client.RestTemplate;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.ArrayList;
+import java.util.function.Supplier;
+import java.util.UUID;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import jakarta.mail.internet.AddressException;
+import jakarta.mail.internet.InternetAddress;
+
+/** Persists notification work so collection never blocks on remote webhook availability. */
+@Slf4j
+@Service
+public class NotificationOutboxService {
+    private static final int MAX_ATTEMPTS = 5;
+    private static final int BATCH_SIZE = 20;
+    private static final Duration CLAIM_TIMEOUT = Duration.ofMinutes(1);
+    private static final ObjectMapper JSON = new ObjectMapper();
+
+    private final RmqAlertNotificationOutboxMapper mapper;
+    private final SettingsRepository settingsRepository;
+    private final AlertSilenceService silenceService;
+    private final AlertRepository alertRepository;
+    private final OperationAuditService operationAuditService;
+    private final RestTemplate restTemplate;
+    private final Supplier<JavaMailSender> mailSender;
+
+    NotificationOutboxService(RmqAlertNotificationOutboxMapper mapper, SettingsRepository settingsRepository,
+            AlertSilenceService silenceService, AlertRepository alertRepository,
+            OperationAuditService operationAuditService) {
+        this(mapper, settingsRepository, silenceService, alertRepository, operationAuditService, newClient(),
+                () -> null);
+    }
+
+    @Autowired
+    public NotificationOutboxService(RmqAlertNotificationOutboxMapper mapper, SettingsRepository settingsRepository,
+            AlertSilenceService silenceService, AlertRepository alertRepository,
+            OperationAuditService operationAuditService, ObjectProvider<JavaMailSender> mailSender) {
+        this(mapper, settingsRepository, silenceService, alertRepository, operationAuditService, newClient(),
+                mailSender::getIfAvailable);
+    }
+
+    NotificationOutboxService(RmqAlertNotificationOutboxMapper mapper, SettingsRepository settingsRepository,
+            AlertSilenceService silenceService, AlertRepository alertRepository,
+            OperationAuditService operationAuditService, RestTemplate restTemplate) {
+        this(mapper, settingsRepository, silenceService, alertRepository, operationAuditService, restTemplate,
+                () -> null);
+    }
+
+    NotificationOutboxService(RmqAlertNotificationOutboxMapper mapper, SettingsRepository settingsRepository,
+            AlertSilenceService silenceService, AlertRepository alertRepository,
+            OperationAuditService operationAuditService, RestTemplate restTemplate,
+            Supplier<JavaMailSender> mailSender) {
+        this.mapper = mapper;
+        this.settingsRepository = settingsRepository;
+        this.silenceService = silenceService;
+        this.alertRepository = alertRepository;
+        this.operationAuditService = operationAuditService;
+        this.restTemplate = restTemplate;
+        this.mailSender = mailSender;
+    }
+
+    public void enqueue(SystemAlertVO alert, AlertRuleVO rule) {
+        enqueue(alert, rule, Map.of());
+    }
+
+    public void sendTestMessage(String channel) {
+        if (!"dingtalk".equals(channel) && !"email".equals(channel) && !"sms".equals(channel)) {
+            throw new IllegalArgumentException("Unsupported notification channel: " + channel);
+        }
+        GeneralSettingsVO settings = settingsRepository.loadGeneralSettings();
+        SystemAlertVO alert = SystemAlertVO.builder().level(org.apache.rocketmq.studio.common.domain.enums.AlertLevel.info)
+                .title("RocketMQ Studio test notification").description("DingTalk notification configuration is working.")
+                .build();
+        try {
+            String content = AlertNotificationTemplate.render(null, alert, null);
+            if ("email".equals(channel)) sendEmail(settings, alert, content);
+            else sendWebhook(settings, alert, channel, content);
+        } catch (Exception error) {
+            throw new IllegalStateException("Test notification failed: " + error.getMessage(), error);
+        }
+    }
+
+    public void enqueue(SystemAlertVO alert, AlertRuleVO rule, Map<String, String> labels) {
+        if (alert.getId() == null) {
+            throw new IllegalStateException("Cannot enqueue notification for an alert without a persistent ID");
+        }
+        Map<String, String> effectiveLabels = labels == null ? Map.of() : labels;
+        LocalDateTime silenceEndsAt = silenceService.activeUntil(rule, alert.getInstanceId(), effectiveLabels,
+                alert.getTime());
+        Set<String> channels = new LinkedHashSet<>();
+        if (rule.getChannels() != null) {
+            rule.getChannels().stream().filter(StringUtils::hasText)
+                    .map(value -> value.trim().toLowerCase()).forEach(channels::add);
+        }
+        for (String channel : channels) {
+            if (!"dingtalk".equals(channel) && !"sms".equals(channel) && !"email".equals(channel)) {
+                continue;
+            }
+            RmqAlertNotificationOutbox row = new RmqAlertNotificationOutbox();
+            row.setAlertId(alert.getId());
+            row.setChannel(channel);
+            row.setStatus(NotificationOutboxStatus.PENDING.name());
+            row.setAttemptCount(0);
+            row.setMessageContent(AlertNotificationTemplate.render(rule.getNotificationTemplate(), alert, rule));
+            row.setNextAttemptAt(silenceEndsAt == null ? utcNow() : silenceEndsAt);
+            mapper.insert(row);
+        }
+    }
+
+    public List<NotificationDeliveryVO> listDeliveries(Long alertId) {
+        if (alertId == null) {
+            throw new org.apache.rocketmq.studio.common.exception.BusinessException(400, "Alert ID is required");
+        }
+        return mapper.selectList(new QueryWrapper<RmqAlertNotificationOutbox>().eq("alert_id", alertId)
+                        .orderByAsc("id"))
+                .stream().map(row -> NotificationDeliveryVO.builder().id(row.getId()).channel(row.getChannel())
+                        .status(NotificationOutboxStatus.valueOf(row.getStatus()))
+                        .attemptCount(row.getAttemptCount() == null ? 0 : row.getAttemptCount())
+                        .nextAttemptAt(row.getNextAttemptAt()).lastError(row.getLastError())
+                        .deliveredAt(row.getDeliveredAt()).build()).toList();
+    }
+
+    public PageResult<NotificationDeliveryPageVO> listDeliveries(String channel, String status, String instanceId,
+            int page, int pageSize) {
+        int safePage = Math.max(1, page);
+        int safePageSize = Math.min(100, Math.max(1, pageSize));
+        String normalizedChannel = normalizeFilter(channel);
+        String normalizedStatus = normalizeStatus(status);
+        String normalizedInstanceId = normalizeTrim(instanceId);
+        long total = mapper.countPage(normalizedChannel, normalizedStatus, normalizedInstanceId);
+        if (total == 0) {
+            return PageResult.empty(safePage, safePageSize);
+        }
+        return PageResult.of(mapper.findPage(normalizedChannel, normalizedStatus, normalizedInstanceId, safePageSize,
+                (long) (safePage - 1) * safePageSize), total, safePage, safePageSize);
+    }
+
+    public void retryFailedDelivery(Long deliveryId) {
+        if (deliveryId == null || deliveryId <= 0) {
+            throw new org.apache.rocketmq.studio.common.exception.BusinessException(400,
+                    "Notification delivery ID is required");
+        }
+        RmqAlertNotificationOutbox row = mapper.selectById(deliveryId);
+        if (row == null || !NotificationOutboxStatus.FAILED.name().equals(row.getStatus())) {
+            throw new org.apache.rocketmq.studio.common.exception.BusinessException(400,
+                    "Only failed notification deliveries can be retried");
+        }
+        LocalDateTime now = utcNow();
+        int updated = mapper.update(null, new UpdateWrapper<RmqAlertNotificationOutbox>()
+                .set("status", NotificationOutboxStatus.PENDING.name()).set("attempt_count", 0)
+                .set("next_attempt_at", now).set("sending_started_at", null).set("claim_token", null)
+                .set("last_error", null).eq("id", deliveryId).eq("status", NotificationOutboxStatus.FAILED.name()));
+        if (updated != 1) {
+            throw new org.apache.rocketmq.studio.common.exception.BusinessException(400,
+                    "Only failed notification deliveries can be retried");
+        }
+        recordDelivery(row, "RETRY_ALERT_NOTIFICATION_MANUALLY", "SUCCESS", null);
+    }
+
+    public NotificationDeliveryBulkRetryResult retryFailedDeliveries(List<Long> deliveryIds) {
+        if (deliveryIds == null || deliveryIds.isEmpty() || deliveryIds.size() > 100) {
+            throw new org.apache.rocketmq.studio.common.exception.BusinessException(400,
+                    "Provide between 1 and 100 notification delivery IDs");
+        }
+        List<Long> succeeded = new ArrayList<>();
+        Map<Long, String> failures = new java.util.LinkedHashMap<>();
+        for (Long deliveryId : new LinkedHashSet<>(deliveryIds)) {
+            try {
+                retryFailedDelivery(deliveryId);
+                succeeded.add(deliveryId);
+            } catch (org.apache.rocketmq.studio.common.exception.BusinessException error) {
+                failures.put(deliveryId, error.getMessage());
+            }
+        }
+        return new NotificationDeliveryBulkRetryResult(succeeded, failures);
+    }
+
+    private static String normalizeFilter(String value) {
+        String normalized = normalizeTrim(value);
+        return normalized == null ? null : normalized.toLowerCase();
+    }
+
+    private static String normalizeTrim(String value) {
+        return StringUtils.hasText(value) ? value.trim() : null;
+    }
+
+    private static String normalizeStatus(String status) {
+        String value = normalizeFilter(status);
+        if (value == null) {
+            return null;
+        }
+        try {
+            return NotificationOutboxStatus.valueOf(value.toUpperCase()).name();
+        } catch (IllegalArgumentException error) {
+            throw new org.apache.rocketmq.studio.common.exception.BusinessException(400,
+                    "Unknown notification delivery status: " + status);
+        }
+    }
+
+    @Scheduled(fixedDelayString = "${studio.alerting.notification-dispatch-interval:PT10S}")
+    public void dispatch() {
+        LocalDateTime now = utcNow();
+        LocalDateTime staleBefore = now.minus(CLAIM_TIMEOUT);
+        List<RmqAlertNotificationOutbox> due = mapper.findDispatchable(now, staleBefore, BATCH_SIZE);
+        for (RmqAlertNotificationOutbox row : due) {
+            String claimToken = UUID.randomUUID().toString();
+            if (mapper.claimForDispatch(row.getId(), now, staleBefore, now, claimToken) != 1) {
+                continue;
+            }
+            send(row, now, claimToken);
+        }
+    }
+
+    private void send(RmqAlertNotificationOutbox row, LocalDateTime now, String claimToken) {
+        try {
+            SystemAlertVO alert = loadAlert(row.getAlertId());
+            LocalDateTime silenceEndsAt = silenceService.activeUntil(
+                    AlertRuleVO.builder().id(alert.getRuleId()).domain(alert.getDomain()).build(),
+                    alert.getInstanceId(), alert.getLabels(), now);
+            if (silenceEndsAt != null) {
+                deferUntilSilenceEnds(row, silenceEndsAt, claimToken);
+                return;
+            }
+            GeneralSettingsVO settings = settingsRepository.loadGeneralSettings();
+            if ("email".equals(row.getChannel())) {
+                sendEmail(settings, alert, message(row, alert));
+            } else {
+                sendWebhook(settings, alert, row.getChannel(), message(row, alert));
+            }
+            if (!updateClaimed(row, claimToken, new UpdateWrapper<RmqAlertNotificationOutbox>()
+                    .set("status", NotificationOutboxStatus.DELIVERED.name()).set("delivered_at", now)
+                    .set("sending_started_at", null)
+                    .set("last_error", null))) {
+                return;
+            }
+            recordDelivery(row, "DELIVER_ALERT_NOTIFICATION", "SUCCESS", null);
+        } catch (Exception error) {
+            retry(row, now, claimToken, error.getMessage());
+        }
+    }
+
+    private void deferUntilSilenceEnds(RmqAlertNotificationOutbox row, LocalDateTime silenceEndsAt, String claimToken) {
+        updateClaimed(row, claimToken, new UpdateWrapper<RmqAlertNotificationOutbox>()
+                .set("status", NotificationOutboxStatus.PENDING.name()).set("next_attempt_at", silenceEndsAt)
+                .set("sending_started_at", null));
+    }
+
+    private void sendWebhook(GeneralSettingsVO settings, SystemAlertVO alert, String channel, String content) {
+        String webhook = webhook(settings, channel);
+        if (!StringUtils.hasText(webhook)) {
+            throw new IllegalStateException("No configured " + channel + " webhook");
+        }
+        UrlHostGuard.check(webhook, false);
+        ResponseEntity<String> response = restTemplate.postForEntity(dingTalkWebhook(webhook, settings, channel),
+                payload(alert, channel, content), String.class);
+        if (!response.getStatusCode().is2xxSuccessful()) {
+            throw new IllegalStateException("Webhook returned " + response.getStatusCode());
+        }
+        if ("dingtalk".equals(channel)) {
+            validateDingTalkResponse(response.getBody());
+        }
+    }
+
+    private static String dingTalkWebhook(String webhook, GeneralSettingsVO settings, String channel) {
+        if (!"dingtalk".equals(channel) || !StringUtils.hasText(settings.getDingtalkSigningSecret())) {
+            return webhook;
+        }
+        long timestamp = System.currentTimeMillis();
+        try {
+            Mac mac = Mac.getInstance("HmacSHA256");
+            mac.init(new SecretKeySpec(settings.getDingtalkSigningSecret().getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
+            String sign = Base64.getEncoder().encodeToString(mac.doFinal((timestamp + "\n" + settings.getDingtalkSigningSecret()).getBytes(StandardCharsets.UTF_8)));
+            return webhook + (webhook.contains("?") ? "&" : "?") + "timestamp=" + timestamp + "&sign="
+                    + URLEncoder.encode(sign, StandardCharsets.UTF_8);
+        } catch (Exception error) {
+            throw new IllegalStateException("Unable to sign DingTalk webhook", error);
+        }
+    }
+
+    private static void validateDingTalkResponse(String response) {
+        try {
+            JsonNode body = JSON.readTree(response);
+            if (body != null && body.path("errcode").canConvertToInt() && body.path("errcode").asInt() == 0) {
+                return;
+            }
+            String error = body == null ? null : body.path("errmsg").asText(null);
+            throw new IllegalStateException("DingTalk rejected webhook: "
+                    + (StringUtils.hasText(error == null ? null : error.toString()) ? error : "missing errcode"));
+        } catch (JsonProcessingException error) {
+            throw new IllegalStateException("DingTalk returned an invalid JSON response", error);
+        }
+    }
+
+    private SystemAlertVO loadAlert(Long id) {
+        return alertRepository.findAlertById(id)
+                .orElseThrow(() -> new IllegalStateException("Alert event no longer exists: " + id));
+    }
+
+    private String webhook(GeneralSettingsVO settings, String channel) {
+        return settings == null ? null : ("dingtalk".equals(channel)
+                ? settings.getDingtalkWebhook() : settings.getSmsWebhook());
+    }
+
+    private void sendEmail(GeneralSettingsVO settings, SystemAlertVO alert, String content) throws AddressException {
+        if (settings == null || !StringUtils.hasText(settings.getEmailRecipients())) {
+            throw new IllegalStateException("No configured email recipients");
+        }
+        JavaMailSender sender = mailSender.get();
+        if (sender == null) {
+            throw new IllegalStateException("SMTP is not configured");
+        }
+        SimpleMailMessage message = new SimpleMailMessage();
+        message.setTo(parseRecipients(settings.getEmailRecipients()));
+        message.setSubject("[RocketMQ Studio] " + alert.getTitle());
+        message.setText(content);
+        sender.send(message);
+    }
+
+    private static String[] parseRecipients(String raw) throws AddressException {
+        List<String> recipients = new ArrayList<>();
+        for (String value : raw.split("[,;]")) {
+            String recipient = value.trim();
+            if (!recipient.isEmpty()) {
+                InternetAddress address = new InternetAddress(recipient, true);
+                address.validate();
+                recipients.add(address.getAddress());
+            }
+        }
+        if (recipients.isEmpty()) {
+            throw new AddressException("No valid email recipients");
+        }
+        return recipients.toArray(String[]::new);
+    }
+
+    private Map<String, Object> payload(SystemAlertVO alert, String channel, String content) {
+        if ("dingtalk".equals(channel)) {
+            return Map.of("msgtype", "text", "text", Map.of("content", content));
+        }
+        Map<String, Object> payload = new java.util.LinkedHashMap<>();
+        payload.put("title", alert.getTitle());
+        payload.put("description", content);
+        payload.put("eventDescription", alert.getDescription());
+        payload.put("content", content);
+        payload.put("level", alert.getLevel());
+        payload.put("transition", alert.getTransition());
+        payload.put("instanceId", alert.getInstanceId());
+        payload.put("labels", alert.getLabels() == null ? Map.of() : alert.getLabels());
+        return payload;
+    }
+
+    private static String message(RmqAlertNotificationOutbox row, SystemAlertVO alert) {
+        return StringUtils.hasText(row.getMessageContent()) ? row.getMessageContent()
+                : AlertNotificationTemplate.render(null, alert, null);
+    }
+
+    private void retry(RmqAlertNotificationOutbox row, LocalDateTime now, String claimToken, String error) {
+        int attempts = (row.getAttemptCount() == null ? 0 : row.getAttemptCount()) + 1;
+        boolean exhausted = attempts >= MAX_ATTEMPTS;
+        if (!updateClaimed(row, claimToken, new UpdateWrapper<RmqAlertNotificationOutbox>()
+                .set("attempt_count", attempts).set("status", (exhausted ? NotificationOutboxStatus.FAILED
+                        : NotificationOutboxStatus.RETRY_WAIT).name())
+                .set("next_attempt_at", now.plusSeconds(Math.min(300, 5L << Math.min(attempts - 1, 5))))
+                .set("sending_started_at", null)
+                .set("last_error", abbreviate(error)))) {
+            return;
+        }
+        recordDelivery(row, exhausted ? "FAIL_ALERT_NOTIFICATION" : "RETRY_ALERT_NOTIFICATION",
+                exhausted ? "FAILURE" : "RETRYING", abbreviate(error));
+        log.warn("Alert notification {} for event {}: {}", exhausted ? "failed" : "will retry", row.getAlertId(), error);
+    }
+
+    private void recordDelivery(RmqAlertNotificationOutbox row, String operation, String result, String error) {
+        operationAuditService.record(operation, "ALERT_NOTIFICATION", String.valueOf(row.getId()), null,
+                "alertId=" + row.getAlertId() + ", channel=" + row.getChannel(), result, error);
+    }
+
+    private static String abbreviate(String value) {
+        if (value == null) return "Delivery failed";
+        return value.length() > 1000 ? value.substring(0, 1000) : value;
+    }
+
+    private boolean updateClaimed(RmqAlertNotificationOutbox row, String claimToken,
+            UpdateWrapper<RmqAlertNotificationOutbox> updates) {
+        return mapper.update(null, updates.eq("id", row.getId()).eq("claim_token", claimToken)) == 1;
+    }
+
+    private static LocalDateTime utcNow() {
+        return LocalDateTime.now(ZoneOffset.UTC);
+    }
+
+    private static RestTemplate newClient() {
+        NoRedirectClientHttpRequestFactory factory = new NoRedirectClientHttpRequestFactory();
+        factory.setConnectTimeout(Duration.ofSeconds(3));
+        factory.setReadTimeout(Duration.ofSeconds(5));
+        return new RestTemplate(factory);
+    }
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxStatus.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxStatus.java
new file mode 100644
index 000000000..d90cd40ed
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxStatus.java
@@ -0,0 +1,11 @@
+/*
+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.
+*/
+package org.apache.rocketmq.studio.ops.alert;
+
+public enum NotificationOutboxStatus {
+    PENDING, SENDING, DELIVERED, RETRY_WAIT, FAILED
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertController.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertController.java
index d731153df..3e5db8f73 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertController.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertController.java
@@ -27,7 +27,9 @@ import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.bind.annotation.PathVariable;
 
+import java.time.LocalDateTime;
 import java.util.List;
 import java.util.Map;
 
@@ -37,19 +39,64 @@ import java.util.Map;
 public class SystemAlertController {
 
     private final AlertService alertService;
+    private final NotificationOutboxService notificationOutboxService;
 
     @GetMapping
     public Result<List<SystemAlertVO>> listAlerts(
-            @RequestParam(required = false) String level) {
-        return Result.ok(alertService.listAlerts(level));
+            @RequestParam(required = false) String level,
+            @RequestParam(required = false) AlertDomain domain,
+            @RequestParam(required = false) String instanceId,
+            @RequestParam(required = false) String transition) {
+        return Result.ok(alertService.listAlerts(level, domain, instanceId, transition));
     }
 
     @GetMapping("/page")
     public Result<PageResult<SystemAlertVO>> listAlertsPage(
             @RequestParam(required = false) String level,
+            @RequestParam(required = false) AlertDomain domain,
+            @RequestParam(required = false) String instanceId,
+            @RequestParam(required = false) String transition,
+            @RequestParam(required = false) String labelKey,
+            @RequestParam(required = false) String labelValue,
+            @RequestParam(required = false) LocalDateTime from,
+            @RequestParam(required = false) LocalDateTime to,
+            @RequestParam(required = false) Boolean notificationSuppressed,
             @RequestParam(defaultValue = "1") int page,
             @RequestParam(defaultValue = "20") int pageSize) {
-        return Result.ok(alertService.listAlerts(level, page, pageSize));
+        return Result.ok(alertService.listAlerts(level, domain, instanceId, transition,
+                labelKey, labelValue, from, to, page, pageSize, notificationSuppressed));
+    }
+
+    @GetMapping("/{id}/related")
+    public Result<List<SystemAlertVO>> listRelatedAlerts(@PathVariable Long id) {
+        return Result.ok(alertService.findRelatedAlerts(id));
+    }
+
+    @GetMapping("/{id}/deliveries")
+    public Result<List<NotificationDeliveryVO>> listDeliveries(@PathVariable Long id) {
+        return Result.ok(notificationOutboxService.listDeliveries(id));
+    }
+
+    @GetMapping("/deliveries/page")
+    public Result<PageResult<NotificationDeliveryPageVO>> listDeliveriesPage(
+            @RequestParam(required = false) String channel,
+            @RequestParam(required = false) String status,
+            @RequestParam(required = false) String instanceId,
+            @RequestParam(defaultValue = "1") int page,
+            @RequestParam(defaultValue = "20") int pageSize) {
+        return Result.ok(notificationOutboxService.listDeliveries(channel, status, instanceId, page, pageSize));
+    }
+
+    @PostMapping("/deliveries/{deliveryId}/retry")
+    public Result<Void> retryFailedDelivery(@PathVariable Long deliveryId) {
+        notificationOutboxService.retryFailedDelivery(deliveryId);
+        return Result.ok();
+    }
+
+    @PostMapping("/deliveries/retry")
+    public Result<NotificationDeliveryBulkRetryResult> retryFailedDeliveries(
+            @RequestBody(required = false) List<Long> deliveryIds) {
+        return Result.ok(notificationOutboxService.retryFailedDeliveries(deliveryIds));
     }
 
     @PostMapping("/acknowledge")
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertVO.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertQuery.java
similarity index 58%
copy from server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertVO.java
copy to server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertQuery.java
index db9c06a49..2778bf255 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertVO.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertQuery.java
@@ -16,23 +16,14 @@
  */
 package org.apache.rocketmq.studio.ops.alert;
 
-import org.apache.rocketmq.studio.common.domain.enums.AlertLevel;
-import lombok.AllArgsConstructor;
-import lombok.Builder;
-import lombok.Data;
-import lombok.NoArgsConstructor;
-
 import java.time.LocalDateTime;
 
-@Data
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-public class SystemAlertVO {
-    private Long id;
-    private AlertLevel level;
-    private String title;
-    private String description;
-    private LocalDateTime time;
-    private boolean acknowledged;
+/** Server-side filters for the paged alert-event feed. */
+public record SystemAlertQuery(String level, AlertDomain domain, String instanceId, String transition,
+        String labelKey, String labelValue, LocalDateTime from, LocalDateTime to, int page, int pageSize,
+        Boolean notificationSuppressed) {
+    public SystemAlertQuery(String level, AlertDomain domain, String instanceId, String transition,
+            String labelKey, String labelValue, LocalDateTime from, LocalDateTime to, int page, int pageSize) {
+        this(level, domain, instanceId, transition, labelKey, labelValue, from, to, page, pageSize, null);
+    }
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertVO.java b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertVO.java
index db9c06a49..cc69f0b45 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertVO.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertVO.java
@@ -23,6 +23,7 @@ import lombok.Data;
 import lombok.NoArgsConstructor;
 
 import java.time.LocalDateTime;
+import java.util.Map;
 
 @Data
 @Builder
@@ -35,4 +36,17 @@ public class SystemAlertVO {
     private String description;
     private LocalDateTime time;
     private boolean acknowledged;
+    private String acknowledgedBy;
+    private LocalDateTime acknowledgedAt;
+    private AlertDomain domain;
+    private Long ruleId;
+    private String fingerprint;
+    private String transition;
+    private String instanceId;
+    private Double currentValue;
+    private boolean notificationSuppressed;
+    private Long suppressionCauseAlertId;
+    private String suppressionReason;
+    @Builder.Default
+    private Map<String, String> labels = Map.of();
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/persistence/MybatisPlusSettingsRepository.java b/server/src/main/java/org/apache/rocketmq/studio/persistence/MybatisPlusSettingsRepository.java
index 2996400f0..74b7ff502 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/persistence/MybatisPlusSettingsRepository.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/persistence/MybatisPlusSettingsRepository.java
@@ -103,6 +103,10 @@ public class MybatisPlusSettingsRepository implements SettingsRepository {
                 // drops it; re-add it here or the configured LLM token is lost on restart.
                 node.put("apiKey", settings.getApiKey());
             }
+            if (org.springframework.util.StringUtils.hasText(settings.getDingtalkSigningSecret())) {
+                // The signing secret is also WRITE_ONLY and must be retained in persisted settings.
+                node.put("dingtalkSigningSecret", settings.getDingtalkSigningSecret());
+            }
             String json = objectMapper.writeValueAsString(node);
             RmqSettings entity = findSingletonSettings();
             if (entity == null) {
diff --git a/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqSystemAlert.java b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAlertCollectionLease.java
similarity index 82%
copy from server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqSystemAlert.java
copy to server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAlertCollectionLease.java
index b519814b0..c85888ce9 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqSystemAlert.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAlertCollectionLease.java
@@ -24,23 +24,12 @@ import lombok.Data;
 import java.time.LocalDateTime;
 
 @Data
-@TableName("rmq_system_alert")
-public class RmqSystemAlert {
-
+@TableName("rmq_alert_collection_lease")
+public class RmqAlertCollectionLease {
     @TableId(type = IdType.AUTO)
     private Long id;
-
-    private String level;
-
-    private String title;
-
-    private String description;
-
-    private LocalDateTime time;
-
-    private Boolean acknowledged;
-
-    private LocalDateTime gmtCreate;
-
+    private String leaseName;
+    private String holderId;
+    private LocalDateTime expiresAt;
     private LocalDateTime gmtModified;
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAlertNotificationOutbox.java b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAlertNotificationOutbox.java
new file mode 100644
index 000000000..3484bc62f
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAlertNotificationOutbox.java
@@ -0,0 +1,31 @@
+/*
+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.
+*/
+package org.apache.rocketmq.studio.persistence.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("rmq_alert_notification_outbox")
+public class RmqAlertNotificationOutbox {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long alertId;
+    private String channel;
+    private String status;
+    private Integer attemptCount;
+    private LocalDateTime nextAttemptAt;
+    private LocalDateTime sendingStartedAt;
+    private String claimToken;
+    private String lastError;
+    private String messageContent;
+    private LocalDateTime deliveredAt;
+    private LocalDateTime gmtModified;
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAlertRule.java b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAlertRule.java
index 71aad62c5..004acbbd8 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAlertRule.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAlertRule.java
@@ -30,6 +30,8 @@ public class RmqAlertRule {
     @TableId(type = IdType.AUTO)
     private Long id;
 
+    private String domain;
+
     private String name;
 
     private String metric;
@@ -42,6 +44,10 @@ public class RmqAlertRule {
 
     private String duration;
 
+    private String aggregation;
+
+    private Integer windowSeconds;
+
     private String channels;
 
     private Boolean enabled;
@@ -56,6 +62,18 @@ public class RmqAlertRule {
 
     private String severity;
 
+    private String instanceId;
+
+    private String consumerGroup;
+
+    private String topic;
+
+    private Integer consecutiveSamples;
+    private String reminderInterval;
+    private String notificationTemplate;
+
+    private String semanticFingerprint;
+
     private LocalDateTime gmtCreate;
 
     private LocalDateTime gmtModified;
diff --git a/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqSystemAlert.java b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAlertSilence.java
similarity index 78%
copy from server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqSystemAlert.java
copy to server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAlertSilence.java
index b519814b0..52af4c465 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqSystemAlert.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAlertSilence.java
@@ -24,23 +24,16 @@ import lombok.Data;
 import java.time.LocalDateTime;
 
 @Data
-@TableName("rmq_system_alert")
-public class RmqSystemAlert {
-
+@TableName("rmq_alert_silence")
+public class RmqAlertSilence {
     @TableId(type = IdType.AUTO)
     private Long id;
-
-    private String level;
-
-    private String title;
-
-    private String description;
-
-    private LocalDateTime time;
-
-    private Boolean acknowledged;
-
-    private LocalDateTime gmtCreate;
-
-    private LocalDateTime gmtModified;
+    private String domain;
+    private Long ruleId;
+    private String instanceId;
+    private String labelsJson;
+    private LocalDateTime startsAt;
+    private LocalDateTime endsAt;
+    private String reason;
+    private String createdBy;
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqSystemAlert.java b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAlertState.java
similarity index 74%
copy from server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqSystemAlert.java
copy to server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAlertState.java
index b519814b0..27789c69a 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqSystemAlert.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAlertState.java
@@ -24,23 +24,19 @@ import lombok.Data;
 import java.time.LocalDateTime;
 
 @Data
-@TableName("rmq_system_alert")
-public class RmqSystemAlert {
-
+@TableName("rmq_alert_state")
+public class RmqAlertState {
     @TableId(type = IdType.AUTO)
     private Long id;
-
-    private String level;
-
-    private String title;
-
-    private String description;
-
-    private LocalDateTime time;
-
-    private Boolean acknowledged;
-
-    private LocalDateTime gmtCreate;
-
+    private Long ruleId;
+    private String fingerprint;
+    private String status;
+    private Integer consecutiveHits;
+    private Double currentValue;
+    private LocalDateTime firstPendingAt;
+    private LocalDateTime firedAt;
+    private LocalDateTime lastNotifiedAt;
+    private LocalDateTime resolvedAt;
+    private Integer version;
     private LocalDateTime gmtModified;
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqSystemAlert.java b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqMetricSnapshot.java
similarity index 77%
copy from server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqSystemAlert.java
copy to server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqMetricSnapshot.java
index b519814b0..99acfdace 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqSystemAlert.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqMetricSnapshot.java
@@ -24,23 +24,18 @@ import lombok.Data;
 import java.time.LocalDateTime;
 
 @Data
-@TableName("rmq_system_alert")
-public class RmqSystemAlert {
-
+@TableName("rmq_metric_snapshot")
+public class RmqMetricSnapshot {
     @TableId(type = IdType.AUTO)
     private Long id;
-
-    private String level;
-
-    private String title;
-
-    private String description;
-
-    private LocalDateTime time;
-
-    private Boolean acknowledged;
-
+    private String instanceId;
+    private String metricKey;
+    private String domain;
+    private String clusterId;
+    private String labelsHash;
+    private String labelsJson;
+    private Double value;
+    private String availability;
+    private LocalDateTime collectedAt;
     private LocalDateTime gmtCreate;
-
-    private LocalDateTime gmtModified;
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqSystemAlert.java b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqSystemAlert.java
index b519814b0..42b04a2a6 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqSystemAlert.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqSystemAlert.java
@@ -27,7 +27,7 @@ import java.time.LocalDateTime;
 @TableName("rmq_system_alert")
 public class RmqSystemAlert {
 
-    @TableId(type = IdType.AUTO)
+    @TableId(value = "id", type = IdType.AUTO)
     private Long id;
 
     private String level;
@@ -40,6 +40,30 @@ public class RmqSystemAlert {
 
     private Boolean acknowledged;
 
+    private String acknowledgedBy;
+
+    private LocalDateTime acknowledgedAt;
+
+    private String domain;
+
+    private Long ruleId;
+
+    private String fingerprint;
+
+    private String transition;
+
+    private String instanceId;
+
+    private Double currentValue;
+
+    private Boolean notificationSuppressed;
+
+    private Long suppressionCauseAlertId;
+
+    private String suppressionReason;
+
+    private String labelsJson;
+
     private LocalDateTime gmtCreate;
 
     private LocalDateTime gmtModified;
diff --git a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java b/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertCollectionLeaseMapper.java
similarity index 55%
copy from server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
copy to server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertCollectionLeaseMapper.java
index a3b47d7d5..023074702 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertCollectionLeaseMapper.java
@@ -17,7 +17,16 @@
 package org.apache.rocketmq.studio.persistence.mapper;
 
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import org.apache.rocketmq.studio.persistence.entity.RmqAlertRule;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Update;
+import org.apache.rocketmq.studio.persistence.entity.RmqAlertCollectionLease;
 
-public interface RmqAlertRuleMapper extends BaseMapper<RmqAlertRule> {
+import java.time.LocalDateTime;
+
+public interface RmqAlertCollectionLeaseMapper extends BaseMapper<RmqAlertCollectionLease> {
+    @Update("UPDATE rmq_alert_collection_lease SET holder_id = #{holderId}, expires_at = #{expiresAt}, "
+            + "gmt_modified = #{now} WHERE lease_name = #{leaseName} "
+            + "AND (expires_at <= #{now} OR holder_id = #{holderId})")
+    int acquire(@Param("leaseName") String leaseName, @Param("holderId") String holderId,
+            @Param("now") LocalDateTime now, @Param("expiresAt") LocalDateTime expiresAt);
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertNotificationOutboxMapper.java b/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertNotificationOutboxMapper.java
new file mode 100644
index 000000000..5ade98502
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertNotificationOutboxMapper.java
@@ -0,0 +1,67 @@
+/*
+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.
+*/
+package org.apache.rocketmq.studio.persistence.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Delete;
+import org.apache.ibatis.annotations.Update;
+import org.apache.rocketmq.studio.persistence.entity.RmqAlertNotificationOutbox;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import org.apache.rocketmq.studio.ops.alert.NotificationDeliveryPageVO;
+
+public interface RmqAlertNotificationOutboxMapper extends BaseMapper<RmqAlertNotificationOutbox> {
+    @Delete("DELETE FROM rmq_alert_notification_outbox WHERE alert_id IN "
+            + "(SELECT id FROM rmq_system_alert WHERE acknowledged = 1)")
+    int deleteForAcknowledgedAlerts();
+
+    @Select("SELECT * FROM rmq_alert_notification_outbox WHERE "
+            + "(status IN ('PENDING', 'RETRY_WAIT') AND next_attempt_at <= #{now}) "
+            + "OR (status = 'SENDING' AND (sending_started_at IS NULL OR sending_started_at <= #{staleBefore})) "
+            + "ORDER BY id LIMIT #{limit}")
+    List<RmqAlertNotificationOutbox> findDispatchable(@Param("now") LocalDateTime now,
+            @Param("staleBefore") LocalDateTime staleBefore, @Param("limit") int limit);
+
+    @Update("UPDATE rmq_alert_notification_outbox SET status = 'SENDING', sending_started_at = #{claimedAt}, "
+            + "claim_token = #{claimToken} "
+            + "WHERE id = #{id} AND ((status IN ('PENDING', 'RETRY_WAIT') AND next_attempt_at <= #{now}) "
+            + "OR (status = 'SENDING' AND (sending_started_at IS NULL OR sending_started_at <= #{staleBefore})))")
+    int claimForDispatch(@Param("id") Long id, @Param("now") LocalDateTime now,
+            @Param("staleBefore") LocalDateTime staleBefore, @Param("claimedAt") LocalDateTime claimedAt,
+            @Param("claimToken") String claimToken);
+
+    @Select("<script>"
+            + "SELECT o.id, o.alert_id AS alertId, o.channel, o.status, o.attempt_count AS attemptCount, "
+            + "o.next_attempt_at AS nextAttemptAt, o.last_error AS lastError, o.delivered_at AS deliveredAt, "
+            + "o.gmt_create AS createdAt, o.message_content AS messageContent, a.title AS alertTitle, a.domain AS alertDomain, "
+            + "a.transition, a.instance_id AS instanceId "
+            + "FROM rmq_alert_notification_outbox o "
+            + "JOIN rmq_system_alert a ON a.id = o.alert_id "
+            + "<where>"
+            + "<if test='channel != null and channel != \"\"'> AND o.channel = #{channel}</if>"
+            + "<if test='status != null and status != \"\"'> AND o.status = #{status}</if>"
+            + "<if test='instanceId != null and instanceId != \"\"'> AND a.instance_id = #{instanceId}</if>"
+            + "</where> ORDER BY o.id DESC LIMIT #{limit} OFFSET #{offset}"
+            + "</script>")
+    List<NotificationDeliveryPageVO> findPage(@Param("channel") String channel, @Param("status") String status,
+            @Param("instanceId") String instanceId, @Param("limit") int limit, @Param("offset") long offset);
+
+    @Select("<script>"
+            + "SELECT COUNT(*) FROM rmq_alert_notification_outbox o "
+            + "JOIN rmq_system_alert a ON a.id = o.alert_id "
+            + "<where>"
+            + "<if test='channel != null and channel != \"\"'> AND o.channel = #{channel}</if>"
+            + "<if test='status != null and status != \"\"'> AND o.status = #{status}</if>"
+            + "<if test='instanceId != null and instanceId != \"\"'> AND a.instance_id = #{instanceId}</if>"
+            + "</where>"
+            + "</script>")
+    long countPage(@Param("channel") String channel, @Param("status") String status,
+            @Param("instanceId") String instanceId);
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java b/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
index a3b47d7d5..5f62b8c3b 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
@@ -17,7 +17,12 @@
 package org.apache.rocketmq.studio.persistence.mapper;
 
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Update;
 import org.apache.rocketmq.studio.persistence.entity.RmqAlertRule;
 
 public interface RmqAlertRuleMapper extends BaseMapper<RmqAlertRule> {
+    @Update("UPDATE rmq_alert_rule SET last_triggered = #{triggeredAt}, gmt_modified = CURRENT_TIMESTAMP "
+            + "WHERE id = #{id}")
+    int updateLastTriggered(@Param("id") Long id, @Param("triggeredAt") String triggeredAt);
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java b/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertSilenceMapper.java
similarity index 86%
copy from server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
copy to server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertSilenceMapper.java
index a3b47d7d5..9d763a49f 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertSilenceMapper.java
@@ -17,7 +17,7 @@
 package org.apache.rocketmq.studio.persistence.mapper;
 
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import org.apache.rocketmq.studio.persistence.entity.RmqAlertRule;
+import org.apache.rocketmq.studio.persistence.entity.RmqAlertSilence;
 
-public interface RmqAlertRuleMapper extends BaseMapper<RmqAlertRule> {
+public interface RmqAlertSilenceMapper extends BaseMapper<RmqAlertSilence> {
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertStateMapper.java b/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertStateMapper.java
new file mode 100644
index 000000000..40290e7cf
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertStateMapper.java
@@ -0,0 +1,40 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.persistence.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Update;
+import org.apache.rocketmq.studio.persistence.entity.RmqAlertState;
+
+import java.time.LocalDateTime;
+
+public interface RmqAlertStateMapper extends BaseMapper<RmqAlertState> {
+    @Update("UPDATE rmq_alert_state SET status = #{state.status}, consecutive_hits = #{state.consecutiveHits}, "
+            + "current_value = #{state.currentValue}, first_pending_at = #{state.firstPendingAt}, "
+            + "fired_at = #{state.firedAt}, last_notified_at = #{state.lastNotifiedAt}, resolved_at = #{state.resolvedAt}, "
+            + "gmt_modified = #{state.gmtModified}, version = version + 1 "
+            + "WHERE id = #{state.id} AND version = #{expectedVersion}")
+    int updateIfVersion(@Param("state") RmqAlertState state, @Param("expectedVersion") int expectedVersion);
+
+    @Update("UPDATE rmq_alert_state SET status = 'ACKED', gmt_modified = #{now}, version = version + 1 "
+            + "WHERE rule_id = #{ruleId} AND fingerprint = #{fingerprint} AND status = 'FIRING' "
+            + "AND fired_at = #{firedAt}")
+    int acknowledgeFiring(@Param("ruleId") Long ruleId, @Param("fingerprint") String fingerprint,
+            @Param("firedAt") LocalDateTime firedAt,
+            @Param("now") LocalDateTime now);
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java b/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqMetricSnapshotMapper.java
similarity index 85%
copy from server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
copy to server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqMetricSnapshotMapper.java
index a3b47d7d5..e8b01a1e5 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertRuleMapper.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqMetricSnapshotMapper.java
@@ -17,7 +17,7 @@
 package org.apache.rocketmq.studio.persistence.mapper;
 
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import org.apache.rocketmq.studio.persistence.entity.RmqAlertRule;
+import org.apache.rocketmq.studio.persistence.entity.RmqMetricSnapshot;
 
-public interface RmqAlertRuleMapper extends BaseMapper<RmqAlertRule> {
+public interface RmqMetricSnapshotMapper extends BaseMapper<RmqMetricSnapshot> {
 }
diff --git a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImpl.java b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImpl.java
index 6448be000..db403c947 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImpl.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImpl.java
@@ -154,7 +154,11 @@ public class RocketMQAdminClientImpl implements AdminClient {
     private void fillConsumeStats(MQAdminExt admin, ConsumerGroupVO vo, String name) {
         try {
             ConsumeStats stats = admin.examineConsumeStats(name);
-            if (stats == null || stats.getOffsetTable() == null || stats.getOffsetTable().isEmpty()) {
+            if (stats == null) {
+                return;
+            }
+            vo.setConsumeStatsAvailable(true);
+            if (stats.getOffsetTable() == null || stats.getOffsetTable().isEmpty()) {
                 return;
             }
             long totalLag = 0;
@@ -173,6 +177,7 @@ public class RocketMQAdminClientImpl implements AdminClient {
             if (newestConsumedTimestamp > 0) {
                 long delaySeconds = (System.currentTimeMillis() - newestConsumedTimestamp) / 1000;
                 vo.setDelaySeconds((int) Math.max(delaySeconds, 0));
+                vo.setConsumptionTimestampAvailable(true);
             }
         } catch (Exception e) {
             log.debug("No consume stats for group {}: {}", name, e.getMessage());
diff --git a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProvider.java b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProvider.java
index fedc63108..d3ec8ce94 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProvider.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProvider.java
@@ -320,7 +320,11 @@ public class RocketMQMetadataProvider implements MetadataProvider {
             } else {
                 stats = adminExecute(admin -> admin.examineConsumeStats(vo.getName()));
             }
-            if (stats == null || stats.getOffsetTable() == null || stats.getOffsetTable().isEmpty()) {
+            if (stats == null) {
+                return;
+            }
+            vo.setConsumeStatsAvailable(true);
+            if (stats.getOffsetTable() == null || stats.getOffsetTable().isEmpty()) {
                 return;
             }
             long totalLag = 0;
@@ -339,6 +343,7 @@ public class RocketMQMetadataProvider implements MetadataProvider {
             if (newestConsumedTimestamp > 0) {
                 long delaySeconds = (System.currentTimeMillis() - newestConsumedTimestamp) / 1000;
                 vo.setDelaySeconds((int) Math.max(delaySeconds, 0));
+                vo.setConsumptionTimestampAvailable(true);
             }
         } catch (Exception e) {
             // No consume stats (e.g. POP-only group without an offset table): keep zeros.
diff --git a/server/src/main/java/org/apache/rocketmq/studio/settings/GeneralSettingsUpdateDTO.java b/server/src/main/java/org/apache/rocketmq/studio/settings/GeneralSettingsUpdateDTO.java
index d027fa127..a467fccb9 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/settings/GeneralSettingsUpdateDTO.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/settings/GeneralSettingsUpdateDTO.java
@@ -59,6 +59,9 @@ public class GeneralSettingsUpdateDTO {
     private String apiVersion;
     private String awsRegion;
     private String dingtalkWebhook;
+    @ToString.Exclude
+    private String dingtalkSigningSecret;
+    private boolean clearDingtalkSigningSecret;
     private String emailRecipients;
     private String smsWebhook;
 
@@ -80,6 +83,8 @@ public class GeneralSettingsUpdateDTO {
                 .apiVersion(apiVersion)
                 .awsRegion(awsRegion)
                 .dingtalkWebhook(dingtalkWebhook)
+                .dingtalkSigningSecret(dingtalkSigningSecret)
+                .clearDingtalkSigningSecret(clearDingtalkSigningSecret)
                 .emailRecipients(emailRecipients)
                 .smsWebhook(smsWebhook)
                 .build();
diff --git a/server/src/main/java/org/apache/rocketmq/studio/settings/GeneralSettingsVO.java b/server/src/main/java/org/apache/rocketmq/studio/settings/GeneralSettingsVO.java
index 37ac02dcc..e40230738 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/settings/GeneralSettingsVO.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/settings/GeneralSettingsVO.java
@@ -50,6 +50,12 @@ public class GeneralSettingsVO {
     private Integer maxTokens;
     private Double temperature;
     private String dingtalkWebhook;
+    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
+    @ToString.Exclude
+    private String dingtalkSigningSecret;
+    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
+    @ToString.Exclude
+    private boolean clearDingtalkSigningSecret;
     private String emailRecipients;
     private String smsWebhook;
 
@@ -63,6 +69,11 @@ public class GeneralSettingsVO {
         return StringUtils.hasText(dingtalkWebhook);
     }
 
+    @JsonProperty(value = "dingtalkSigningSecretConfigured", access = JsonProperty.Access.READ_ONLY)
+    public boolean isDingtalkSigningSecretConfigured() {
+        return StringUtils.hasText(dingtalkSigningSecret);
+    }
+
     @JsonProperty(value = "smsWebhookConfigured", access = JsonProperty.Access.READ_ONLY)
     public boolean isSmsWebhookConfigured() {
         return StringUtils.hasText(smsWebhook);
diff --git a/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsController.java b/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsController.java
index 4874ccf77..d80d588aa 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsController.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsController.java
@@ -28,6 +28,7 @@ import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
 import java.util.List;
+import org.apache.rocketmq.studio.ops.alert.NotificationOutboxService;
 
 @RestController
 @RequestMapping("/api/settings")
@@ -35,6 +36,7 @@ import java.util.List;
 public class SettingsController {
 
     private final SettingsService settingsService;
+    private final NotificationOutboxService notificationOutboxService;
 
     @GetMapping("/general")
     public Result<GeneralSettingsVO> getGeneralSettings() {
@@ -47,6 +49,12 @@ public class SettingsController {
         return Result.ok();
     }
 
+    @PostMapping("/general/test-notification")
+    public Result<Void> testNotification(@RequestParam String channel) {
+        notificationOutboxService.sendTestMessage(channel);
+        return Result.ok();
+    }
+
     @GetMapping("/datasources")
     public Result<List<DataSourceVO>> listDataSources() {
         return Result.ok(settingsService.listDataSources());
diff --git a/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsService.java b/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsService.java
index 8772c97b7..807408dfa 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsService.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsService.java
@@ -132,6 +132,11 @@ public class SettingsService {
         } else if (!StringUtils.hasText(settings.getApiKey()) && currentSettings != null) {
             settings.setApiKey(currentSettings.getApiKey());
         }
+        if (settings.isClearDingtalkSigningSecret()) {
+            settings.setDingtalkSigningSecret("");
+        } else if (!StringUtils.hasText(settings.getDingtalkSigningSecret()) && currentSettings != null) {
+            settings.setDingtalkSigningSecret(currentSettings.getDingtalkSigningSecret());
+        }
         if (currentSettings != null) {
             if (!StringUtils.hasText(settings.getDeploymentName())) {
                 settings.setDeploymentName(currentSettings.getDeploymentName());
@@ -144,6 +149,7 @@ public class SettingsService {
             }
         }
         settings.setClearApiKey(false);
+        settings.setClearDingtalkSigningSecret(false);
         settingsRepository.saveGeneralSettings(settings);
         recordSettingsAudit();
     }
diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml
index db9260acf..84d3e729c 100644
--- a/server/src/main/resources/application.yml
+++ b/server/src/main/resources/application.yml
@@ -11,6 +11,14 @@ spring:
     username: ${SPRING_DATASOURCE_USERNAME:root}
     password: ${SPRING_DATASOURCE_PASSWORD:studio123}
     driver-class-name: com.mysql.cj.jdbc.Driver
+  mail:
+    host: ${STUDIO_ALERTING_SMTP_HOST:}
+    port: ${STUDIO_ALERTING_SMTP_PORT:587}
+    username: ${STUDIO_ALERTING_SMTP_USERNAME:}
+    password: ${STUDIO_ALERTING_SMTP_PASSWORD:}
+    properties:
+      mail.smtp.auth: ${STUDIO_ALERTING_SMTP_AUTH:true}
+      mail.smtp.starttls.enable: ${STUDIO_ALERTING_SMTP_STARTTLS:true}
 
 mybatis-plus:
   configuration:
@@ -68,6 +76,13 @@ studio:
   query-history:
     retention-days: ${STUDIO_QUERY_HISTORY_RETENTION_DAYS:90}
     cleanup-interval: ${STUDIO_QUERY_HISTORY_CLEANUP_INTERVAL:PT24H}
+  alerting:
+    collection-interval: ${STUDIO_ALERTING_COLLECTION_INTERVAL:PT30S}
+    collection-parallelism: ${STUDIO_ALERTING_COLLECTION_PARALLELISM:4}
+    collection-timeout: ${STUDIO_ALERTING_COLLECTION_TIMEOUT:PT15S}
+    snapshot-retention: ${STUDIO_ALERTING_SNAPSHOT_RETENTION:PT24H}
+    snapshot-cleanup-interval: ${STUDIO_ALERTING_SNAPSHOT_CLEANUP_INTERVAL:PT1H}
+    notification-dispatch-interval: ${STUDIO_ALERTING_NOTIFICATION_DISPATCH_INTERVAL:PT10S}
   llm:
     token: ${RMQ_LLM_TOKEN:}
     anthropic-base-url: ${RMQ_ANTHROPIC_BASE_URL:}
diff --git a/server/src/main/resources/db/schema.sql b/server/src/main/resources/db/schema.sql
index 29a21f063..fc058cd61 100644
--- a/server/src/main/resources/db/schema.sql
+++ b/server/src/main/resources/db/schema.sql
@@ -251,6 +251,8 @@ CREATE TABLE IF NOT EXISTS rmq_alert_rule (
   threshold DOUBLE,
   threshold_unit VARCHAR(32),
   duration VARCHAR(32),
+  aggregation VARCHAR(16) NOT NULL DEFAULT 'LAST',
+  window_seconds INT NOT NULL DEFAULT 0,
   channels VARCHAR(512) COMMENT '逗号分隔的通知渠道',
   enabled TINYINT(1) DEFAULT 1,
   last_triggered VARCHAR(64),
@@ -258,10 +260,103 @@ CREATE TABLE IF NOT EXISTS rmq_alert_rule (
   broker_name VARCHAR(128),
   cluster_name VARCHAR(128),
   severity VARCHAR(32),
+  domain VARCHAR(16) NOT NULL DEFAULT 'BUSINESS' COMMENT 'BUSINESS or CLUSTER alert rule domain',
+  instance_id VARCHAR(128) COMMENT 'Studio instance scope required for native rule evaluation',
+  consumer_group VARCHAR(255) COMMENT 'Optional consumer group selector for business metrics',
+  topic VARCHAR(255) COMMENT 'Optional topic selector for topic backlog metrics',
+  consecutive_samples INT NOT NULL DEFAULT 1 COMMENT 'Consecutive native samples required before firing',
+  reminder_interval VARCHAR(32) NOT NULL DEFAULT '30m' COMMENT 'Repeat notification interval while unacknowledged',
+  notification_template TEXT COMMENT 'Optional notification body template',
+  semantic_fingerprint CHAR(64) NOT NULL COMMENT 'SHA-256 identity of the rule evaluation conditions',
+  UNIQUE KEY uk_alert_rule_semantic_fingerprint (semantic_fingerprint),
   PRIMARY KEY (`id`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
--- 14. 系统告警事件
+-- 14. Native alert metric snapshots (short retention, managed by CollectorScheduler)
+CREATE TABLE IF NOT EXISTS rmq_metric_snapshot (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+  `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  `instance_id` VARCHAR(128) NOT NULL,
+  `metric_key` VARCHAR(128) NOT NULL,
+  `domain` VARCHAR(16) NOT NULL,
+  `cluster_id` VARCHAR(128),
+  `labels_hash` CHAR(64) NOT NULL,
+  `labels_json` TEXT NOT NULL,
+  `value` DOUBLE NULL,
+  `availability` VARCHAR(16) NOT NULL,
+  `collected_at` DATETIME NOT NULL,
+  PRIMARY KEY (`id`),
+  INDEX idx_metric_snapshot_lookup (`instance_id`, `metric_key`, `collected_at`),
+  INDEX idx_metric_snapshot_retention (`collected_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS rmq_alert_collection_lease (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+  `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  `lease_name` VARCHAR(128) NOT NULL,
+  `holder_id` VARCHAR(64) NOT NULL,
+  `expires_at` DATETIME NOT NULL,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY uk_alert_collection_lease_name (`lease_name`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS rmq_alert_state (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+  `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  `rule_id` bigint(20) unsigned NOT NULL,
+  `fingerprint` CHAR(64) NOT NULL,
+  `status` VARCHAR(16) NOT NULL,
+  `consecutive_hits` INT NOT NULL DEFAULT 0,
+  `current_value` DOUBLE NULL,
+  `first_pending_at` DATETIME NULL,
+  `fired_at` DATETIME NULL,
+  `last_notified_at` DATETIME NULL,
+  `resolved_at` DATETIME NULL,
+  `version` INT NOT NULL DEFAULT 0,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY uk_alert_state_rule_fingerprint (`rule_id`, `fingerprint`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS rmq_alert_silence (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+  `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  `domain` VARCHAR(16) NULL,
+  `rule_id` bigint(20) unsigned NULL,
+  `instance_id` VARCHAR(128) NULL,
+  `labels_json` TEXT NULL,
+  `starts_at` DATETIME NOT NULL,
+  `ends_at` DATETIME NOT NULL,
+  `reason` VARCHAR(512) NULL,
+  `created_by` VARCHAR(128) NOT NULL,
+  PRIMARY KEY (`id`),
+  INDEX idx_alert_silence_active (`starts_at`, `ends_at`),
+  INDEX idx_alert_silence_scope (`domain`, `rule_id`, `instance_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS rmq_alert_notification_outbox (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+  `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  `alert_id` bigint(20) unsigned NOT NULL,
+  `channel` VARCHAR(32) NOT NULL,
+  `status` VARCHAR(16) NOT NULL,
+  `attempt_count` INT NOT NULL DEFAULT 0,
+  `next_attempt_at` DATETIME NOT NULL,
+  `sending_started_at` DATETIME NULL,
+  `claim_token` VARCHAR(64) NULL,
+  `last_error` VARCHAR(1000) NULL,
+  `message_content` TEXT NULL COMMENT 'Rendered notification body snapshot',
+  `delivered_at` DATETIME NULL,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY uk_alert_notification_outbox (`alert_id`, `channel`),
+  INDEX idx_alert_notification_ready (`status`, `next_attempt_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- 15. 系统告警事件
 CREATE TABLE IF NOT EXISTS rmq_system_alert (
   `id`           bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
   `gmt_create`   datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
@@ -271,9 +366,23 @@ CREATE TABLE IF NOT EXISTS rmq_system_alert (
   description TEXT,
   time DATETIME,
   acknowledged TINYINT(1) DEFAULT 0,
+  acknowledged_by VARCHAR(128),
+  acknowledged_at DATETIME,
+  domain VARCHAR(16),
+  rule_id bigint(20) unsigned,
+  fingerprint CHAR(64),
+  transition VARCHAR(16),
+  instance_id VARCHAR(128),
+  current_value DOUBLE,
+  notification_suppressed TINYINT(1) NOT NULL DEFAULT 0,
+  suppression_cause_alert_id bigint(20) unsigned,
+  suppression_reason VARCHAR(512),
+  labels_json TEXT,
   PRIMARY KEY (`id`),
   INDEX idx_level (level),
-  INDEX idx_acknowledged (acknowledged)
+  INDEX idx_acknowledged (acknowledged),
+  INDEX idx_system_alert_domain_time (domain, time),
+  INDEX idx_system_alert_feed (domain, instance_id, transition, time)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
 -- 15. Cloud provider credentials (secret_key is base64-encoded and never seeded).
@@ -298,3 +407,6 @@ CREATE TABLE IF NOT EXISTS rmq_cloud_credential (
 ALTER TABLE rmq_instance_message MODIFY queried_by VARCHAR(128);
 ALTER TABLE rmq_instance_trace MODIFY queried_by VARCHAR(128);
 ALTER TABLE rmq_operation_audit MODIFY operator VARCHAR(128);
+
+-- Existing deployments are upgraded by AlertSchemaMigration after the application
+-- connects, because this schema is also parsed by H2 in the development profile.
diff --git a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/CollectorSchedulerTest.java b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/CollectorSchedulerTest.java
new file mode 100644
index 000000000..be7283447
--- /dev/null
+++ b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/CollectorSchedulerTest.java
@@ -0,0 +1,154 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.cluster.metrics;
+
+import org.apache.rocketmq.studio.instance.InstanceRepository;
+import org.apache.rocketmq.studio.instance.InstanceVO;
+import org.apache.rocketmq.studio.ops.alert.AlertDomain;
+import org.apache.rocketmq.studio.ops.alert.NativeAlertProcessor;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class CollectorSchedulerTest {
+
+    @Test
+    void persistsFastInstanceWhileAnotherInstanceTimesOutTest() throws Exception {
+        AlertingProperties properties = new AlertingProperties();
+        properties.setCollectionParallelism(2);
+        properties.setCollectionTimeout("PT0.1S");
+        InstanceRepository instances = mock(InstanceRepository.class);
+        InstanceVO slow = InstanceVO.builder().name("slow").endpoint("slow:9876").build();
+        InstanceVO fast = InstanceVO.builder().name("fast").endpoint("fast:9876").build();
+        when(instances.findAll()).thenReturn(List.of(slow, fast));
+
+        ClusterMetricsCollector collector = mock(ClusterMetricsCollector.class);
+        when(collector.supports(slow)).thenReturn(true);
+        when(collector.supports(fast)).thenReturn(true);
+        when(collector.collect(slow)).thenAnswer(invocation -> {
+            try {
+                Thread.sleep(TimeUnit.SECONDS.toMillis(5));
+            } catch (InterruptedException ignored) {
+                Thread.currentThread().interrupt();
+            }
+            return List.of();
+        });
+        MetricSample fastSample = new MetricSample("nameserver.availability", AlertDomain.CLUSTER, "fast", null,
+                null, 1D, MetricAvailability.AVAILABLE, Instant.now());
+        when(collector.collect(fast)).thenReturn(List.of(fastSample));
+
+        CountDownLatch persisted = new CountDownLatch(1);
+        MetricSnapshotRepository snapshots = mock(MetricSnapshotRepository.class);
+        org.mockito.Mockito.doAnswer(invocation -> {
+            persisted.countDown();
+            return null;
+        }).when(snapshots).saveAll(List.of(fastSample));
+        AlertCollectionLease lease = mock(AlertCollectionLease.class);
+        when(lease.tryAcquire()).thenReturn(true);
+        ExecutorService executor = Executors.newFixedThreadPool(2);
+        CollectorScheduler scheduler = new CollectorScheduler(properties, instances, List.of(collector), List.of(), snapshots,
+                mock(NativeAlertProcessor.class), lease, executor);
+
+        Thread pass = new Thread(scheduler::collect);
+        pass.start();
+        assertTrue(persisted.await(1, TimeUnit.SECONDS), "fast instance should persist before the slow timeout expires");
+        pass.join(TimeUnit.SECONDS.toMillis(2));
+        assertTrue(!pass.isAlive(), "collection pass should finish after cancelling the slow instance");
+        scheduler.stopCollectionExecutor();
+    }
+
+    @Test
+    void collectsAndPersistsSupportedSamplesTest() {
+        AlertingProperties properties = new AlertingProperties();
+        InstanceRepository instances = mock(InstanceRepository.class);
+        ClusterMetricsCollector collector = mock(ClusterMetricsCollector.class);
+        MetricSnapshotRepository snapshots = mock(MetricSnapshotRepository.class);
+        InstanceVO instance = InstanceVO.builder().name("local").endpoint("localhost:9876").build();
+        MetricSample sample = new MetricSample("nameserver.availability", AlertDomain.CLUSTER, "local", null,
+                null, 1D, MetricAvailability.AVAILABLE, Instant.now());
+        when(instances.findAll()).thenReturn(List.of(instance));
+        when(collector.supports(instance)).thenReturn(true);
+        when(collector.collect(instance)).thenReturn(List.of(sample));
+
+        NativeAlertProcessor processor = mock(NativeAlertProcessor.class);
+        AlertCollectionLease lease = mock(AlertCollectionLease.class);
+        when(lease.tryAcquire()).thenReturn(true);
+        new CollectorScheduler(properties, instances, List.of(collector), List.of(), snapshots, processor, lease).collect();
+
+        verify(snapshots).saveAll(List.of(sample));
+        verify(processor).process(List.of(sample));
+    }
+
+    @Test
+    void removesExpiredSnapshotsUsingConfiguredRetentionTest() {
+        AlertingProperties properties = new AlertingProperties();
+        properties.setSnapshotRetention("PT2H");
+        MetricSnapshotRepository snapshots = mock(MetricSnapshotRepository.class);
+
+        new CollectorScheduler(properties, mock(InstanceRepository.class), List.of(), List.of(), snapshots,
+                mock(NativeAlertProcessor.class), mock(AlertCollectionLease.class)).cleanUpSnapshots();
+
+        verify(snapshots).deleteBefore(any(Instant.class));
+    }
+
+    @Test
+    void doesNotCollectWhenAnotherReplicaHoldsTheLeaseTest() {
+        AlertingProperties properties = new AlertingProperties();
+        InstanceRepository instances = mock(InstanceRepository.class);
+        AlertCollectionLease lease = mock(AlertCollectionLease.class);
+        when(lease.tryAcquire()).thenReturn(false);
+
+        new CollectorScheduler(properties, instances, List.of(), List.of(), mock(MetricSnapshotRepository.class),
+                mock(NativeAlertProcessor.class), lease).collect();
+
+        verify(instances, never()).findAll();
+    }
+
+    @Test
+    void discardsCollectedSamplesWhenTheLeaseExpiresBeforePersistenceTest() {
+        AlertingProperties properties = new AlertingProperties();
+        InstanceRepository instances = mock(InstanceRepository.class);
+        ClusterMetricsCollector collector = mock(ClusterMetricsCollector.class);
+        MetricSnapshotRepository snapshots = mock(MetricSnapshotRepository.class);
+        NativeAlertProcessor processor = mock(NativeAlertProcessor.class);
+        AlertCollectionLease lease = mock(AlertCollectionLease.class);
+        InstanceVO instance = InstanceVO.builder().name("local").endpoint("localhost:9876").build();
+        MetricSample sample = new MetricSample("nameserver.availability", AlertDomain.CLUSTER, "local", null,
+                null, 1D, MetricAvailability.AVAILABLE, Instant.now());
+        when(instances.findAll()).thenReturn(List.of(instance));
+        when(collector.supports(instance)).thenReturn(true);
+        when(collector.collect(instance)).thenReturn(List.of(sample));
+        when(lease.tryAcquire()).thenReturn(true, false);
+
+        new CollectorScheduler(properties, instances, List.of(collector), List.of(), snapshots, processor, lease).collect();
+
+        verify(snapshots, never()).saveAll(any());
+        verify(processor, never()).process(any());
+    }
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertVO.java b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/CollectorStatusControllerTest.java
similarity index 58%
copy from server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertVO.java
copy to server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/CollectorStatusControllerTest.java
index db9c06a49..d56ee6de7 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertVO.java
+++ b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/CollectorStatusControllerTest.java
@@ -14,25 +14,22 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.rocketmq.studio.ops.alert;
+package org.apache.rocketmq.studio.cluster.metrics;
 
-import org.apache.rocketmq.studio.common.domain.enums.AlertLevel;
-import lombok.AllArgsConstructor;
-import lombok.Builder;
-import lombok.Data;
-import lombok.NoArgsConstructor;
+import org.junit.jupiter.api.Test;
 
-import java.time.LocalDateTime;
+import java.util.List;
 
-@Data
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-public class SystemAlertVO {
-    private Long id;
-    private AlertLevel level;
-    private String title;
-    private String description;
-    private LocalDateTime time;
-    private boolean acknowledged;
+import static org.assertj.core.api.Assertions.assertThat;
+
+class CollectorStatusControllerTest {
+    @Test
+    void reportsNativeCollectionMetadataTest() {
+        AlertingProperties properties = new AlertingProperties();
+        properties.setCollectionInterval("PT1M");
+
+        CollectorStatusVO status = new CollectorStatusController(properties, List.of(), List.of()).status().getData();
+
+        assertThat(status.collectionInterval()).isEqualTo("PT1M");
+    }
 }
diff --git a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricSampleTest.java b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricSampleTest.java
new file mode 100644
index 000000000..9f87682ad
--- /dev/null
+++ b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricSampleTest.java
@@ -0,0 +1,43 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.cluster.metrics;
+
+import org.apache.rocketmq.studio.ops.alert.AlertDomain;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class MetricSampleTest {
+
+    @Test
+    void unavailableSamplesCannotCarryAValueTest() {
+        assertThatThrownBy(() -> new MetricSample("broker.availability", AlertDomain.CLUSTER, "local", null,
+                null, 0D, MetricAvailability.UNAVAILABLE, Instant.now()))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessage("Only available metric samples may have a value");
+    }
+
+    @Test
+    void availableSamplesRequireAValueTest() {
+        assertThatThrownBy(() -> new MetricSample("broker.availability", AlertDomain.CLUSTER, "local", null,
+                null, null, MetricAvailability.AVAILABLE, Instant.now()))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessage("Available metric samples require a value");
+    }
+}
diff --git a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqBusinessMetricsCollectorTest.java b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqBusinessMetricsCollectorTest.java
new file mode 100644
index 000000000..ffb5d5cba
--- /dev/null
+++ b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqBusinessMetricsCollectorTest.java
@@ -0,0 +1,145 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.cluster.metrics.collectors;
+
+import org.apache.rocketmq.studio.cluster.metrics.MetricAvailability;
+import org.apache.rocketmq.studio.cluster.metrics.MetricSample;
+import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
+import org.apache.rocketmq.studio.instance.InstanceVO;
+import org.apache.rocketmq.studio.instance.group.ConsumerGroupVO;
+import org.apache.rocketmq.studio.instance.group.QueueProgressVO;
+import org.apache.rocketmq.studio.provider.InstanceProvider;
+import org.apache.rocketmq.studio.provider.InstanceProviderRegistry;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class ApacheRocketMqBusinessMetricsCollectorTest {
+
+    @Test
+    void collectsOneLagSampleForEachConsumerGroupTest() {
+        InstanceProviderRegistry registry = mock(InstanceProviderRegistry.class);
+        InstanceProvider provider = mock(InstanceProvider.class);
+        ConsumerGroupVO orders = group("orders", "cluster-a", 42);
+        ConsumerGroupVO payments = group("payments", "cluster-a", 0);
+        when(registry.byInstanceId("local")).thenReturn(Optional.of(provider));
+        when(provider.listConsumerGroups("local", null)).thenReturn(List.of(orders, payments));
+        when(provider.getGroupProgress("local", "orders")).thenReturn(List.of(QueueProgressVO.builder()
+                .topic("orders-topic").diffTotal(17).build(), QueueProgressVO.builder()
+                .topic("orders-topic").diffTotal(42).build()));
+        when(provider.getGroupProgress("local", "payments")).thenReturn(List.of(QueueProgressVO.builder()
+                .topic("payments-topic").diffTotal(0).build()));
+
+        List<MetricSample> samples = new ApacheRocketMqBusinessMetricsCollector(registry).collect(apacheInstance());
+
+        assertThat(samples).hasSize(8).allSatisfy(sample -> {
+            assertThat(sample.availability()).isEqualTo(MetricAvailability.AVAILABLE);
+        });
+        assertThat(samples).filteredOn(sample -> sample.metricKey().equals(
+                ApacheRocketMqBusinessMetricsCollector.CONSUMER_LAG_TOTAL)
+                && "orders".equals(sample.labels().get("consumerGroup")))
+                .singleElement().satisfies(sample -> assertThat(sample.value()).isEqualTo(42D));
+        assertThat(samples).filteredOn(sample -> sample.metricKey().equals(
+                ApacheRocketMqBusinessMetricsCollector.CONSUMER_LAG_MAX_QUEUE)
+                && "orders".equals(sample.labels().get("consumerGroup"))).singleElement()
+                .satisfies(sample -> assertThat(sample.value()).isEqualTo(42D));
+        assertThat(samples).filteredOn(sample -> sample.metricKey().equals(
+                ApacheRocketMqBusinessMetricsCollector.CONSUMER_DELAY_SECONDS)
+                && "orders".equals(sample.labels().get("consumerGroup"))).singleElement()
+                .satisfies(sample -> assertThat(sample.value()).isEqualTo(30D));
+        assertThat(samples).filteredOn(sample -> sample.metricKey().equals(
+                ApacheRocketMqBusinessMetricsCollector.TOPIC_BACKLOG_TOTAL)
+                && "orders-topic".equals(sample.labels().get("topic"))).singleElement()
+                .satisfies(sample -> assertThat(sample.value()).isEqualTo(59D));
+    }
+
+    @Test
+    void skipsUnsupportedVendorTest() {
+        InstanceVO instance = apacheInstance();
+        instance.setVendor(InstanceVendor.ALIYUN);
+
+        assertThat(new ApacheRocketMqBusinessMetricsCollector(mock(InstanceProviderRegistry.class)).collect(instance))
+                .isEmpty();
+    }
+
+    @Test
+    void supportsLegacyInstancesWithoutAnExplicitVendorTest() {
+        InstanceProviderRegistry registry = mock(InstanceProviderRegistry.class);
+        InstanceProvider provider = mock(InstanceProvider.class);
+        when(registry.byInstanceId("local")).thenReturn(Optional.of(provider));
+        when(provider.listConsumerGroups("local", null)).thenReturn(List.of());
+        InstanceVO legacyInstance = apacheInstance();
+        legacyInstance.setVendor(null);
+
+        assertThat(new ApacheRocketMqBusinessMetricsCollector(registry).collect(legacyInstance)).isEmpty();
+        verify(provider).listConsumerGroups("local", null);
+    }
+
+    @Test
+    void reportsUnavailableWhenProviderFailsTest() {
+        InstanceProviderRegistry registry = mock(InstanceProviderRegistry.class);
+        when(registry.byInstanceId("local")).thenThrow(new IllegalStateException("offline"));
+
+        assertThat(new ApacheRocketMqBusinessMetricsCollector(registry).collect(apacheInstance())).hasSize(4)
+                .allSatisfy(sample -> {
+                    assertThat(sample.availability()).isEqualTo(MetricAvailability.UNAVAILABLE);
+                    assertThat(sample.value()).isNull();
+                    assertThat(sample.unavailableReason()).isEqualTo("BUSINESS_METRICS_COLLECTION_FAILED");
+                });
+    }
+
+    @Test
+    void doesNotTurnMissingGroupStatsIntoZeroLagTest() {
+        InstanceProviderRegistry registry = mock(InstanceProviderRegistry.class);
+        InstanceProvider provider = mock(InstanceProvider.class);
+        ConsumerGroupVO group = new ConsumerGroupVO();
+        group.setName("orders");
+        group.setClusterId("cluster-a");
+        when(registry.byInstanceId("local")).thenReturn(Optional.of(provider));
+        when(provider.listConsumerGroups("local", null)).thenReturn(List.of(group));
+
+        List<MetricSample> samples = new ApacheRocketMqBusinessMetricsCollector(registry).collect(apacheInstance());
+
+        assertThat(samples).hasSize(4).allSatisfy(sample -> {
+            assertThat(sample.availability()).isEqualTo(MetricAvailability.UNAVAILABLE);
+            assertThat(sample.value()).isNull();
+            assertThat(sample.labels()).containsEntry("consumerGroup", "orders");
+            assertThat(sample.unavailableReason()).isEqualTo("CONSUMER_STATS_UNAVAILABLE");
+        });
+    }
+
+    private static ConsumerGroupVO group(String name, String clusterId, long lag) {
+        ConsumerGroupVO group = new ConsumerGroupVO();
+        group.setName(name);
+        group.setClusterId(clusterId);
+        group.setTotalLag(lag);
+        group.setConsumeStatsAvailable(true);
+        group.setConsumptionTimestampAvailable(true);
+        group.setDelaySeconds(30);
+        return group;
+    }
+
+    private static InstanceVO apacheInstance() {
+        return InstanceVO.builder().name("local").endpoint("localhost:9876").vendor(InstanceVendor.APACHE).build();
+    }
+}
diff --git a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqClusterMetricsCollectorTest.java b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqClusterMetricsCollectorTest.java
new file mode 100644
index 000000000..8aebb17b3
--- /dev/null
+++ b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqClusterMetricsCollectorTest.java
@@ -0,0 +1,106 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.cluster.metrics.collectors;
+
+import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
+import org.apache.rocketmq.remoting.protocol.body.KVTable;
+import org.apache.rocketmq.remoting.protocol.route.BrokerData;
+import org.apache.rocketmq.studio.cluster.broker.MqAdminExtFactory;
+import org.apache.rocketmq.studio.cluster.broker.RuntimeAdminClientResolver;
+import org.apache.rocketmq.studio.cluster.metrics.MetricAvailability;
+import org.apache.rocketmq.studio.cluster.metrics.MetricSample;
+import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
+import org.apache.rocketmq.studio.instance.InstanceVO;
+import org.apache.rocketmq.tools.admin.MQAdminExt;
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class ApacheRocketMqClusterMetricsCollectorTest {
+
+    @Test
+    void collectsBrokerAvailabilityAndNormalizesDiskUsageTest() throws Exception {
+        RuntimeAdminClientResolver resolver = mock(RuntimeAdminClientResolver.class);
+        MQAdminExt admin = mock(MQAdminExt.class);
+        InstanceVO instance = apacheInstance();
+        ClusterInfo topology = new ClusterInfo();
+        topology.setBrokerAddrTable(Map.of("broker-a", new BrokerData("cluster-a", "broker-a",
+                new HashMap<>(Map.of(0L, "broker-a:10911")))));
+        KVTable runtime = new KVTable();
+        runtime.setTable(new HashMap<>(Map.of("commitLogDiskRatio", "75", "jvmMemoryHeapUsed", "768",
+                "jvmMemoryHeapMax", "1024", "sendThreadPoolQueueSize", "250",
+                "sendThreadPoolQueueCapacity", "1000")));
+        when(admin.examineBrokerClusterInfo()).thenReturn(topology);
+        when(admin.fetchBrokerRuntimeStats("broker-a:10911")).thenReturn(runtime);
+        when(resolver.execute(eq(instance), any(MqAdminExtFactory.AdminAction.class)))
+                .thenAnswer(invocation -> invocation.<MqAdminExtFactory.AdminAction<Object>>getArgument(1)
+                        .apply(admin));
+
+        List<MetricSample> samples = new ApacheRocketMqClusterMetricsCollector(resolver).collect(instance);
+
+        assertThat(samples).extracting(MetricSample::metricKey)
+                .containsExactlyInAnyOrder("nameserver.availability", "broker.availability", "broker.disk.usage_ratio",
+                        "broker.jvm.heap.usage_ratio", "broker.send_queue.usage_ratio");
+        assertThat(samples).filteredOn(sample -> sample.metricKey().equals("broker.disk.usage_ratio"))
+                .singleElement().satisfies(sample -> {
+                    assertThat(sample.value()).isEqualTo(0.75D);
+                    assertThat(sample.availability()).isEqualTo(MetricAvailability.AVAILABLE);
+                    assertThat(sample.labels()).containsEntry("brokerName", "broker-a");
+                });
+        assertThat(samples).filteredOn(sample -> sample.metricKey().equals("broker.jvm.heap.usage_ratio"))
+                .singleElement().extracting(MetricSample::value).isEqualTo(0.75D);
+        assertThat(samples).filteredOn(sample -> sample.metricKey().equals("broker.send_queue.usage_ratio"))
+                .singleElement().extracting(MetricSample::value).isEqualTo(0.25D);
+    }
+
+    @Test
+    void recordsUnavailableNameserverWhenTopologyCollectionFailsTest() {
+        RuntimeAdminClientResolver resolver = mock(RuntimeAdminClientResolver.class);
+        InstanceVO instance = apacheInstance();
+        when(resolver.execute(eq(instance), any(MqAdminExtFactory.AdminAction.class)))
+                .thenThrow(new IllegalStateException("connection refused"));
+
+        List<MetricSample> samples = new ApacheRocketMqClusterMetricsCollector(resolver).collect(instance);
+
+        assertThat(samples).singleElement().satisfies(sample -> {
+            assertThat(sample.metricKey()).isEqualTo("nameserver.availability");
+            assertThat(sample.value()).isNull();
+            assertThat(sample.availability()).isEqualTo(MetricAvailability.UNAVAILABLE);
+        });
+    }
+
+    @Test
+    void skipsUnsupportedVendorTest() {
+        InstanceVO instance = apacheInstance();
+        instance.setVendor(InstanceVendor.ALIYUN);
+
+        assertThat(new ApacheRocketMqClusterMetricsCollector(mock(RuntimeAdminClientResolver.class)).collect(instance))
+                .isEmpty();
+    }
+
+    private static InstanceVO apacheInstance() {
+        return InstanceVO.builder().name("local").endpoint("localhost:9876").vendor(InstanceVendor.APACHE).build();
+    }
+}
diff --git a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqDlqMetricsCollectorTest.java b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqDlqMetricsCollectorTest.java
new file mode 100644
index 000000000..e4ce1d66d
--- /dev/null
+++ b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqDlqMetricsCollectorTest.java
@@ -0,0 +1,90 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.cluster.metrics.collectors;
+
+import org.apache.rocketmq.studio.cluster.metrics.MetricAvailability;
+import org.apache.rocketmq.studio.cluster.metrics.MetricSample;
+import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
+import org.apache.rocketmq.studio.instance.InstanceVO;
+import org.apache.rocketmq.studio.instance.dlq.DLQGroupVO;
+import org.apache.rocketmq.studio.instance.dlq.DLQProvider;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class ApacheRocketMqDlqMetricsCollectorTest {
+
+    @Test
+    void collectsAvailableAndUnavailableDlqGroupSamplesTest() {
+        DLQProvider provider = mock(DLQProvider.class);
+        when(provider.listDLQGroups("local")).thenReturn(List.of(
+                DLQGroupVO.builder().groupName("orders").messageCount(12).statsAvailable(true).build(),
+                DLQGroupVO.builder().groupName("payments").statsAvailable(false).build()));
+
+        List<MetricSample> samples = new ApacheRocketMqDlqMetricsCollector(provider).collect(apacheInstance());
+
+        assertThat(samples).hasSize(2);
+        assertThat(samples).filteredOn(sample -> "orders".equals(sample.labels().get("consumerGroup")))
+                .singleElement().satisfies(sample -> {
+                    assertThat(sample.value()).isEqualTo(12D);
+                    assertThat(sample.availability()).isEqualTo(MetricAvailability.AVAILABLE);
+                });
+        assertThat(samples).filteredOn(sample -> "payments".equals(sample.labels().get("consumerGroup")))
+                .singleElement().satisfies(sample -> assertThat(sample.availability())
+                        .isEqualTo(MetricAvailability.UNAVAILABLE));
+    }
+
+    @Test
+    void reportsUnavailableWhenDlqListingFailsTest() {
+        DLQProvider provider = mock(DLQProvider.class);
+        when(provider.listDLQGroups("local")).thenThrow(new IllegalStateException("offline"));
+
+        assertThat(new ApacheRocketMqDlqMetricsCollector(provider).collect(apacheInstance())).singleElement()
+                .satisfies(sample -> {
+                    assertThat(sample.value()).isNull();
+                    assertThat(sample.availability()).isEqualTo(MetricAvailability.UNAVAILABLE);
+                });
+    }
+
+    @Test
+    void skipsUnsupportedVendorTest() {
+        InstanceVO instance = apacheInstance();
+        instance.setVendor(InstanceVendor.TENCENT);
+
+        assertThat(new ApacheRocketMqDlqMetricsCollector(mock(DLQProvider.class)).collect(instance)).isEmpty();
+    }
+
+    @Test
+    void supportsLegacyInstancesWithoutAnExplicitVendorTest() {
+        DLQProvider provider = mock(DLQProvider.class);
+        when(provider.listDLQGroups("local")).thenReturn(List.of());
+        InstanceVO legacyInstance = apacheInstance();
+        legacyInstance.setVendor(null);
+
+        assertThat(new ApacheRocketMqDlqMetricsCollector(provider).collect(legacyInstance)).isEmpty();
+        verify(provider).listDLQGroups("local");
+    }
+
+    private static InstanceVO apacheInstance() {
+        return InstanceVO.builder().name("local").endpoint("localhost:9876").vendor(InstanceVendor.APACHE).build();
+    }
+}
diff --git a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqProxyMetricsCollectorTest.java b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqProxyMetricsCollectorTest.java
new file mode 100644
index 000000000..b3fc26a8e
--- /dev/null
+++ b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/collectors/ApacheRocketMqProxyMetricsCollectorTest.java
@@ -0,0 +1,99 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.cluster.metrics.collectors;
+
+import org.apache.rocketmq.studio.cluster.broker.ClusterService;
+import org.apache.rocketmq.studio.cluster.broker.ClusterVO;
+import org.apache.rocketmq.studio.cluster.metrics.MetricAvailability;
+import org.apache.rocketmq.studio.cluster.metrics.MetricSample;
+import org.apache.rocketmq.studio.cluster.proxy.ProxyHealthProbe;
+import org.apache.rocketmq.studio.cluster.proxy.ProxyVO;
+import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
+import org.apache.rocketmq.studio.instance.InstanceVO;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.doThrow;
+
+class ApacheRocketMqProxyMetricsCollectorTest {
+
+    @Test
+    void probesOnlyProxiesDiscoveredForTheSelectedInstanceTest() {
+        ClusterService clusterService = mock(ClusterService.class);
+        ProxyHealthProbe probe = mock(ProxyHealthProbe.class);
+        InstanceVO instance = InstanceVO.builder().name("local").endpoint("localhost:9876")
+                .vendor(InstanceVendor.APACHE).build();
+        when(clusterService.listClusters("local")).thenReturn(List.of(ClusterVO.builder().id("cluster-a")
+                .proxies(List.of(ProxyVO.builder().addr("proxy-a:8080").grpcPort(8081).build(),
+                        ProxyVO.builder().addr("[2001:db8::5]:8080").grpcPort(8081).build()))
+                .build()));
+        when(probe.probe("proxy-a", 8081, 2_000)).thenReturn(ProxyHealthProbe.ProbeResult.reachable(3));
+        when(probe.probe("2001:db8::5", 8081, 2_000)).thenReturn(ProxyHealthProbe.ProbeResult.unreachable());
+
+        List<MetricSample> samples = new ApacheRocketMqProxyMetricsCollector(clusterService, probe).collect(instance);
+
+        assertThat(samples).hasSize(2);
+        assertThat(samples).filteredOn(sample -> sample.labels().get("proxyAddr").equals("proxy-a:8080"))
+                .singleElement().satisfies(sample -> {
+                    assertThat(sample.metricKey()).isEqualTo("proxy.availability");
+                    assertThat(sample.clusterId()).isEqualTo("cluster-a");
+                    assertThat(sample.value()).isEqualTo(1D);
+                    assertThat(sample.availability()).isEqualTo(MetricAvailability.AVAILABLE);
+                });
+        assertThat(samples).filteredOn(sample -> sample.labels().get("proxyAddr").contains("2001:db8"))
+                .singleElement().extracting(MetricSample::availability).isEqualTo(MetricAvailability.UNAVAILABLE);
+        verify(clusterService).listClusters("local");
+    }
+
+    @Test
+    void recordsUnavailableSampleForMalformedDiscoveredProxyTest() {
+        ClusterService clusterService = mock(ClusterService.class);
+        InstanceVO instance = InstanceVO.builder().name("local").endpoint("localhost:9876").build();
+        when(clusterService.listClusters("local")).thenReturn(List.of(ClusterVO.builder().name("cluster-a")
+                .proxies(List.of(ProxyVO.builder().addr("bad-address").grpcPort(8081).build())).build()));
+
+        List<MetricSample> samples = new ApacheRocketMqProxyMetricsCollector(clusterService,
+                mock(ProxyHealthProbe.class)).collect(instance);
+
+        assertThat(samples).singleElement().satisfies(sample -> {
+            assertThat(sample.clusterId()).isEqualTo("cluster-a");
+            assertThat(sample.availability()).isEqualTo(MetricAvailability.UNAVAILABLE);
+            assertThat(sample.value()).isNull();
+        });
+    }
+
+    @Test
+    void recordsUnavailableSampleWhenProxyDiscoveryFailsTest() {
+        ClusterService clusterService = mock(ClusterService.class);
+        InstanceVO instance = InstanceVO.builder().name("local").endpoint("localhost:9876").build();
+        doThrow(new IllegalStateException("nameserver unavailable")).when(clusterService).listClusters("local");
+
+        List<MetricSample> samples = new ApacheRocketMqProxyMetricsCollector(clusterService,
+                mock(ProxyHealthProbe.class)).collect(instance);
+
+        assertThat(samples).singleElement().satisfies(sample -> {
+            assertThat(sample.metricKey()).isEqualTo("proxy.availability");
+            assertThat(sample.availability()).isEqualTo(MetricAvailability.UNAVAILABLE);
+            assertThat(sample.labels()).containsEntry("proxyAddr", "unknown");
+        });
+    }
+}
diff --git a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/collectors/CloudRocketMqBusinessMetricsCollectorTest.java b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/collectors/CloudRocketMqBusinessMetricsCollectorTest.java
new file mode 100644
index 000000000..c7ea40b4d
--- /dev/null
+++ b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/collectors/CloudRocketMqBusinessMetricsCollectorTest.java
@@ -0,0 +1,70 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.cluster.metrics.collectors;
+
+import org.apache.rocketmq.studio.cluster.metrics.MetricAvailability;
+import org.apache.rocketmq.studio.cluster.metrics.MetricSample;
+import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
+import org.apache.rocketmq.studio.instance.InstanceVO;
+import org.apache.rocketmq.studio.instance.group.ConsumerGroupVO;
+import org.apache.rocketmq.studio.instance.group.QueueProgressVO;
+import org.apache.rocketmq.studio.provider.InstanceProvider;
+import org.apache.rocketmq.studio.provider.InstanceProviderRegistry;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class CloudRocketMqBusinessMetricsCollectorTest {
+    @Test
+    void collectsTotalAndMaximumLagFromACloudProviderTest() {
+        InstanceProviderRegistry registry = mock(InstanceProviderRegistry.class);
+        InstanceProvider provider = mock(InstanceProvider.class);
+        InstanceVO instance = InstanceVO.builder().name("aliyun").vendor(InstanceVendor.ALIYUN).build();
+        ConsumerGroupVO group = new ConsumerGroupVO();
+        group.setName("orders");
+        group.setClusterId("cloud-a");
+        when(registry.byInstanceId("aliyun")).thenReturn(Optional.of(provider));
+        when(provider.listConsumerGroups("aliyun", null)).thenReturn(List.of(group));
+        when(provider.getGroupProgress("aliyun", "orders")).thenReturn(List.of(
+                QueueProgressVO.builder().topic("orders-topic").diffTotal(12).build(), QueueProgressVO.builder()
+                .topic("orders-topic").diffTotal(30).build()));
+
+        List<MetricSample> samples = new CloudRocketMqBusinessMetricsCollector(registry).collect(instance);
+
+        assertThat(samples).hasSize(3).allSatisfy(sample -> assertThat(sample.availability())
+                .isEqualTo(MetricAvailability.AVAILABLE));
+        assertThat(samples).filteredOn(sample -> sample.metricKey().equals("consumer.lag.total"))
+                .singleElement().extracting(MetricSample::value).isEqualTo(42D);
+        assertThat(samples).filteredOn(sample -> sample.metricKey().equals("consumer.lag.max_queue"))
+                .singleElement().extracting(MetricSample::value).isEqualTo(30D);
+        assertThat(samples).filteredOn(sample -> sample.metricKey().equals("topic.backlog.total"))
+                .singleElement().extracting(MetricSample::value).isEqualTo(42D);
+    }
+
+    @Test
+    void skipsApacheInstancesHandledByTheApacheCollectorTest() {
+        InstanceVO instance = InstanceVO.builder().name("local").vendor(InstanceVendor.APACHE).build();
+
+        assertThat(new CloudRocketMqBusinessMetricsCollector(mock(InstanceProviderRegistry.class)).collect(instance))
+                .isEmpty();
+    }
+}
diff --git a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/collectors/CloudRocketMqClusterMetricsCollectorTest.java b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/collectors/CloudRocketMqClusterMetricsCollectorTest.java
new file mode 100644
index 000000000..ec10a5a79
--- /dev/null
+++ b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/collectors/CloudRocketMqClusterMetricsCollectorTest.java
@@ -0,0 +1,79 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.cluster.metrics.collectors;
+
+import org.apache.rocketmq.studio.cluster.metrics.MetricAvailability;
+import org.apache.rocketmq.studio.cluster.metrics.MetricSample;
+import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
+import org.apache.rocketmq.studio.instance.InstanceVO;
+import org.apache.rocketmq.studio.provider.CloudCatalogProvider;
+import org.apache.rocketmq.studio.provider.CloudInstanceDetailVO;
+import org.apache.rocketmq.studio.provider.InstanceProviderRegistry;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class CloudRocketMqClusterMetricsCollectorTest {
+
+    @Test
+    void collectsRunningCloudInstanceAsAvailableTest() {
+        InstanceProviderRegistry registry = mock(InstanceProviderRegistry.class);
+        CloudCatalogProvider catalog = mock(CloudCatalogProvider.class);
+        InstanceVO instance = cloudInstance(InstanceVendor.ALIYUN);
+        CloudInstanceDetailVO detail = new CloudInstanceDetailVO();
+        detail.setStatus("RUNNING");
+        when(registry.catalogFor(InstanceVendor.ALIYUN)).thenReturn(catalog);
+        when(catalog.getCloudInstance(7L, "cn-hangzhou", "rmq-cloud")).thenReturn(detail);
+
+        List<MetricSample> samples = new CloudRocketMqClusterMetricsCollector(registry).collect(instance);
+
+        assertThat(samples).singleElement().satisfies(sample -> {
+            assertThat(sample.metricKey()).isEqualTo("cloud.instance.availability");
+            assertThat(sample.value()).isEqualTo(1D);
+            assertThat(sample.availability()).isEqualTo(MetricAvailability.AVAILABLE);
+            assertThat(sample.labels()).containsEntry("cloudStatus", "RUNNING");
+        });
+    }
+
+    @Test
+    void recordsUnavailableForStoppedOrUnreachableCloudInstanceTest() {
+        InstanceProviderRegistry registry = mock(InstanceProviderRegistry.class);
+        CloudCatalogProvider catalog = mock(CloudCatalogProvider.class);
+        InstanceVO instance = cloudInstance(InstanceVendor.TENCENT);
+        CloudInstanceDetailVO detail = new CloudInstanceDetailVO();
+        detail.setStatus("STOPPED");
+        when(registry.catalogFor(InstanceVendor.TENCENT)).thenReturn(catalog);
+        when(catalog.getCloudInstance(7L, "cn-hangzhou", "rmq-cloud")).thenReturn(detail);
+
+        List<MetricSample> samples = new CloudRocketMqClusterMetricsCollector(registry).collect(instance);
+
+        assertThat(samples).singleElement().satisfies(sample -> {
+            assertThat(sample.value()).isNull();
+            assertThat(sample.availability()).isEqualTo(MetricAvailability.UNAVAILABLE);
+            assertThat(sample.labels()).containsEntry("cloudStatus", "STOPPED");
+        });
+    }
+
+    private static InstanceVO cloudInstance(InstanceVendor vendor) {
+        return InstanceVO.builder().name("cloud-local").vendor(vendor).credentialId(7L)
+                .regionId("cn-hangzhou").cloudInstanceId("rmq-cloud").build();
+    }
+}
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertVO.java b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertFingerprintTest.java
similarity index 56%
copy from server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertVO.java
copy to server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertFingerprintTest.java
index db9c06a49..7eec5cb52 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/SystemAlertVO.java
+++ b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertFingerprintTest.java
@@ -16,23 +16,25 @@
  */
 package org.apache.rocketmq.studio.ops.alert;
 
-import org.apache.rocketmq.studio.common.domain.enums.AlertLevel;
-import lombok.AllArgsConstructor;
-import lombok.Builder;
-import lombok.Data;
-import lombok.NoArgsConstructor;
+import org.junit.jupiter.api.Test;
 
-import java.time.LocalDateTime;
+import java.util.LinkedHashMap;
+import java.util.Map;
 
-@Data
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-public class SystemAlertVO {
-    private Long id;
-    private AlertLevel level;
-    private String title;
-    private String description;
-    private LocalDateTime time;
-    private boolean acknowledged;
+import static org.assertj.core.api.Assertions.assertThat;
+
+class AlertFingerprintTest {
+    @Test
+    void isStableRegardlessOfLabelMapOrderTest() {
+        Map<String, String> first = new LinkedHashMap<>();
+        first.put("broker", "a");
+        first.put("queue", "1");
+        Map<String, String> second = new LinkedHashMap<>();
+        second.put("queue", "1");
+        second.put("broker", "a");
+
+        assertThat(AlertFingerprint.of(7L, "local", first))
+                .isEqualTo(AlertFingerprint.of(7L, "local", second))
+                .hasSize(64);
+    }
 }
diff --git a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertNotificationSuppressionServiceTest.java b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertNotificationSuppressionServiceTest.java
new file mode 100644
index 000000000..9a036fc4b
--- /dev/null
+++ b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertNotificationSuppressionServiceTest.java
@@ -0,0 +1,95 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import org.apache.rocketmq.studio.common.domain.PageResult;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class AlertNotificationSuppressionServiceTest {
+
+    @Test
+    void findsAnActiveClusterIncidentInTheSameResourceScopeTest() {
+        AlertRepository repository = mock(AlertRepository.class);
+        LocalDateTime now = LocalDateTime.now();
+        SystemAlertVO cause = event(1L, AlertDomain.CLUSTER, "FIRING", "broker-1", now.minusMinutes(15));
+        when(repository.findAlertsPage(any())).thenReturn(PageResult.of(List.of(cause), 1, 1, 100));
+
+        SystemAlertVO result = new AlertNotificationSuppressionService(repository)
+                .findSuppressingClusterAlert(event(2L, AlertDomain.BUSINESS, "FIRING", "broker-1", now))
+                .orElseThrow();
+
+        assertThat(result.getId()).isEqualTo(1L);
+        verify(repository).findAlertsPage(org.mockito.ArgumentMatchers.argThat(query -> query.from().equals(now.minusMinutes(30))
+                && query.to().equals(now) && query.domain() == AlertDomain.CLUSTER));
+    }
+
+    @Test
+    void doesNotSuppressForAnIncidentOutsideTheCorrelationWindowTest() {
+        AlertRepository repository = mock(AlertRepository.class);
+        LocalDateTime now = LocalDateTime.now();
+        when(repository.findAlertsPage(any())).thenReturn(PageResult.of(List.of(), 0, 1, 100));
+
+        assertThat(new AlertNotificationSuppressionService(repository)
+                .findSuppressingClusterAlert(event(2L, AlertDomain.BUSINESS, "FIRING", "broker-1", now)))
+                .isEmpty();
+        verify(repository).findAlertsPage(org.mockito.ArgumentMatchers.argThat(query -> query.from().equals(now.minusMinutes(30))
+                && query.to().equals(now) && query.domain() == AlertDomain.CLUSTER));
+    }
+
+    @Test
+    void doesNotSuppressAfterTheSameClusterIncidentHasResolvedTest() {
+        AlertRepository repository = mock(AlertRepository.class);
+        LocalDateTime now = LocalDateTime.now();
+        SystemAlertVO firing = event(1L, AlertDomain.CLUSTER, "FIRING", "broker-1", now.minusMinutes(3));
+        firing.setFingerprint("broker-1-availability");
+        SystemAlertVO resolved = event(2L, AlertDomain.CLUSTER, "RESOLVED", "broker-1", now.minusMinutes(1));
+        resolved.setFingerprint("broker-1-availability");
+        when(repository.findAlertsPage(any())).thenReturn(PageResult.of(List.of(firing, resolved), 2, 1, 100));
+
+        assertThat(new AlertNotificationSuppressionService(repository)
+                .findSuppressingClusterAlert(event(3L, AlertDomain.BUSINESS, "FIRING", "broker-1", now)))
+                .isEmpty();
+    }
+
+    @Test
+    void doesNotSuppressAcrossDifferentBrokerScopesTest() {
+        AlertRepository repository = mock(AlertRepository.class);
+        LocalDateTime now = LocalDateTime.now();
+        when(repository.findAlertsPage(any())).thenReturn(PageResult.of(
+                List.of(event(1L, AlertDomain.CLUSTER, "FIRING", "broker-2", now.minusMinutes(1))), 1, 1, 100));
+
+        assertThat(new AlertNotificationSuppressionService(repository)
+                .findSuppressingClusterAlert(event(2L, AlertDomain.BUSINESS, "FIRING", "broker-1", now)))
+                .isEmpty();
+    }
+
+    private static SystemAlertVO event(Long id, AlertDomain domain, String transition, String brokerName,
+            LocalDateTime time) {
+        return SystemAlertVO.builder().id(id).domain(domain).transition(transition).instanceId("local")
+                .title("alert-" + id).time(time).labels(Map.of("brokerName", brokerName)).build();
+    }
+}
diff --git a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertNotificationTemplateTest.java b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertNotificationTemplateTest.java
new file mode 100644
index 000000000..61eb8abdf
--- /dev/null
+++ b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertNotificationTemplateTest.java
@@ -0,0 +1,43 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import org.apache.rocketmq.studio.common.domain.enums.AlertLevel;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class AlertNotificationTemplateTest {
+
+    @Test
+    void replacesOnlyDocumentedValuesAndLeavesUnknownPlaceholdersUntouchedTest() {
+        AlertRuleVO rule = AlertRuleVO.builder().name("Disk threshold").metric("broker.disk.usage_ratio")
+                .threshold(85).thresholdUnit("%").build();
+        SystemAlertVO alert = SystemAlertVO.builder().level(AlertLevel.warning).title("Disk threshold")
+                .description("FIRING broker.disk.usage_ratio on local").transition("FIRING")
+                .instanceId("local").currentValue(0.865).time(LocalDateTime.of(2026, 8, 23, 12, 0))
+                .labels(Map.of("brokerName", "broker-a", "brokerAddr", "127.0.0.1:10911")).build();
+
+        String rendered = AlertNotificationTemplate.render(
+                "${ruleName}|${transition}|${value}${thresholdUnit}/${threshold}|${labels}|${missing}", alert, rule);
+
+        assertThat(rendered).isEqualTo("Disk threshold|FIRING|86.5%/85.0|"
+                + "brokerAddr=127.0.0.1:10911, brokerName=broker-a|${missing}");
+    }
+
+    @Test
+    void usesTheExistingNotificationFormatWhenNoTemplateWasConfiguredTest() {
+        SystemAlertVO alert = SystemAlertVO.builder().level(AlertLevel.info).title("Test")
+                .description("connection works").build();
+
+        assertThat(AlertNotificationTemplate.render(null, alert, null))
+                .isEqualTo("[info] Test - connection works\nLabels: ");
+    }
+}
diff --git a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleControllerTest.java b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleControllerTest.java
index c7442d4d3..f59fbd8c4 100644
--- a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleControllerTest.java
+++ b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleControllerTest.java
@@ -52,17 +52,26 @@ class AlertRuleControllerTest {
     @MockBean
     private AlertService alertService;
 
+    @MockBean
+    private NativeAlertRuleTestService nativeAlertRuleTestService;
+
+    @MockBean
+    private NativeAlertMetricCatalogService metricCatalogService;
+
+    @MockBean
+    private AlertRuleTransferService transferService;
+
     @Test
-    void listRulesShouldReturnRules() throws Exception {
+    void businessRulesEndpointShouldReturnBusinessRulesTest() throws Exception {
         AlertRuleVO rule = AlertRuleVO.builder()
                 .id(1L)
                 .name("High Lag")
                 .metric("rocketmq_consumer_lag_messages")
                 .enabled(true)
                 .build();
-        when(alertService.listRules()).thenReturn(List.of(rule));
+        when(alertService.listRules(AlertDomain.BUSINESS)).thenReturn(List.of(rule));
 
-        mockMvc.perform(get("/api/alert-rules"))
+        mockMvc.perform(get("/api/business-alert-rules"))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.code").value(200))
                 .andExpect(jsonPath("$.data[0].id").value(1))
@@ -94,6 +103,39 @@ class AlertRuleControllerTest {
         verify(alertService).listRules("lag", true, 2, 20);
     }
 
+    @Test
+    void businessRulesPageEndpointShouldForwardFiltersTest() throws Exception {
+        AlertRuleVO rule = AlertRuleVO.builder().id(1L).name("High Lag")
+                .domain(AlertDomain.BUSINESS).enabled(true).build();
+        when(alertService.listRules(AlertDomain.BUSINESS, "lag", true, 2, 10))
+                .thenReturn(PageResult.of(List.of(rule), 11, 2, 10));
+
+        mockMvc.perform(get("/api/business-alert-rules/page")
+                        .param("search", "lag").param("enabled", "true")
+                        .param("page", "2").param("pageSize", "10"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.total").value(11))
+                .andExpect(jsonPath("$.data.items[0].id").value(1));
+
+        verify(alertService).listRules(AlertDomain.BUSINESS, "lag", true, 2, 10);
+    }
+
+    @Test
+    void legacyRulesEndpointShouldContinueToReturnAllRulesTest() throws Exception {
+        AlertRuleVO business = AlertRuleVO.builder().id(1L).name("High Lag")
+                .domain(AlertDomain.BUSINESS).enabled(true).build();
+        AlertRuleVO cluster = AlertRuleVO.builder().id(2L).name("Broker down")
+                .domain(AlertDomain.CLUSTER).enabled(true).build();
+        when(alertService.listRules()).thenReturn(List.of(business, cluster));
+
+        mockMvc.perform(get("/api/alert-rules"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.length()").value(2))
+                .andExpect(jsonPath("$.data[1].domain").value("CLUSTER"));
+
+        verify(alertService).listRules();
+    }
+
     @Test
     void exportRulesShouldReturnGeneratedYaml() throws Exception {
         when(alertService.exportPrometheusRulesYaml()).thenReturn("groups:\n");
@@ -105,7 +147,30 @@ class AlertRuleControllerTest {
     }
 
     @Test
-    void createRuleShouldReturnCreatedRule() throws Exception {
+    void exportsAndImportsBusinessRuleTransferTest() throws Exception {
+        AlertRuleTransferDTO transfer = new AlertRuleTransferDTO();
+        transfer.setVersion(AlertRuleTransferDTO.VERSION);
+        transfer.setDomain(AlertDomain.BUSINESS);
+        transfer.setRules(List.of());
+        when(transferService.exportRules(AlertDomain.BUSINESS)).thenReturn(transfer);
+
+        mockMvc.perform(get("/api/alert-rules/transfer"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.version").value(1))
+                .andExpect(jsonPath("$.data.domain").value("BUSINESS"));
+
+        AlertRuleRequestDTO rule = new AlertRuleRequestDTO();
+        rule.setName("Consumer lag");
+        transfer.setRules(List.of(rule));
+        mockMvc.perform(post("/api/alert-rules/import")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(objectMapper.writeValueAsString(transfer)))
+                .andExpect(status().isOk());
+        verify(transferService).importRules(eq(AlertDomain.BUSINESS), any(AlertRuleTransferDTO.class));
+    }
+
+    @Test
+    void createRuleShouldReturnCreatedRuleTest() throws Exception {
         AlertRuleVO request = AlertRuleVO.builder()
                 .name("High Lag")
                 .metric("rocketmq_consumer_lag_messages")
@@ -117,7 +182,7 @@ class AlertRuleControllerTest {
                 .metric("rocketmq_consumer_lag_messages")
                 .enabled(true)
                 .build();
-        when(alertService.createRule(any(AlertRuleVO.class))).thenReturn(created);
+        when(alertService.createRule(eq(AlertDomain.BUSINESS), any(AlertRuleVO.class))).thenReturn(created);
 
         mockMvc.perform(post("/api/alert-rules/create")
                         .contentType(MediaType.APPLICATION_JSON)
@@ -128,7 +193,7 @@ class AlertRuleControllerTest {
     }
 
     @Test
-    void createRuleShouldRejectNullRequestBody() throws Exception {
+    void createRuleShouldRejectNullRequestBodyTest() throws Exception {
         mockMvc.perform(post("/api/alert-rules/create")
                         .contentType(MediaType.APPLICATION_JSON)
                         .content("null"))
@@ -140,7 +205,7 @@ class AlertRuleControllerTest {
     }
 
     @Test
-    void createRuleShouldRejectInvalidRuleFields() throws Exception {
+    void createRuleShouldRejectInvalidRuleFieldsTest() throws Exception {
         mockMvc.perform(post("/api/alert-rules/create")
                         .contentType(MediaType.APPLICATION_JSON)
                         .content("{\"name\":\"High Lag\",\"operator\":\"invalid\"}"))
@@ -152,7 +217,21 @@ class AlertRuleControllerTest {
     }
 
     @Test
-    void createRuleShouldRejectInvalidDuration() throws Exception {
+    void createRuleShouldRejectUnsupportedNotificationChannelsTest() throws Exception {
+        AlertRuleVO request = AlertRuleVO.builder().name("High Lag").metric("consumer.lag.total")
+                .channels(List.of("webhook")).enabled(true).build();
+
+        mockMvc.perform(post("/api/alert-rules/create")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(objectMapper.writeValueAsString(request)))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.message").value("channel is unsupported"));
+
+        verifyNoInteractions(alertService);
+    }
+
+    @Test
+    void createRuleShouldRejectInvalidDurationTest() throws Exception {
         mockMvc.perform(post("/api/alert-rules/create")
                         .contentType(MediaType.APPLICATION_JSON)
                         .content("{\"name\":\"High Lag\",\"duration\":\"later\"}"))
@@ -164,7 +243,7 @@ class AlertRuleControllerTest {
     }
 
     @Test
-    void createRuleShouldRejectInvalidSeverity() throws Exception {
+    void createRuleShouldRejectInvalidSeverityTest() throws Exception {
         mockMvc.perform(post("/api/alert-rules/create")
                         .contentType(MediaType.APPLICATION_JSON)
                         .content("{\"name\":\"High Lag\",\"severity\":\"urgent\"}"))
@@ -176,7 +255,7 @@ class AlertRuleControllerTest {
     }
 
     @Test
-    void updateRuleShouldRejectMissingId() throws Exception {
+    void updateRuleShouldRejectMissingIdTest() throws Exception {
         mockMvc.perform(post("/api/alert-rules/update")
                         .contentType(MediaType.APPLICATION_JSON)
                         .content("{\"name\":\"High Lag\"}"))
@@ -188,7 +267,7 @@ class AlertRuleControllerTest {
     }
 
     @Test
-    void updateRuleShouldRejectNullRequestBody() throws Exception {
+    void updateRuleShouldRejectNullRequestBodyTest() throws Exception {
         mockMvc.perform(post("/api/alert-rules/update")
                         .contentType(MediaType.APPLICATION_JSON)
                         .content("null"))
@@ -200,13 +279,13 @@ class AlertRuleControllerTest {
     }
 
     @Test
-    void toggleRuleShouldPassValidatedRequest() throws Exception {
+    void toggleRuleShouldPassValidatedRequestTest() throws Exception {
         AlertRuleVO toggled = AlertRuleVO.builder()
                 .id(1L)
                 .name("High Lag")
                 .enabled(false)
                 .build();
-        when(alertService.toggleRule(1L, false)).thenReturn(toggled);
+        when(alertService.toggleRule(AlertDomain.BUSINESS, 1L, false)).thenReturn(toggled);
 
         mockMvc.perform(post("/api/alert-rules/toggle")
                         .contentType(MediaType.APPLICATION_JSON)
@@ -216,11 +295,11 @@ class AlertRuleControllerTest {
                 .andExpect(jsonPath("$.data.id").value(1))
                 .andExpect(jsonPath("$.data.enabled").value(false));
 
-        verify(alertService).toggleRule(eq(1L), eq(false));
+        verify(alertService).toggleRule(eq(AlertDomain.BUSINESS), eq(1L), eq(false));
     }
 
     @Test
-    void toggleRuleShouldRejectMissingId() throws Exception {
+    void toggleRuleShouldRejectMissingIdTest() throws Exception {
         mockMvc.perform(post("/api/alert-rules/toggle")
                         .contentType(MediaType.APPLICATION_JSON)
                         .content(objectMapper.writeValueAsString(Map.of("enabled", true))))
@@ -232,7 +311,7 @@ class AlertRuleControllerTest {
     }
 
     @Test
-    void toggleRuleShouldRejectMissingEnabled() throws Exception {
+    void toggleRuleShouldRejectMissingEnabledTest() throws Exception {
         mockMvc.perform(post("/api/alert-rules/toggle")
                         .contentType(MediaType.APPLICATION_JSON)
                         .content(objectMapper.writeValueAsString(Map.of("id", 1))))
@@ -244,7 +323,7 @@ class AlertRuleControllerTest {
     }
 
     @Test
-    void toggleRuleShouldRejectInvalidEnabledType() throws Exception {
+    void toggleRuleShouldRejectInvalidEnabledTypeTest() throws Exception {
         mockMvc.perform(post("/api/alert-rules/toggle")
                         .contentType(MediaType.APPLICATION_JSON)
                         .content(objectMapper.writeValueAsString(Map.of("id", 1, "enabled", "invalid"))))
@@ -256,7 +335,7 @@ class AlertRuleControllerTest {
     }
 
     @Test
-    void deleteRuleShouldPassValidatedRequest() throws Exception {
+    void deleteRuleShouldPassValidatedRequestTest() throws Exception {
         mockMvc.perform(post("/api/alert-rules/delete")
                         .contentType(MediaType.APPLICATION_JSON)
                         .content(objectMapper.writeValueAsString(Map.of("id", 1))))
@@ -264,11 +343,11 @@ class AlertRuleControllerTest {
                 .andExpect(jsonPath("$.code").value(200))
                 .andExpect(jsonPath("$.message").value("success"));
 
-        verify(alertService).deleteRule(1L);
+        verify(alertService).deleteRule(AlertDomain.BUSINESS, 1L);
     }
 
     @Test
-    void deleteRuleShouldRejectBlankId() throws Exception {
+    void deleteRuleShouldRejectBlankIdTest() throws Exception {
         mockMvc.perform(post("/api/alert-rules/delete")
                         .contentType(MediaType.APPLICATION_JSON)
                         .content(objectMapper.writeValueAsString(java.util.Collections.singletonMap("id", null))))
@@ -280,9 +359,9 @@ class AlertRuleControllerTest {
     }
 
     @Test
-    void bulkToggleShouldReturnPartialResults() throws Exception {
+    void bulkToggleShouldReturnPartialResultsTest() throws Exception {
         AlertRuleVO updated = AlertRuleVO.builder().id(1L).enabled(false).build();
-        when(alertService.bulkToggleRules(List.of(1L, 999L), false))
+        when(alertService.bulkToggleRules(AlertDomain.BUSINESS, List.of(1L, 999L), false))
                 .thenReturn(AlertRuleBulkResultVO.builder()
                         .succeededIds(List.of(1L))
                         .failures(Map.of(999L, "Alert rule not found"))
@@ -298,7 +377,7 @@ class AlertRuleControllerTest {
     }
 
     @Test
-    void bulkDeleteShouldRejectEmptyIds() throws Exception {
+    void bulkDeleteShouldRejectEmptyIdsTest() throws Exception {
         mockMvc.perform(post("/api/alert-rules/bulk-delete")
                         .contentType(MediaType.APPLICATION_JSON)
                         .content("{\"ids\":[]}"))
diff --git a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleDurationTest.java b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleDurationTest.java
new file mode 100644
index 000000000..a22233149
--- /dev/null
+++ b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleDurationTest.java
@@ -0,0 +1,21 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class AlertRuleDurationTest {
+    @Test
+    void parsesTheRuleDurationSyntaxTest() {
+        assertThat(AlertRuleDuration.parse("1h30m")).isEqualTo(Duration.ofMinutes(90));
+        assertThat(AlertRuleDuration.parse(null)).isEqualTo(Duration.ZERO);
+    }
+}
diff --git a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleEvaluatorTest.java b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleEvaluatorTest.java
new file mode 100644
index 000000000..b55fa7c1c
--- /dev/null
+++ b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleEvaluatorTest.java
@@ -0,0 +1,91 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import org.apache.rocketmq.studio.cluster.metrics.MetricAvailability;
+import org.apache.rocketmq.studio.cluster.metrics.MetricSample;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class AlertRuleEvaluatorTest {
+    private final AlertRuleEvaluator evaluator = new AlertRuleEvaluator();
+
+    @Test
+    void triggersMatchingAvailableMetricTest() {
+        AlertRuleVO rule = AlertRuleVO.builder().domain(AlertDomain.CLUSTER).metric("broker.disk.usage_ratio")
+                .operator(">=").threshold(0.85).enabled(true).build();
+        MetricSample sample = sample(MetricAvailability.AVAILABLE, 0.9);
+
+        AlertEvaluationResult result = evaluator.evaluate(rule, sample);
+
+        assertThat(result.matches()).isTrue();
+        assertThat(result.conditionMet()).isTrue();
+        assertThat(result.currentValue()).isEqualTo(0.9);
+    }
+
+    @Test
+    void evaluatesPercentageThresholdsForNativeRatioMetricsTest() {
+        AlertRuleVO rule = AlertRuleVO.builder().domain(AlertDomain.CLUSTER).metric("broker.disk.usage_ratio")
+                .operator(">=").threshold(85).thresholdUnit("%").enabled(true).build();
+
+        AlertEvaluationResult result = evaluator.evaluate(rule, sample(MetricAvailability.AVAILABLE, 0.9));
+
+        assertThat(result.conditionMet()).isTrue();
+    }
+
+    @Test
+    void unavailableMetricDoesNotBehaveAsZeroTest() {
+        AlertRuleVO rule = AlertRuleVO.builder().domain(AlertDomain.CLUSTER).metric("broker.disk.usage_ratio")
+                .operator("<").threshold(0.1).enabled(true).build();
+
+        AlertEvaluationResult result = evaluator.evaluate(rule, sample(MetricAvailability.UNAVAILABLE, null));
+
+        assertThat(result.matches()).isTrue();
+        assertThat(result.conditionMet()).isFalse();
+        assertThat(result.currentValue()).isNull();
+    }
+
+    @Test
+    void explicitlyTriggersAvailabilityRuleForUnavailableSampleTest() {
+        AlertRuleVO rule = AlertRuleVO.builder().domain(AlertDomain.CLUSTER).metric("broker.availability")
+                .operator("UNAVAILABLE").enabled(true).build();
+        MetricSample sample = new MetricSample("broker.availability", AlertDomain.CLUSTER, "local", null, null,
+                null, MetricAvailability.UNAVAILABLE, Instant.now());
+
+        AlertEvaluationResult result = evaluator.evaluate(rule, sample);
+
+        assertThat(result.matches()).isTrue();
+        assertThat(result.conditionMet()).isTrue();
+        assertThat(result.currentValue()).isNull();
+    }
+
+    @Test
+    void doesNotMatchOtherRuleDomainTest() {
+        AlertRuleVO rule = AlertRuleVO.builder().domain(AlertDomain.BUSINESS).metric("broker.disk.usage_ratio")
+                .operator(">=").threshold(0.85).enabled(true).build();
+
+        assertThat(evaluator.evaluate(rule, sample(MetricAvailability.AVAILABLE, 0.9)).matches()).isFalse();
+    }
+
+    private static MetricSample sample(MetricAvailability availability, Double value) {
+        return new MetricSample("broker.disk.usage_ratio", AlertDomain.CLUSTER, "local", null, null, value,
+                availability, Instant.now());
+    }
+}
diff --git a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleRequestDTOTest.java b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleRequestDTOTest.java
index 09d0dfbdd..de1a6f6e6 100644
--- a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleRequestDTOTest.java
+++ b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleRequestDTOTest.java
@@ -30,18 +30,19 @@ class AlertRuleRequestDTOTest {
     private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
 
     @Test
-    void channelsShouldRejectNullAndBlankElements() {
+    void channelsShouldRejectNullBlankAndUnsupportedElementsTest() {
         AlertRuleRequestDTO request = new AlertRuleRequestDTO();
         request.setName("High Lag");
         request.setChannels(Arrays.asList("email", null, " "));
 
         assertThat(validator.validate(request))
                 .extracting(violation -> violation.getMessage())
-                .containsOnly("channel must not be blank");
+                .containsExactlyInAnyOrder("channel must not be blank", "channel must not be blank",
+                        "channel is unsupported");
     }
 
     @Test
-    void toAlertRuleVOShouldTrimAndDeduplicateChannelsInInputOrder() {
+    void toAlertRuleVOShouldTrimAndDeduplicateChannelsInInputOrderTest() {
         AlertRuleRequestDTO request = new AlertRuleRequestDTO();
         request.setName("High Lag");
         request.setChannels(List.of(" email ", "sms", "email", " sms "));
diff --git a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleSemanticFingerprintTest.java b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleSemanticFingerprintTest.java
new file mode 100644
index 000000000..a52012cc2
--- /dev/null
+++ b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleSemanticFingerprintTest.java
@@ -0,0 +1,80 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class AlertRuleSemanticFingerprintTest {
+
+    @Test
+    void ignoresPresentationAndDeliverySettingsTest() {
+        AlertRuleVO first = AlertRuleVO.builder()
+                .domain(AlertDomain.CLUSTER).name("Disk warning").instanceId("local")
+                .metric("broker.disk.usage_ratio").operator(">=").threshold(85)
+                .duration("5m").aggregation("LAST").channels(List.of("email"))
+                .severity("warning").enabled(true).reminderInterval("30m").build();
+        AlertRuleVO second = AlertRuleVO.builder()
+                .domain(AlertDomain.CLUSTER).name("Disk critical").instanceId(" local ")
+                .metric(" broker.disk.usage_ratio ").operator(" >= ").threshold(85.0)
+                .duration("5M").channels(List.of("dingtalk", "sms"))
+                .severity("critical").enabled(false).reminderInterval("1h").build();
+
+        assertThat(AlertRuleSemanticFingerprint.of(second))
+                .isEqualTo(AlertRuleSemanticFingerprint.of(first));
+    }
+
+    @Test
+    void changesWhenTheEvaluationScopeOrWindowChangesTest() {
+        AlertRuleVO base = AlertRuleVO.builder()
+                .domain(AlertDomain.BUSINESS).instanceId("local").metric("consumer.lag.total")
+                .operator(">=").threshold(1000).duration("5m").consumerGroup("group-a").build();
+        AlertRuleVO differentScope = AlertRuleVO.builder()
+                .domain(AlertDomain.BUSINESS).instanceId("local").metric("consumer.lag.total")
+                .operator(">=").threshold(1000).duration("5m").consumerGroup("group-b").build();
+        AlertRuleVO differentWindow = AlertRuleVO.builder()
+                .domain(AlertDomain.BUSINESS).instanceId("local").metric("consumer.lag.total")
+                .operator(">=").threshold(1000).duration("5m").consumerGroup("group-a")
+                .windowSeconds(60).build();
+
+        assertThat(AlertRuleSemanticFingerprint.of(differentScope))
+                .isNotEqualTo(AlertRuleSemanticFingerprint.of(base));
+        assertThat(AlertRuleSemanticFingerprint.of(differentWindow))
+                .isNotEqualTo(AlertRuleSemanticFingerprint.of(base));
+    }
+
+    @Test
+    void ratioThresholdsShouldUseTheirNormalizedValueForSemanticIdentityTest() {
+        AlertRuleVO percentage = AlertRuleVO.builder()
+                .domain(AlertDomain.CLUSTER)
+                .metric("broker.disk.usage_ratio")
+                .operator(">=")
+                .threshold(85)
+                .thresholdUnit("%")
+                .build();
+        AlertRuleVO fraction = AlertRuleVO.builder()
+                .domain(AlertDomain.CLUSTER)
+                .metric("broker.disk.usage_ratio")
+                .operator(">=")
+                .threshold(0.85)
+                .thresholdUnit(null)
+                .build();
+        AlertRuleVO rawValue = AlertRuleVO.builder()
+                .domain(AlertDomain.CLUSTER)
+                .metric("broker.disk.usage_ratio")
+                .operator(">=")
+                .threshold(85)
+                .thresholdUnit(null)
+                .build();
+
+        assertThat(AlertRuleSemanticFingerprint.of(percentage))
+                .isEqualTo(AlertRuleSemanticFingerprint.of(fraction))
+                .isNotEqualTo(AlertRuleSemanticFingerprint.of(rawValue));
+    }
+}
diff --git a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleTransferServiceTest.java b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleTransferServiceTest.java
new file mode 100644
index 000000000..a3aaaf982
--- /dev/null
+++ b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleTransferServiceTest.java
@@ -0,0 +1,138 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class AlertRuleTransferServiceTest {
+
+    @Mock
+    private AlertService alertService;
+
+    @Mock
+    private NativeAlertMetricCatalogService metricCatalogService;
+
+    @InjectMocks
+    private AlertRuleTransferService transferService;
+
+    @Test
+    void exportsPortableRulesWithoutIdsOrRuntimeStateTest() {
+        AlertRuleVO rule = AlertRuleVO.builder()
+                .id(42L)
+                .domain(AlertDomain.CLUSTER)
+                .name("Broker disk usage")
+                .metric("broker.disk.usage")
+                .operator(">")
+                .threshold(85)
+                .duration("5m")
+                .channels(List.of("dingtalk"))
+                .enabled(true)
+                .lastTriggered("2026-08-23T10:35:38Z")
+                .notificationTemplate("${ruleName}: ${value}")
+                .build();
+        when(alertService.listRules(AlertDomain.CLUSTER)).thenReturn(List.of(rule));
+
+        AlertRuleTransferDTO transfer = transferService.exportRules(AlertDomain.CLUSTER);
+
+        assertEquals(AlertRuleTransferDTO.VERSION, transfer.getVersion());
+        assertEquals(AlertDomain.CLUSTER, transfer.getDomain());
+        assertEquals("Broker disk usage", transfer.getRules().get(0).getName());
+        assertEquals("${ruleName}: ${value}", transfer.getRules().get(0).getNotificationTemplate());
+        assertNull(transfer.getRules().get(0).getId());
+    }
+
+    @Test
+    void rejectsImportFromAnotherDomainBeforeChangingRulesTest() {
+        AlertRuleTransferDTO transfer = transfer(AlertDomain.BUSINESS, request("Business lag"));
+
+        BusinessException error = assertThrows(BusinessException.class,
+                () -> transferService.importRules(AlertDomain.CLUSTER, transfer));
+
+        assertEquals(400, error.getCode());
+        verifyNoInteractions(alertService, metricCatalogService);
+    }
+
+    @Test
+    void validatesEveryRuleBeforeCreatingAnyRuleTest() {
+        AlertRuleRequestDTO first = request("First");
+        AlertRuleRequestDTO invalid = request("Invalid");
+        doAnswer(invocation -> {
+            AlertRuleVO candidate = invocation.getArgument(0);
+            if ("Invalid".equals(candidate.getName())) {
+                throw new BusinessException(400, "invalid metric");
+            }
+            return null;
+        }).when(metricCatalogService).validate(any(AlertRuleVO.class));
+
+        assertThrows(BusinessException.class,
+                () -> transferService.importRules(AlertDomain.CLUSTER,
+                        transfer(AlertDomain.CLUSTER, first, invalid)));
+
+        verify(metricCatalogService, times(2)).validate(any(AlertRuleVO.class));
+        verify(alertService, never()).createRule(eq(AlertDomain.CLUSTER), any(AlertRuleVO.class));
+    }
+
+    @Test
+    void importsRulesAsNewRulesInTheRequestedDomainTest() {
+        AlertRuleRequestDTO request = request("Broker unavailable");
+        request.setId(99L);
+        request.setNotificationTemplate("${transition} ${metric}");
+        when(alertService.createRule(eq(AlertDomain.CLUSTER), any(AlertRuleVO.class)))
+                .thenAnswer(invocation -> invocation.getArgument(1));
+
+        List<AlertRuleVO> imported = transferService.importRules(AlertDomain.CLUSTER,
+                transfer(AlertDomain.CLUSTER, request));
+
+        ArgumentCaptor<AlertRuleVO> captor = ArgumentCaptor.forClass(AlertRuleVO.class);
+        verify(alertService).createRule(eq(AlertDomain.CLUSTER), captor.capture());
+        assertNull(captor.getValue().getId());
+        assertEquals(AlertDomain.CLUSTER, captor.getValue().getDomain());
+        assertEquals("${transition} ${metric}", captor.getValue().getNotificationTemplate());
+        assertEquals("Broker unavailable", imported.get(0).getName());
+    }
+
+    private static AlertRuleTransferDTO transfer(AlertDomain domain, AlertRuleRequestDTO... rules) {
+        AlertRuleTransferDTO transfer = new AlertRuleTransferDTO();
+        transfer.setVersion(AlertRuleTransferDTO.VERSION);
+        transfer.setDomain(domain);
+        transfer.setRules(List.of(rules));
+        return transfer;
+    }
+
+    private static AlertRuleRequestDTO request(String name) {
+        AlertRuleRequestDTO request = new AlertRuleRequestDTO();
+        request.setName(name);
+        request.setMetric("broker.disk.usage");
+        request.setOperator(">");
+        request.setThreshold(85);
+        request.setDuration("5m");
+        request.setChannels(List.of("dingtalk"));
+        request.setEnabled(true);
+        return request;
+    }
+}
diff --git a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertSchemaMigrationTest.java b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertSchemaMigrationTest.java
new file mode 100644
index 000000000..5b6a46473
--- /dev/null
+++ b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertSchemaMigrationTest.java
@@ -0,0 +1,61 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import org.h2.jdbcx.JdbcDataSource;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.DefaultApplicationArguments;
+
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.Statement;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class AlertSchemaMigrationTest {
+    @Test
+    void upgradesExistingAlertTablesWithoutChangingFreshSchemaBehaviorTest() throws Exception {
+        JdbcDataSource dataSource = new JdbcDataSource();
+        dataSource.setURL("jdbc:h2:mem:alert-schema-migration;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE");
+        dataSource.setUser("sa");
+        try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) {
+            statement.execute("CREATE TABLE rmq_alert_rule (id BIGINT PRIMARY KEY, name VARCHAR(128))");
+            statement.execute("CREATE TABLE rmq_system_alert (id BIGINT PRIMARY KEY, time TIMESTAMP)");
+            statement.execute("CREATE TABLE rmq_alert_notification_outbox (id BIGINT PRIMARY KEY, alert_id BIGINT, "
+                    + "channel VARCHAR(32), status VARCHAR(16), next_attempt_at TIMESTAMP)");
+        }
+
+        AlertSchemaMigration migration = new AlertSchemaMigration(dataSource);
+        migration.run(new DefaultApplicationArguments());
+        migration.run(new DefaultApplicationArguments());
+
+        try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement();
+                ResultSet result = statement.executeQuery("SELECT COUNT(*) FROM information_schema.columns "
+                        + "WHERE table_name = 'rmq_alert_rule' AND column_name IN "
+                        + "('aggregation', 'window_seconds', 'domain', 'instance_id', 'consumer_group', 'topic', "
+                        + "'consecutive_samples', 'notification_template')")) {
+            result.next();
+            assertThat(result.getInt(1)).isEqualTo(8);
+        }
+
+        try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement();
+                ResultSet result = statement.executeQuery("SELECT COUNT(*) FROM information_schema.tables "
+                        + "WHERE table_name IN ('rmq_metric_snapshot', 'rmq_alert_collection_lease', "
+                        + "'rmq_alert_state', 'rmq_alert_silence', 'rmq_alert_notification_outbox')")) {
+            result.next();
+            assertThat(result.getInt(1)).isEqualTo(5);
+        }
+
+        try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement();
+                ResultSet result = statement.executeQuery("SELECT COUNT(*) FROM information_schema.columns "
+                        + "WHERE table_name = 'rmq_alert_notification_outbox' AND column_name IN "
+                        + "('sending_started_at', 'claim_token', 'message_content')")) {
+            result.next();
+            assertThat(result.getInt(1)).isEqualTo(3);
+        }
+    }
+}
diff --git a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceDefaultRulesTest.java b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceDefaultRulesTest.java
index 6d0acfda2..cce9201d3 100644
--- a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceDefaultRulesTest.java
+++ b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceDefaultRulesTest.java
@@ -34,12 +34,12 @@ class AlertServiceDefaultRulesTest {
     private static final Pattern ALERT_PATTERN = Pattern.compile("^\\s*- alert:");
 
     @Test
-    void exportUsesBundledAssetsAsDefaultWhenRepositoryIsEmpty() {
+    void exportUsesBundledAssetsAsDefaultWhenRepositoryIsEmptyTest() {
         AlertRepository repository = mock(AlertRepository.class);
         when(repository.findAllRules()).thenReturn(Collections.emptyList());
 
-        AlertService service = new AlertService(repository, new AlertRuleAssetService(),
-                Mockito.mock(OperationAuditService.class));
+        AlertService service = new AlertService(repository, Mockito.mock(AlertStateRepository.class),
+                new AlertRuleAssetService(), Mockito.mock(OperationAuditService.class));
         String yaml = service.exportPrometheusRulesYaml();
 
         int ruleCount = countRules(yaml);
@@ -47,12 +47,12 @@ class AlertServiceDefaultRulesTest {
     }
 
     @Test
-    void listRulesDelegatesToRepositoryWhenEmptyUsesDefaultsViaExport() {
+    void listRulesDelegatesToRepositoryWhenEmptyUsesDefaultsViaExportTest() {
         AlertRepository repository = mock(AlertRepository.class);
         when(repository.findAllRules()).thenReturn(List.of());
 
-        AlertService service = new AlertService(repository, new AlertRuleAssetService(),
-                Mockito.mock(OperationAuditService.class));
+        AlertService service = new AlertService(repository, Mockito.mock(AlertStateRepository.class),
+                new AlertRuleAssetService(), Mockito.mock(OperationAuditService.class));
         String yaml = service.exportPrometheusRulesYaml();
 
         assertTrue(yaml.contains("rocketmq-broker.rules"));
diff --git a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
index 051105355..b137d57a1 100644
--- a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
+++ b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
@@ -28,10 +28,13 @@ import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.Locale;
+import java.util.Map;
 import java.util.Optional;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -53,16 +56,19 @@ class AlertServiceTest {
 
     @Mock
     private OperationAuditService operationAuditService;
+    @Mock
+    private AlertStateRepository alertStateRepository;
 
     private AlertService alertService;
 
     @org.junit.jupiter.api.BeforeEach
-    void setUp() {
-        alertService = new AlertService(alertRepository, new AlertRuleAssetService(), operationAuditService);
+    void setUpTest() {
+        alertService = new AlertService(alertRepository, alertStateRepository, new AlertRuleAssetService(),
+                operationAuditService);
     }
 
     @Test
-    void listRulesShouldReturnAllRules() {
+    void listRulesShouldReturnAllRulesTest() {
         AlertRuleVO rule1 = AlertRuleVO.builder().id(1L).name("High CPU").metric("cpu_usage")
                 .operator(">").threshold(90.0).enabled(true).build();
         AlertRuleVO rule2 = AlertRuleVO.builder().id(2L).name("Low Disk").metric("disk_free")
@@ -79,7 +85,7 @@ class AlertServiceTest {
     }
 
     @Test
-    void listRulesShouldReturnEmptyListWhenNoRules() {
+    void listRulesShouldReturnEmptyListWhenNoRulesTest() {
         when(alertRepository.findAllRules()).thenReturn(Collections.emptyList());
 
         List<AlertRuleVO> result = alertService.listRules();
@@ -113,6 +119,90 @@ class AlertServiceTest {
         verify(alertRepository, never()).findRulePage(any(), any(), anyInt(), anyInt());
     }
 
+    @Test
+    void listRulesShouldSeparateBusinessAndClusterDomainsTest() {
+        AlertRuleVO business = AlertRuleVO.builder().id(1L).name("Consumer lag").build();
+        AlertRuleVO cluster = AlertRuleVO.builder()
+                .id(2L).name("Broker unavailable").domain(AlertDomain.CLUSTER).build();
+        when(alertRepository.findAllRules()).thenReturn(List.of(business, cluster));
+
+        assertThat(alertService.listRules(AlertDomain.BUSINESS)).containsExactly(business);
+        assertThat(alertService.listRules(AlertDomain.CLUSTER)).containsExactly(cluster);
+    }
+
+    @Test
+    void listRulesPageShouldDelegateFiltersWithoutReadingAllRulesTest() {
+        PageResult<AlertRuleVO> expected = PageResult.of(List.of(), 0, 2, 10);
+        when(alertRepository.findRulesPage(any(AlertRuleQuery.class))).thenReturn(expected);
+
+        assertThat(alertService.listRules(AlertDomain.BUSINESS, " lag ", true, 2, 10)).isSameAs(expected);
+
+        verify(alertRepository).findRulesPage(argThat(query -> query.domain() == AlertDomain.BUSINESS
+                && "lag".equals(query.search()) && Boolean.TRUE.equals(query.enabled())
+                && query.page() == 2 && query.pageSize() == 10));
+        verify(alertRepository, never()).findAllRules();
+    }
+
+    @Test
+    void listRulesPageShouldRejectInvalidPageBoundsTest() {
+        assertThatThrownBy(() -> alertService.listRules(AlertDomain.BUSINESS, null, null, 0, 20))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("Invalid page or pageSize");
+        assertThatThrownBy(() -> alertService.listRules(AlertDomain.CLUSTER, null, null, 1, 101))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("Invalid page or pageSize");
+        verify(alertRepository, never()).findRulesPage(any(AlertRuleQuery.class));
+    }
+
+    @Test
+    void clusterRuleOperationsShouldRejectBusinessRulesTest() {
+        AlertRuleVO business = AlertRuleVO.builder().id(1L).name("Consumer lag").build();
+        when(alertRepository.findRuleById(1L)).thenReturn(Optional.of(business));
+
+        assertThatThrownBy(() -> alertService.toggleRule(AlertDomain.CLUSTER, 1L, false))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("Alert rule not found: 1")
+                .satisfies(ex -> assertThat(((BusinessException) ex).getCode()).isEqualTo(404));
+
+        verify(alertRepository, never()).replaceRule(any());
+        verify(alertRepository, never()).findAllRules();
+    }
+
+    @Test
+    void clusterRuleOperationsShouldUpdateClusterRulesOnlyTest() {
+        AlertRuleVO cluster = AlertRuleVO.builder().id(2L).name("Broker unavailable")
+                .domain(AlertDomain.CLUSTER).enabled(false).build();
+        when(alertRepository.findRuleById(2L)).thenReturn(Optional.of(cluster));
+        when(alertRepository.replaceRule(cluster)).thenReturn(true);
+
+        AlertRuleVO updated = alertService.toggleRule(AlertDomain.CLUSTER, 2L, true);
+
+        assertThat(updated.isEnabled()).isTrue();
+        verify(alertRepository).replaceRule(cluster);
+        verify(alertRepository, never()).findAllRules();
+        verify(alertStateRepository).deleteByRuleId(2L);
+    }
+
+    @Test
+    void updatingRuleShouldResetItsPreviousEvaluationStateTest() {
+        AlertRuleVO rule = AlertRuleVO.builder().id(4L).name("Lag").metric("consumer.lag.total")
+                .operator(">").threshold(100).instanceId("local").build();
+        when(alertRepository.replaceRule(rule)).thenReturn(true);
+
+        alertService.updateRule(rule);
+
+        verify(alertStateRepository).deleteByRuleId(4L);
+    }
+
+    @Test
+    void deletingRuleShouldRemoveItsStoredEvaluationStateTest() {
+        when(alertRepository.deleteRule(4L)).thenReturn(true);
+
+        alertService.deleteRule(4L);
+
+        verify(alertStateRepository).deleteByRuleId(4L);
+    }
+
     @Test
     void exportPrometheusRulesYamlShouldReturnDefaultRulesWhenRepositoryIsEmpty() {
         when(alertRepository.findAllRules()).thenReturn(Collections.emptyList());
@@ -129,7 +219,7 @@ class AlertServiceTest {
     }
 
     @Test
-    void exportPrometheusRulesYamlShouldConvertConfiguredRules() {
+    void exportPrometheusRulesYamlShouldConvertConfiguredRulesTest() {
         AlertRuleVO rule = AlertRuleVO.builder()
                 .name("High Lag Alert")
                 .metric("rocketmq_consumer_lag_messages")
@@ -152,7 +242,34 @@ class AlertServiceTest {
     }
 
     @Test
-    void exportPrometheusRulesYamlShouldEmitSingleGroupForSameTeamRules() {
+    void exportPrometheusRulesYamlShouldExcludeNativeClusterRulesTest() {
+        AlertRuleVO legacy = AlertRuleVO.builder()
+                .name("High Lag Alert")
+                .metric("rocketmq_consumer_lag_messages")
+                .operator(">")
+                .threshold(5000)
+                .enabled(true)
+                .build();
+        AlertRuleVO nativeRule = AlertRuleVO.builder()
+                .domain(AlertDomain.CLUSTER)
+                .name("Native Disk Usage")
+                .metric("broker.disk.usage_ratio")
+                .operator(">=")
+                .threshold(85)
+                .thresholdUnit("%")
+                .enabled(true)
+                .build();
+        when(alertRepository.findAllRules()).thenReturn(List.of(legacy, nativeRule));
+
+        String result = alertService.exportPrometheusRulesYaml();
+
+        assertThat(result)
+                .contains("HighLagAlert")
+                .doesNotContain("NativeDiskUsage");
+    }
+
+    @Test
+    void exportPrometheusRulesYamlShouldEmitSingleGroupForSameTeamRulesTest() {
         AlertRuleVO first = AlertRuleVO.builder()
                 .name("Lag Alert A")
                 .metric("rocketmq_consumer_lag_messages")
@@ -185,7 +302,7 @@ class AlertServiceTest {
     }
 
     @Test
-    void exportPrometheusRulesYamlShouldDisambiguateDuplicateAlertNames() {
+    void exportPrometheusRulesYamlShouldDisambiguateDuplicateAlertNamesTest() {
         AlertRuleVO first = AlertRuleVO.builder()
                 .name("High Lag")
                 .metric("rocketmq_consumer_lag_messages")
@@ -210,7 +327,7 @@ class AlertServiceTest {
     }
 
     @Test
-    void exportPrometheusRulesYamlShouldInferTeamWithoutMetricCaseSensitivity() {
+    void exportPrometheusRulesYamlShouldInferTeamWithoutMetricCaseSensitivityTest() {
         AlertRuleVO rule = AlertRuleVO.builder()
                 .name("Uppercase lag")
                 .metric("ROCKETMQ_CONSUMER_LAG_MESSAGES")
@@ -226,7 +343,7 @@ class AlertServiceTest {
     }
 
     @Test
-    void exportPrometheusRulesYamlShouldRenderReplicationLagRuleWithScopeAndSeverity() {
+    void exportPrometheusRulesYamlShouldRenderReplicationLagRuleWithScopeAndSeverityTest() {
         AlertRuleVO rule = AlertRuleVO.builder()
                 .name("Replication Lag High")
                 .metric("rocketmq_broker_replication_lag_bytes")
@@ -251,7 +368,7 @@ class AlertServiceTest {
     }
 
     @Test
-    void exportPrometheusRulesYamlShouldIgnoreWildcardScopeAndInvalidSeverity() {
+    void exportPrometheusRulesYamlShouldIgnoreWildcardScopeAndInvalidSeverityTest() {
         AlertRuleVO rule = AlertRuleVO.builder()
                 .name("Replication Lag Any Broker")
                 .metric("rocketmq_broker_replication_lag_bytes")
@@ -272,7 +389,7 @@ class AlertServiceTest {
     }
 
     @Test
-    void exportPrometheusRulesYamlShouldReplaceInvalidPrometheusFields() {
+    void exportPrometheusRulesYamlShouldReplaceInvalidPrometheusFieldsTest() {
         AlertRuleVO rule = AlertRuleVO.builder()
                 .name("Malformed rule")
                 .metric("up) or vector(1")
@@ -292,7 +409,7 @@ class AlertServiceTest {
     }
 
     @Test
-    void exportPrometheusRulesYamlShouldNormalizeSeverityIndependentlyOfDefaultLocale() {
+    void exportPrometheusRulesYamlShouldNormalizeSeverityIndependentlyOfDefaultLocaleTest() {
         AlertRuleVO rule = AlertRuleVO.builder()
                 .name("Informational Alert")
                 .metric("rocketmq_consumer_lag_messages")
@@ -316,7 +433,7 @@ class AlertServiceTest {
     }
 
     @Test
-    void exportPrometheusRulesYamlShouldNotEmitABlankSummary() throws Exception {
+    void exportPrometheusRulesYamlShouldNotEmitABlankSummaryTest() throws Exception {
         AlertRuleVO rule = AlertRuleVO.builder()
                 .name("Lag alert")
                 .metric("rocketmq_consumer_lag_messages")
@@ -337,7 +454,7 @@ class AlertServiceTest {
     }
 
     @Test
-    void exportPrometheusRulesYamlShouldEscapeSpecialCharacters() throws Exception {
+    void exportPrometheusRulesYamlShouldEscapeSpecialCharactersTest() throws Exception {
         AlertRuleVO rule = AlertRuleVO.builder()
                 .name("Scoped rule")
                 .metric("rocketmq_consumer_lag_messages")
@@ -364,7 +481,7 @@ class AlertServiceTest {
     }
 
     @Test
-    void exportPrometheusRulesYamlShouldUseFallbackWhenAlertNameHasNoValidCharacters() throws Exception {
+    void exportPrometheusRulesYamlShouldUseFallbackWhenAlertNameHasNoValidCharactersTest() throws Exception {
         AlertRuleVO rule = AlertRuleVO.builder()
                 .name(" - !")
                 .metric("rocketmq_consumer_lag_messages")
@@ -382,7 +499,7 @@ class AlertServiceTest {
     }
 
     @Test
-    void exportPrometheusRulesYamlShouldPrefixAlertNamesStartingWithDigit() throws Exception {
+    void exportPrometheusRulesYamlShouldPrefixAlertNamesStartingWithDigitTest() throws Exception {
         AlertRuleVO rule = AlertRuleVO.builder()
                 .name("5xx spike")
                 .metric("rocketmq_consumer_lag_messages")
@@ -401,7 +518,7 @@ class AlertServiceTest {
     }
 
     @Test
-    void exportPrometheusRulesYamlShouldReplaceNonFiniteThresholds() throws Exception {
+    void exportPrometheusRulesYamlShouldReplaceNonFiniteThresholdsTest() throws Exception {
         AlertRuleVO nanRule = AlertRuleVO.builder()
                 .name("Bad Threshold A")
                 .metric("rocketmq_consumer_lag_messages")
@@ -427,7 +544,7 @@ class AlertServiceTest {
     }
 
     @Test
-    void exportPrometheusRulesYamlShouldExcludeDisabledRules() {
+    void exportPrometheusRulesYamlShouldExcludeDisabledRulesTest() {
         AlertRuleVO enabled = AlertRuleVO.builder()
                 .name("Enabled Lag Alert")
                 .metric("rocketmq_consumer_lag_messages")
@@ -453,7 +570,7 @@ class AlertServiceTest {
     }
 
     @Test
-    void exportPrometheusRulesYamlShouldUseDefaultRulesWhenAllConfiguredRulesAreDisabled() {
+    void exportPrometheusRulesYamlShouldUseDefaultRulesWhenAllConfiguredRulesAreDisabledTest() {
         AlertRuleVO disabled = AlertRuleVO.builder()
                 .name("Disabled Lag Alert")
                 .metric("rocketmq_consumer_lag_messages")
@@ -471,7 +588,7 @@ class AlertServiceTest {
     }
 
     @Test
-    void createRuleShouldPreserveReplicationScopeFields() {
+    void createRuleShouldPreserveReplicationScopeFieldsTest() {
         AlertRuleVO input = AlertRuleVO.builder()
                 .name("Replication Lag High")
                 .metric("rocketmq_broker_replication_lag_bytes")
@@ -482,7 +599,7 @@ class AlertServiceTest {
                 .clusterName("DefaultCluster")
                 .severity("critical")
                 .build();
-        when(alertRepository.saveRule(any(AlertRuleVO.class))).thenAnswer(invocation -> {
+        when(alertRepository.insertRule(any(AlertRuleVO.class))).thenAnswer(invocation -> {
             AlertRuleVO saved = invocation.getArgument(0);
             if (saved.getId() == null) {
                 saved.setId(1L);
@@ -496,16 +613,16 @@ class AlertServiceTest {
         assertThat(result.getBrokerName()).isEqualTo("broker-a");
         assertThat(result.getClusterName()).isEqualTo("DefaultCluster");
         assertThat(result.getSeverity()).isEqualTo("critical");
-        verify(alertRepository).saveRule(result);
+        verify(alertRepository).insertRule(result);
         verify(operationAuditService).record(eq("CREATE_ALERT_RULE"), eq("ALERT_RULE"), eq("1"),
                 eq(null), eq("name=Replication Lag High"), eq("SUCCESS"), eq(null));
     }
 
     @Test
-    void createRuleShouldAssignId() {
+    void createRuleShouldAssignIdTest() {
         AlertRuleVO input = AlertRuleVO.builder().name("New Rule").metric("tps")
                 .operator(">").threshold(1000.0).build();
-        when(alertRepository.saveRule(any(AlertRuleVO.class))).thenAnswer(invocation -> {
+        when(alertRepository.insertRule(any(AlertRuleVO.class))).thenAnswer(invocation -> {
             AlertRuleVO saved = invocation.getArgument(0);
             if (saved.getId() == null) {
                 saved.setId(1L);
@@ -518,15 +635,15 @@ class AlertServiceTest {
         assertThat(result.getId()).isNotNull();
         assertThat(result.getName()).isEqualTo("New Rule");
         assertThat(result.getMetric()).isEqualTo("tps");
-        verify(alertRepository).saveRule(result);
+        verify(alertRepository).insertRule(result);
     }
 
     @Test
-    void createRuleShouldGenerateUniqueIds() {
+    void createRuleShouldGenerateUniqueIdsTest() {
         AlertRuleVO input1 = AlertRuleVO.builder().name("Rule 1").build();
         AlertRuleVO input2 = AlertRuleVO.builder().name("Rule 2").build();
         java.util.concurrent.atomic.AtomicLong sequence = new java.util.concurrent.atomic.AtomicLong();
-        when(alertRepository.saveRule(any(AlertRuleVO.class))).thenAnswer(invocation -> {
+        when(alertRepository.insertRule(any(AlertRuleVO.class))).thenAnswer(invocation -> {
             AlertRuleVO saved = invocation.getArgument(0);
             if (saved.getId() == null) {
                 saved.setId(sequence.incrementAndGet());
@@ -541,17 +658,30 @@ class AlertServiceTest {
     }
 
     @Test
-    void createRuleShouldRejectNullRequest() {
+    void createRuleShouldRejectNullRequestTest() {
         assertThatThrownBy(() -> alertService.createRule(null))
                 .isInstanceOf(BusinessException.class)
                 .hasMessage("Alert rule request is required")
                 .satisfies(ex -> assertThat(((BusinessException) ex).getCode()).isEqualTo(400));
 
-        verify(alertRepository, never()).saveRule(any());
+        verify(alertRepository, never()).insertRule(any());
     }
 
     @Test
-    void createRuleShouldRejectBlankName() {
+    void createRuleShouldRejectProvidedIdBeforePersistenceTest() {
+        AlertRuleVO input = AlertRuleVO.builder().id(7L).name("Existing rule").metric("tps").build();
+
+        assertThatThrownBy(() -> alertService.createRule(input))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("Alert rule ID must not be provided when creating a rule")
+                .satisfies(ex -> assertThat(((BusinessException) ex).getCode()).isEqualTo(400));
+
+        verify(alertRepository, never()).insertRule(any());
+        verify(alertRepository, never()).replaceRule(any());
+    }
+
+    @Test
+    void createRuleShouldRejectBlankNameTest() {
         AlertRuleVO input = AlertRuleVO.builder().name(" ").metric("tps").build();
 
         assertThatThrownBy(() -> alertService.createRule(input))
@@ -559,13 +689,13 @@ class AlertServiceTest {
                 .hasMessage("Alert rule name is required")
                 .satisfies(ex -> assertThat(((BusinessException) ex).getCode()).isEqualTo(400));
 
-        verify(alertRepository, never()).saveRule(any());
+        verify(alertRepository, never()).insertRule(any());
         verify(operationAuditService, never()).record(anyString(), anyString(), anyString(),
                 any(), any(), anyString(), any());
     }
 
     @Test
-    void updateRuleShouldRejectBlankNameBeforeRepositoryUpdate() {
+    void updateRuleShouldRejectBlankNameBeforeRepositoryUpdateTest() {
         AlertRuleVO update = AlertRuleVO.builder().id(1L).name(" ").metric("tps").build();
 
         assertThatThrownBy(() -> alertService.updateRule(update))
@@ -579,20 +709,91 @@ class AlertServiceTest {
     }
 
     @Test
-    void createRuleShouldNormalizeNameBeforeSavingAndAuditing() {
+    void createRuleShouldNormalizeNameBeforeSavingAndAuditingTest() {
         AlertRuleVO input = AlertRuleVO.builder().name(" CPU Alert ").metric("tps").build();
-        when(alertRepository.saveRule(any(AlertRuleVO.class))).thenAnswer(invocation -> invocation.getArgument(0));
+        when(alertRepository.insertRule(any(AlertRuleVO.class))).thenAnswer(invocation -> invocation.getArgument(0));
 
         AlertRuleVO result = alertService.createRule(input);
 
         assertThat(result.getName()).isEqualTo("CPU Alert");
-        verify(alertRepository).saveRule(argThat(rule -> "CPU Alert".equals(rule.getName())));
+        verify(alertRepository).insertRule(argThat(rule -> "CPU Alert".equals(rule.getName())));
         verify(operationAuditService).record(eq("CREATE_ALERT_RULE"), eq("ALERT_RULE"), anyString(),
                 eq(null), eq("name=CPU Alert"), eq("SUCCESS"), eq(null));
     }
 
     @Test
-    void updateRuleShouldNormalizeNameBeforeReplacingAndAuditing() {
+    void createRuleShouldRejectDuplicateEvaluationConditionsTest() {
+        AlertRuleVO existing = AlertRuleVO.builder()
+                .id(1L)
+                .domain(AlertDomain.BUSINESS)
+                .name("Existing rule")
+                .metric("consumer_lag")
+                .operator(">")
+                .threshold(1000)
+                .duration("5m")
+                .aggregation("LAST")
+                .windowSeconds(0)
+                .consumerGroup("group-a")
+                .enabled(true)
+                .build();
+        AlertRuleVO duplicate = AlertRuleVO.builder()
+                .domain(AlertDomain.BUSINESS)
+                .name("Another label")
+                .metric(" consumer_lag ")
+                .operator(" > ")
+                .threshold(1000.0)
+                .duration("5M")
+                .consumerGroup(" group-a ")
+                .channels(List.of("email"))
+                .severity("critical")
+                .enabled(false)
+                .build();
+        when(alertRepository.findAllRules()).thenReturn(List.of(existing));
+
+        assertThatThrownBy(() -> alertService.createRule(duplicate))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("An alert rule with the same evaluation conditions already exists")
+                .satisfies(error -> assertThat(((BusinessException) error).getCode()).isEqualTo(409));
+
+        verify(alertRepository, never()).insertRule(any());
+    }
+
+    @Test
+    void createRuleShouldAllowRulesWithDifferentEvaluationScopeTest() {
+        AlertRuleVO existing = AlertRuleVO.builder()
+                .id(1L).domain(AlertDomain.BUSINESS).name("Group A").metric("consumer_lag")
+                .operator(">=").threshold(1000).duration("5m").consumerGroup("group-a").build();
+        AlertRuleVO distinct = AlertRuleVO.builder()
+                .domain(AlertDomain.BUSINESS).name("Group B").metric("consumer_lag")
+                .operator(">=").threshold(1000).duration("5m").consumerGroup("group-b").build();
+        when(alertRepository.findAllRules()).thenReturn(List.of(existing));
+        when(alertRepository.insertRule(any(AlertRuleVO.class))).thenAnswer(invocation -> invocation.getArgument(0));
+
+        alertService.createRule(distinct);
+
+        verify(alertRepository).insertRule(distinct);
+    }
+
+    @Test
+    void updateRuleShouldRejectAnotherRulesEvaluationConditionsTest() {
+        AlertRuleVO existing = AlertRuleVO.builder()
+                .id(1L).domain(AlertDomain.BUSINESS).name("Existing").metric("consumer_lag")
+                .operator(">=").threshold(1000).duration("5m").consumerGroup("group-a").build();
+        AlertRuleVO update = AlertRuleVO.builder()
+                .id(2L).domain(AlertDomain.BUSINESS).name("Updated").metric("consumer_lag")
+                .operator(">=").threshold(1000).duration("5m").consumerGroup("group-a").build();
+        when(alertRepository.findAllRules()).thenReturn(List.of(existing, update));
+
+        assertThatThrownBy(() -> alertService.updateRule(update))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("An alert rule with the same evaluation conditions already exists")
+                .satisfies(error -> assertThat(((BusinessException) error).getCode()).isEqualTo(409));
+
+        verify(alertRepository, never()).replaceRule(any());
+    }
+
+    @Test
+    void updateRuleShouldNormalizeNameBeforeReplacingAndAuditingTest() {
         AlertRuleVO update = AlertRuleVO.builder()
                 .id(1L)
                 .name(" CPU Alert ")
@@ -609,7 +810,7 @@ class AlertServiceTest {
     }
 
     @Test
-    void updateRuleShouldUpdateExistingRule() {
+    void updateRuleShouldUpdateExistingRuleTest() {
         AlertRuleVO update = AlertRuleVO.builder().id(1L).name("CPU Alert").threshold(90.0).build();
         when(alertRepository.replaceRule(update)).thenReturn(true);
 
@@ -623,7 +824,7 @@ class AlertServiceTest {
     }
 
     @Test
-    void updateRuleShouldRejectNullRequest() {
+    void updateRuleShouldRejectNullRequestTest() {
         assertThatThrownBy(() -> alertService.updateRule(null))
                 .isInstanceOf(BusinessException.class)
                 .hasMessage("Alert rule request is required")
@@ -633,7 +834,7 @@ class AlertServiceTest {
     }
 
     @Test
-    void updateRuleShouldRejectNullId() {
+    void updateRuleShouldRejectNullIdTest() {
         AlertRuleVO update = AlertRuleVO.builder().name("CPU Alert").build();
 
         assertThatThrownBy(() -> alertService.updateRule(update))
@@ -644,7 +845,7 @@ class AlertServiceTest {
     }
 
     @Test
-    void updateRuleShouldRejectBlankId() {
+    void updateRuleShouldRejectBlankIdTest() {
         AlertRuleVO update = AlertRuleVO.builder().id(null).name("CPU Alert").build();
 
         assertThatThrownBy(() -> alertService.updateRule(update))
@@ -655,7 +856,7 @@ class AlertServiceTest {
     }
 
     @Test
-    void updateRuleShouldRejectUnknownId() {
+    void updateRuleShouldRejectUnknownIdTest() {
         AlertRuleVO update = AlertRuleVO.builder().id(999L).name("CPU Alert").build();
         when(alertRepository.replaceRule(update)).thenReturn(false);
 
@@ -664,28 +865,29 @@ class AlertServiceTest {
                 .hasMessage("Alert rule not found: 999")
                 .satisfies(ex -> assertThat(((BusinessException) ex).getCode()).isEqualTo(404));
         verify(alertRepository).replaceRule(update);
-        verify(alertRepository, never()).saveRule(any());
+        verify(alertRepository, never()).insertRule(any());
     }
 
     @Test
-    void toggleRuleShouldEnableRule() {
+    void toggleRuleShouldEnableRuleTest() {
         AlertRuleVO existing = AlertRuleVO.builder().id(1L).name("CPU Alert").enabled(false).build();
         when(alertRepository.findRuleById(1L)).thenReturn(Optional.of(existing));
-        when(alertRepository.saveRule(any(AlertRuleVO.class))).thenAnswer(invocation -> invocation.getArgument(0));
+        when(alertRepository.replaceRule(existing)).thenReturn(true);
 
         AlertRuleVO result = alertService.toggleRule(1L, true);
 
         assertThat(result.isEnabled()).isTrue();
-        verify(alertRepository).saveRule(result);
+        verify(alertRepository).replaceRule(result);
+        verify(alertRepository, never()).findAllRules();
         verify(operationAuditService).record(eq("TOGGLE_ALERT_RULE"), eq("ALERT_RULE"), eq("1"),
                 eq(null), eq("enabled=true"), eq("SUCCESS"), eq(null));
     }
 
     @Test
-    void toggleRuleShouldDisableRule() {
+    void toggleRuleShouldDisableRuleTest() {
         AlertRuleVO existing = AlertRuleVO.builder().id(1L).name("CPU Alert").enabled(true).build();
         when(alertRepository.findRuleById(1L)).thenReturn(Optional.of(existing));
-        when(alertRepository.saveRule(any(AlertRuleVO.class))).thenAnswer(invocation -> invocation.getArgument(0));
+        when(alertRepository.replaceRule(existing)).thenReturn(true);
 
         AlertRuleVO result = alertService.toggleRule(1L, false);
 
@@ -693,7 +895,7 @@ class AlertServiceTest {
     }
 
     @Test
-    void toggleRuleShouldRejectBlankIdBeforeLoadingRules() {
+    void toggleRuleShouldRejectBlankIdBeforeLoadingRulesTest() {
         assertThatThrownBy(() -> alertService.toggleRule(null, true))
                 .isInstanceOf(BusinessException.class)
                 .hasMessage("Alert rule ID is required")
@@ -722,7 +924,7 @@ class AlertServiceTest {
     }
 
     @Test
-    void deleteRuleShouldRejectBlankIdBeforeDeleting() {
+    void deleteRuleShouldRejectBlankIdBeforeDeletingTest() {
         assertThatThrownBy(() -> alertService.deleteRule(null))
                 .isInstanceOf(BusinessException.class)
                 .hasMessage("Alert rule ID is required")
@@ -732,7 +934,7 @@ class AlertServiceTest {
     }
 
     @Test
-    void deleteRuleShouldCallRepository() {
+    void deleteRuleShouldCallRepositoryTest() {
         when(alertRepository.deleteRule(1L)).thenReturn(true);
 
         alertService.deleteRule(1L);
@@ -743,7 +945,7 @@ class AlertServiceTest {
     }
 
     @Test
-    void deleteRuleShouldRejectUnknownRule() {
+    void deleteRuleShouldRejectUnknownRuleTest() {
         when(alertRepository.deleteRule(999L)).thenReturn(false);
 
         assertThatThrownBy(() -> alertService.deleteRule(999L))
@@ -752,7 +954,7 @@ class AlertServiceTest {
     }
 
     @Test
-    void bulkToggleShouldDeduplicateIdsAndReportMissingRules() {
+    void bulkToggleShouldDeduplicateIdsAndReportMissingRulesTest() {
         AlertRuleVO rule = AlertRuleVO.builder().id(1L).name("High CPU").enabled(false).build();
         when(alertRepository.findRulesByIds(List.of(1L, 999L))).thenReturn(List.of(rule));
         when(alertRepository.replaceRule(any(AlertRuleVO.class))).thenReturn(true);
@@ -765,10 +967,27 @@ class AlertServiceTest {
         assertThat(result.getUpdatedRules()).singleElement()
                 .extracting(AlertRuleVO::isEnabled).isEqualTo(true);
         verify(alertRepository).replaceRule(rule);
+        verify(alertRepository, never()).findAllRules();
+        verify(alertStateRepository).deleteByRuleId(1L);
     }
 
     @Test
-    void bulkToggleShouldReportRulesDeletedConcurrentlyInsteadOfRecreatingThem() {
+    void domainBulkOperationsShouldResetEachMutatedRuleStateTest() {
+        AlertRuleVO rule = AlertRuleVO.builder().id(1L).name("Broker unavailable")
+                .domain(AlertDomain.CLUSTER).enabled(false).build();
+        when(alertRepository.findRulesByIds(List.of(1L))).thenReturn(List.of(rule));
+        when(alertRepository.replaceRule(rule)).thenReturn(true);
+        when(alertRepository.deleteRule(1L)).thenReturn(true);
+
+        alertService.bulkToggleRules(AlertDomain.CLUSTER, List.of(1L), true);
+        alertService.bulkDeleteRules(AlertDomain.CLUSTER, List.of(1L));
+
+        verify(alertStateRepository, org.mockito.Mockito.times(2)).deleteByRuleId(1L);
+        verify(alertRepository, never()).findAllRules();
+    }
+
+    @Test
+    void bulkToggleShouldReportRulesDeletedConcurrentlyInsteadOfRecreatingThemTest() {
         AlertRuleVO rule = AlertRuleVO.builder().id(1L).name("High CPU").enabled(false).build();
         when(alertRepository.findRulesByIds(List.of(1L))).thenReturn(List.of(rule));
         when(alertRepository.replaceRule(any(AlertRuleVO.class))).thenReturn(false);
@@ -778,11 +997,11 @@ class AlertServiceTest {
         assertThat(result.getSucceededIds()).isEmpty();
         assertThat(result.getFailures()).containsEntry(1L, "Alert rule not found");
         assertThat(result.getUpdatedRules()).isEmpty();
-        verify(alertRepository, never()).saveRule(any(AlertRuleVO.class));
+        verify(alertRepository, never()).insertRule(any(AlertRuleVO.class));
     }
 
     @Test
-    void bulkDeleteShouldPreservePartialFailureDetails() {
+    void bulkDeleteShouldPreservePartialFailureDetailsTest() {
         when(alertRepository.deleteRule(1L)).thenReturn(true);
         when(alertRepository.deleteRule(999L)).thenReturn(false);
         when(alertRepository.deleteRule(2L))
@@ -796,10 +1015,11 @@ class AlertServiceTest {
                 .containsEntry(999L, "Alert rule not found")
                 .containsEntry(2L, "database unavailable");
         assertThat(result.getUpdatedRules()).isEmpty();
+        verify(alertStateRepository).deleteByRuleId(1L);
     }
 
     @Test
-    void listAlertsShouldReturnAlertsForLevel() {
+    void listAlertsShouldReturnAlertsForLevelTest() {
         SystemAlertVO alert1 = SystemAlertVO.builder().id(1L).level(AlertLevel.error)
                 .title("Broker Down").acknowledged(false).build();
         SystemAlertVO alert2 = SystemAlertVO.builder().id(2L).level(AlertLevel.error)
@@ -815,7 +1035,7 @@ class AlertServiceTest {
     }
 
     @Test
-    void listAlertsShouldReturnAllAlertsWhenLevelIsNull() {
+    void listAlertsShouldReturnAllAlertsWhenLevelIsNullTest() {
         SystemAlertVO alert = SystemAlertVO.builder().id(1L).level(AlertLevel.warning)
                 .title("Slow Consumer").build();
         when(alertRepository.findAlerts(null)).thenReturn(List.of(alert));
@@ -856,6 +1076,43 @@ class AlertServiceTest {
                 org.mockito.ArgumentMatchers.anyInt());
     }
 
+    @Test
+    void relatedAlertsShouldReturnOtherDomainFiringEventsInTheSameInstanceAndScopeTest() {
+        LocalDateTime eventTime = LocalDateTime.of(2026, 8, 23, 12, 0);
+        SystemAlertVO source = SystemAlertVO.builder().id(1L).domain(AlertDomain.BUSINESS)
+                .instanceId("local").time(eventTime).labels(Map.of("brokerName", "broker-a")).build();
+        SystemAlertVO related = SystemAlertVO.builder().id(2L).domain(AlertDomain.CLUSTER)
+                .instanceId("local").time(eventTime).transition("FIRING")
+                .labels(Map.of("brokerName", "broker-a")).build();
+        SystemAlertVO differentBroker = SystemAlertVO.builder().id(3L).domain(AlertDomain.CLUSTER)
+                .instanceId("local").time(eventTime).transition("FIRING")
+                .labels(Map.of("brokerName", "broker-b")).build();
+        when(alertRepository.findAlertById(1L)).thenReturn(Optional.of(source));
+        when(alertRepository.findAlertsPage(any(SystemAlertQuery.class)))
+                .thenReturn(PageResult.of(List.of(related, differentBroker), 2, 1, 100));
+
+        List<SystemAlertVO> result = alertService.findRelatedAlerts(1L);
+
+        assertThat(result).containsExactly(related);
+        verify(alertRepository).findAlertsPage(argThat(query -> query.domain() == AlertDomain.CLUSTER
+                && "local".equals(query.instanceId()) && "FIRING".equals(query.transition())
+                && eventTime.minusMinutes(30).equals(query.from()) && eventTime.plusMinutes(30).equals(query.to())));
+    }
+
+    @Test
+    void relatedAlertsShouldIncludeTheExplicitSuppressionCauseOutsideTheDisplayWindowTest() {
+        SystemAlertVO source = SystemAlertVO.builder().id(1L).domain(AlertDomain.BUSINESS)
+                .instanceId("local").suppressionCauseAlertId(12L)
+                .labels(Map.of("brokerName", "broker-a")).build();
+        SystemAlertVO cause = SystemAlertVO.builder().id(12L).domain(AlertDomain.CLUSTER)
+                .instanceId("local").transition("FIRING")
+                .labels(Map.of("brokerName", "broker-a")).build();
+        when(alertRepository.findAlertById(1L)).thenReturn(Optional.of(source));
+        when(alertRepository.findAlertById(12L)).thenReturn(Optional.of(cause));
+
+        assertThat(alertService.findRelatedAlerts(1L)).containsExactly(cause);
+    }
+
     @Test
     void acknowledgeAlertShouldSetAcknowledgedTrue() {
         SystemAlertVO existing = SystemAlertVO.builder().id(1L).level(AlertLevel.error)
@@ -866,13 +1123,41 @@ class AlertServiceTest {
         SystemAlertVO result = alertService.acknowledgeAlert(1L);
 
         assertThat(result.isAcknowledged()).isTrue();
+        assertThat(result.getAcknowledgedBy()).isEqualTo("system");
+        assertThat(result.getAcknowledgedAt()).isNotNull();
         verify(alertRepository).acknowledgeAlert(result);
         verify(operationAuditService).record(eq("ACKNOWLEDGE_SYSTEM_ALERT"), eq("SYSTEM_ALERT"), eq("1"),
                 eq(null), eq("acknowledged=true"), eq("SUCCESS"), eq(null));
     }
 
     @Test
-    void acknowledgeAlertShouldRejectBlankIdBeforeLoadingAlerts() {
+    void acknowledgeNativeAlertShouldAcknowledgeItsActiveRuleStateTest() {
+        SystemAlertVO alert = SystemAlertVO.builder().id(1L).ruleId(7L).fingerprint("fingerprint")
+                .time(LocalDateTime.of(2026, 8, 22, 12, 0))
+                .transition("FIRING").acknowledged(false).build();
+        when(alertRepository.findAlertById(1L)).thenReturn(Optional.of(alert));
+        when(alertRepository.acknowledgeAlert(any(SystemAlertVO.class))).thenReturn(true);
+
+        alertService.acknowledgeAlert(1L);
+
+        verify(alertStateRepository).acknowledge(new AlertStateKey(7L, "fingerprint"),
+                LocalDateTime.of(2026, 8, 22, 12, 0).toInstant(ZoneOffset.UTC));
+    }
+
+    @Test
+    void acknowledgingResolvedEventMustNotAcknowledgeANewerFiringStateTest() {
+        SystemAlertVO resolved = SystemAlertVO.builder().id(1L).ruleId(7L).fingerprint("fingerprint")
+                .transition("RESOLVED").acknowledged(false).build();
+        when(alertRepository.findAlertById(1L)).thenReturn(Optional.of(resolved));
+        when(alertRepository.acknowledgeAlert(any(SystemAlertVO.class))).thenReturn(true);
+
+        alertService.acknowledgeAlert(1L);
+
+        verify(alertStateRepository, never()).acknowledge(any(AlertStateKey.class), any());
+    }
+
+    @Test
+    void acknowledgeAlertShouldRejectBlankIdBeforeLoadingAlertsTest() {
         assertThatThrownBy(() -> alertService.acknowledgeAlert(null))
                 .isInstanceOf(BusinessException.class)
                 .hasMessage("System alert ID is required")
@@ -900,7 +1185,7 @@ class AlertServiceTest {
     }
 
     @Test
-    void acknowledgeAlertShouldRejectConcurrentRemoval() {
+    void acknowledgeAlertShouldRejectConcurrentRemovalTest() {
         SystemAlertVO existing = SystemAlertVO.builder().id(1L).level(AlertLevel.error)
                 .title("Broker Down").acknowledged(false).build();
         when(alertRepository.findAlertById(1L)).thenReturn(java.util.Optional.of(existing));
@@ -915,7 +1200,7 @@ class AlertServiceTest {
     }
 
     @Test
-    void clearAcknowledgedShouldReturnDeletedCount() {
+    void clearAcknowledgedShouldReturnDeletedCountTest() {
         when(alertRepository.deleteAcknowledgedAlerts()).thenReturn(3);
 
         int result = alertService.clearAcknowledged();
@@ -927,7 +1212,7 @@ class AlertServiceTest {
     }
 
     @Test
-    void clearAcknowledgedShouldReturnZeroWhenNoneAcknowledged() {
+    void clearAcknowledgedShouldReturnZeroWhenNoneAcknowledgedTest() {
         when(alertRepository.deleteAcknowledgedAlerts()).thenReturn(0);
 
         int result = alertService.clearAcknowledged();
diff --git a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceServiceTest.java b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceServiceTest.java
new file mode 100644
index 000000000..ae29d6c10
--- /dev/null
+++ b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertSilenceServiceTest.java
@@ -0,0 +1,119 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import org.apache.rocketmq.studio.audit.OperationAuditService;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class AlertSilenceServiceTest {
+    @Mock
+    private AlertSilenceRepository repository;
+    @Mock
+    private OperationAuditService operationAuditService;
+
+    @Test
+    void createsInstanceScopedSilenceAndMatchesOnlyItsScopeTest() {
+        AlertSilenceService service = new AlertSilenceService(repository, operationAuditService);
+        LocalDateTime start = LocalDateTime.of(2026, 8, 22, 9, 0);
+        LocalDateTime end = start.plusHours(1);
+        when(repository.save(any())).thenAnswer(invocation -> {
+            AlertSilenceVO silence = invocation.getArgument(0);
+            silence.setId(7L);
+            return silence;
+        });
... 8303 lines suppressed ...