This is an automated email from the ASF dual-hosted git repository.

tomsun28 pushed a commit to branch 2.0.0
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git

commit 9d53fd7be2b1ede4a3ed659c6d312c1c4ed4f24f
Author: tomsun28 <[email protected]>
AuthorDate: Fri Oct 9 13:21:49 2026 +0800

    fix(merge): port remaining master bugfixes and hardening lost in 
master->2.0.0 sync
    
    Backend:
    - AlarmGroupReduce: port #4316 retention semantics (firing members stay
      cached until emitted resolved; never emit a resolved group while any
      member still fires and #4361 (fall back to default repeat interval when
      the converge rule was deleted) — the snapshot-flush model reintroduced
      the resolved-flap bug
    - NoticeConfigController: restore POST /api/notice/template/preview using
      NoticeTemplateRenderer (#4338)
    - NoticeReceiver contract: add ntfy (type 15) and wpush (type 16) fields —
      the strict @JsonAnySetter was 400-rejecting payloads carrying them even
      though both notify handlers still exist
    - SmsLocalSmsClientImpl: use SmsAlertFields (NPE-safe content fallback)
    
    CI/CD + packaging:
    - nightly-build + hybrid-collector-release: pin docker/graalvm actions to
      the same ASF-allowlisted SHAs used elsewhere (master #4356/#4193)
    - docker-compose files: re-apply localhost port binding (#4258), greptime
      v1.1.3 pin (#4244), 14317 OTLP convention; keep apache/hertzbeat:2.0.0
    - script/docker/collector/Dockerfile: restore mkdir -p /var/run/sshd (#4143)
    - application-test.yml: drop stale unisms block (UniSMS removed)
    
    Tests (ported/adapted from master):
    - AlarmGroupReduceTest: 3 convergence regressions (#4316/#4361)
    - NoticeConfigControllerTest: template preview ok/invalid
    - ChatClientProviderServiceImplTest: config-change cache refresh +
      security-context tool callback wrapping (#4230)
    - GreptimeSqlQueryExecutorTest: form-urlencoded request body (#4223)
    - AlertDefineJsonImExportServiceTest: queryLanguage round-trip (#4256)
    EOF
    )
---
 .github/workflows/hybrid-collector-release.yml     |  14 +--
 .github/workflows/nightly-build.yml                |  10 +-
 .../impl/ChatClientProviderServiceImplTest.java    |  95 +++++++++++++++
 .../alert/controller/NoticeConfigController.java   |  20 ++++
 .../hertzbeat/alert/dto/NoticeReceiverOptions.java |   8 ++
 .../hertzbeat/alert/dto/NoticeReceiverRequest.java |   6 +
 .../hertzbeat/alert/reduce/AlarmGroupReduce.java   |  23 +++-
 .../service/NoticeReceiverContractMapper.java      |  30 ++++-
 .../alert/service/impl/SmsLocalSmsClientImpl.java  |   7 +-
 .../controller/NoticeConfigControllerTest.java     |  41 +++++++
 .../alert/reduce/AlarmGroupReduceTest.java         | 132 +++++++++++++++++----
 .../AlertDefineJsonImExportServiceTest.java        |   3 +
 .../src/main/resources/application-test.yml        |   8 --
 .../warehouse/db/GreptimeSqlQueryExecutorTest.java |  33 ++++++
 .../hertzbeat-mysql-iotdb/docker-compose.yaml      |  14 ++-
 .../hertzbeat-mysql-tdengine/docker-compose.yaml   |  12 +-
 .../docker-compose.yaml                            |  12 +-
 .../docker-compose.yaml                            |  29 +++--
 .../docker-compose.yaml                            |  18 ++-
 .../docker-compose.yaml                            |  18 ++-
 script/docker/collector/Dockerfile                 |   2 +-
 21 files changed, 448 insertions(+), 87 deletions(-)

diff --git a/.github/workflows/hybrid-collector-release.yml 
b/.github/workflows/hybrid-collector-release.yml
index dd456138a0..a455481ae4 100644
--- a/.github/workflows/hybrid-collector-release.yml
+++ b/.github/workflows/hybrid-collector-release.yml
@@ -56,7 +56,7 @@ jobs:
         with:
           go-version-file: hertzbeat-otel-runtime/go.mod
           cache-dependency-path: hertzbeat-otel-runtime/go.mod
-      - uses: graalvm/setup-graalvm@v1
+      - uses: graalvm/setup-graalvm@186d0493a2df5eb62df5ecc498883d18fd58c303  
# v1.6.2
         with:
           java-version: 25
           distribution: graalvm
@@ -103,7 +103,7 @@ jobs:
     timeout-minutes: 30
     steps:
       - uses: actions/checkout@v4
-      - uses: graalvm/setup-graalvm@v1
+      - uses: graalvm/setup-graalvm@186d0493a2df5eb62df5ecc498883d18fd58c303  
# v1.6.2
         with:
           java-version: 25
           distribution: graalvm
@@ -140,7 +140,7 @@ jobs:
         with:
           go-version-file: hertzbeat-otel-runtime/go.mod
           cache-dependency-path: hertzbeat-otel-runtime/go.mod
-      - uses: graalvm/setup-graalvm@v1
+      - uses: graalvm/setup-graalvm@186d0493a2df5eb62df5ecc498883d18fd58c303  
# v1.6.2
         with:
           java-version: 25
           distribution: graalvm
@@ -182,10 +182,10 @@ jobs:
           merge-multiple: true
       - name: Prepare verified native container inputs
         run: ./script/ci/prepare-hybrid-collector-native-container-context.sh 
dist target/native-container-context
-      - uses: docker/setup-qemu-action@v3
-      - uses: docker/setup-buildx-action@v3
+      - uses: 
docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8  # v4.2.0
+      - uses: 
docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e  # v4.3.0
       - name: Build runnable amd64 native image
-        uses: docker/build-push-action@v6
+        uses: 
docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a  # v7.3.0
         with:
           context: target/native-container-context
           file: script/docker/collector/Dockerfile.native
@@ -195,7 +195,7 @@ jobs:
       - name: Verify native image runtime
         run: ./script/ci/verify-hybrid-collector-native-image.sh 
apache/hertzbeat-collector:native-test linux/amd64
       - name: Build multi-platform native image
-        uses: docker/build-push-action@v6
+        uses: 
docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a  # v7.3.0
         with:
           context: target/native-container-context
           file: script/docker/collector/Dockerfile.native
diff --git a/.github/workflows/nightly-build.yml 
b/.github/workflows/nightly-build.yml
index 16d6356b27..a471a9812b 100644
--- a/.github/workflows/nightly-build.yml
+++ b/.github/workflows/nightly-build.yml
@@ -85,16 +85,16 @@ jobs:
         fi
         ./script/ci/verify-hybrid-collector-jvm-package.sh "$1" linux-arm64
 
-    - uses: docker/setup-qemu-action@v3
-    - uses: docker/setup-buildx-action@v3
+    - uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8  
# v4.2.0
+    - uses: 
docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e  # v4.3.0
     - name: Log in to Docker Hub
-      uses: docker/login-action@v3
+      uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0  # 
v4.4.0
       with:
         username: ${{ secrets.DOCKERHUB_USER }}
         password: ${{ secrets.DOCKERHUB_TOKEN }}
         
     - name: Build and Push Server
-      uses: docker/build-push-action@v6
+      uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a  
# v7.3.0
       with:
         context: ./dist
         file: ./script/docker/server/Dockerfile
@@ -103,7 +103,7 @@ jobs:
         tags: apache/hertzbeat:nightly
         
     - name: Build Collector image for verification
-      uses: docker/build-push-action@v6
+      uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a  
# v7.3.0
       with:
         context: ./dist
         file: ./script/docker/collector/Dockerfile
diff --git 
a/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/service/impl/ChatClientProviderServiceImplTest.java
 
b/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/service/impl/ChatClientProviderServiceImplTest.java
index 6e2b80807b..c416cb4da3 100644
--- 
a/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/service/impl/ChatClientProviderServiceImplTest.java
+++ 
b/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/service/impl/ChatClientProviderServiceImplTest.java
@@ -18,16 +18,40 @@
 package org.apache.hertzbeat.ai.service.impl;
 
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
+import com.usthe.sureness.subject.SubjectSum;
+import java.lang.reflect.Proxy;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.hertzbeat.ai.config.McpContextHolder;
+import org.apache.hertzbeat.ai.config.SecurityContextToolCallback;
+import org.apache.hertzbeat.ai.pojo.dto.ChatRequestContext;
 import org.apache.hertzbeat.ai.sop.registry.SkillRegistry;
 import org.apache.hertzbeat.base.dao.GeneralConfigDao;
 import org.apache.hertzbeat.common.entity.dto.ModelProviderConfig;
 import org.apache.hertzbeat.common.entity.manager.GeneralConfig;
+import org.apache.hertzbeat.common.support.event.AiProviderConfigChangeEvent;
 import org.apache.hertzbeat.common.util.JsonUtil;
 import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.ai.chat.client.ChatClient;
+import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.ToolCallbackProvider;
+import org.springframework.context.support.StaticApplicationContext;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.test.util.ReflectionTestUtils;
+import reactor.core.publisher.Flux;
 import org.springframework.context.ApplicationContext;
 
 class ChatClientProviderServiceImplTest {
@@ -75,6 +99,77 @@ class ChatClientProviderServiceImplTest {
         assertTrue(service.isConfigured());
     }
 
+    @Test
+    void configurationChangeShouldRefreshConfiguredState() {
+        AtomicReference<GeneralConfig> currentConfig = new AtomicReference<>();
+        GeneralConfigDao configDao = proxyConfigDao(currentConfig);
+        ChatClientProviderServiceImpl service = new 
ChatClientProviderServiceImpl(null, configDao, null);
+
+        assertFalse(service.isConfigured());
+
+        currentConfig.set(providerConfig("sk-test"));
+        service.onAiProviderConfigChange(changeEvent());
+        assertTrue(service.isConfigured());
+
+        currentConfig.set(providerConfig(" "));
+        service.onAiProviderConfigChange(changeEvent());
+        assertFalse(service.isConfigured());
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    void streamChatShouldAttachSubjectAndWrapEveryToolCallback() {
+        ApplicationContext applicationContext = mock(ApplicationContext.class);
+        SkillRegistry skillRegistry = mock(SkillRegistry.class);
+        ChatClient chatClient = mock(ChatClient.class);
+        ChatClient.ChatClientRequestSpec requestSpec = 
mock(ChatClient.ChatClientRequestSpec.class);
+        ChatClient.StreamResponseSpec streamSpec = 
mock(ChatClient.StreamResponseSpec.class);
+        ToolCallback delegate = mock(ToolCallback.class);
+        SubjectSum subject = mock(SubjectSum.class);
+        ChatClientProviderServiceImpl service = new 
ChatClientProviderServiceImpl(
+                applicationContext, proxyConfigDao(new AtomicReference<>()), 
skillRegistry);
+
+        when(applicationContext.getBean("openAiChatClient", 
ChatClient.class)).thenReturn(chatClient);
+        when(chatClient.prompt()).thenReturn(requestSpec);
+        when(requestSpec.messages(anyList())).thenReturn(requestSpec);
+        when(requestSpec.system(anyString())).thenReturn(requestSpec);
+        when(requestSpec.tools(any(Object[].class))).thenReturn(requestSpec);
+        when(requestSpec.toolContext(anyMap())).thenReturn(requestSpec);
+        when(requestSpec.stream()).thenReturn(streamSpec);
+        when(streamSpec.content()).thenReturn(Flux.just("answer"));
+        when(skillRegistry.getAllSkills()).thenReturn(java.util.List.of());
+        ReflectionTestUtils.setField(service, "systemResource", new 
ByteArrayResource(
+                "skills={dynamically_injected_skills_list}; 
conversation={current_conversation_id}"
+                        .getBytes(StandardCharsets.UTF_8)));
+        ReflectionTestUtils.setField(service, "toolCallbackProvider", 
ToolCallbackProvider.from(delegate));
+
+        ChatRequestContext context = ChatRequestContext.builder()
+                .message("question")
+                .conversationId(42L)
+                .subject(subject)
+                .build();
+        service.streamChat(context).collectList().block();
+
+        ArgumentCaptor<Object[]> callbacksCaptor = 
ArgumentCaptor.forClass(Object[].class);
+        ArgumentCaptor<Map<String, Object>> contextCaptor = 
ArgumentCaptor.forClass(Map.class);
+        verify(requestSpec).tools(callbacksCaptor.capture());
+        verify(requestSpec).toolContext(contextCaptor.capture());
+        assertInstanceOf(SecurityContextToolCallback.class, 
callbacksCaptor.getValue()[0]);
+        assertSame(subject, McpContextHolder.getSubject(new 
org.springframework.ai.chat.model.ToolContext(
+                contextCaptor.getValue())));
+    }
+
+    private static GeneralConfigDao 
proxyConfigDao(AtomicReference<GeneralConfig> currentConfig) {
+        return (GeneralConfigDao) Proxy.newProxyInstance(
+                GeneralConfigDao.class.getClassLoader(),
+                new Class<?>[]{GeneralConfigDao.class},
+                (proxy, method, args) -> "findByType".equals(method.getName()) 
? currentConfig.get() : null);
+    }
+
+    private static AiProviderConfigChangeEvent changeEvent() {
+        return new AiProviderConfigChangeEvent(new StaticApplicationContext());
+    }
+
     private static ChatClientProviderServiceImpl newService(GeneralConfigDao 
generalConfigDao) {
         return new ChatClientProviderServiceImpl(
                 mock(ApplicationContext.class),
diff --git 
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/controller/NoticeConfigController.java
 
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/controller/NoticeConfigController.java
index f856752f1e..1a579a2a43 100644
--- 
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/controller/NoticeConfigController.java
+++ 
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/controller/NoticeConfigController.java
@@ -26,16 +26,19 @@ import jakarta.validation.Valid;
 import java.util.List;
 import java.util.Optional;
 import lombok.extern.slf4j.Slf4j;
+import org.apache.hertzbeat.alert.AlerterProperties;
 import org.apache.hertzbeat.alert.dto.NoticeReceiverMutationResponse;
 import org.apache.hertzbeat.alert.dto.NoticeReceiverOptionResponse;
 import org.apache.hertzbeat.alert.dto.NoticeReceiverRequest;
 import org.apache.hertzbeat.alert.dto.NoticeReceiverResponse;
+import org.apache.hertzbeat.alert.notice.NoticeTemplateRenderer;
 import org.apache.hertzbeat.alert.service.NoticeReceiverContractService;
 import org.apache.hertzbeat.alert.service.NoticeTemplateMutationException;
 import org.apache.hertzbeat.common.entity.dto.Message;
 import org.apache.hertzbeat.common.entity.alerter.NoticeRule;
 import org.apache.hertzbeat.common.entity.alerter.NoticeTemplate;
 import org.apache.hertzbeat.alert.service.NoticeConfigService;
+import org.apache.hertzbeat.common.util.ResourceBundleUtil;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.dao.DataAccessException;
 import org.springframework.data.domain.Page;
@@ -71,6 +74,9 @@ public class NoticeConfigController {
     @Autowired
     private NoticeReceiverContractService noticeReceiverService;
 
+    @Autowired
+    private AlerterProperties alerterProperties;
+
     @PostMapping(path = "/receiver")
     @Operation(summary = "Add a recipient", description = "Add a recipient")
     public ResponseEntity<Message<NoticeReceiverMutationResponse>> 
addNewNoticeReceiver(
@@ -317,6 +323,20 @@ public class NoticeConfigController {
         }
     }
 
+    @PostMapping(path = "/template/preview")
+    @Operation(summary = "Preview how a notice template renders against a 
sample alert",
+            description = "Preview how a notice template renders against a 
sample alert, without sending anything")
+    public ResponseEntity<Message<String>> previewNoticeTemplate(@Valid 
@RequestBody NoticeTemplate noticeTemplate) {
+        try {
+            String rendered = NoticeTemplateRenderer.renderContent(
+                    noticeTemplate, NoticeTemplateRenderer.sampleGroupAlert(), 
alerterProperties.getConsoleUrl(),
+                    ResourceBundleUtil.getBundle("alerter"));
+            return ResponseEntity.ok(Message.successWithData(rendered));
+        } catch (Exception e) {
+            return ResponseEntity.ok(Message.fail(FAIL_CODE, "Failed to render 
template: " + e.getMessage()));
+        }
+    }
+
     private ResponseEntity<Message<Void>> 
templateMutationFailure(NoticeTemplateMutationException exception) {
         String message = switch (exception.getReason()) {
             case INVALID_REQUEST -> NOTICE_TEMPLATE_INVALID_MESSAGE;
diff --git 
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/dto/NoticeReceiverOptions.java
 
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/dto/NoticeReceiverOptions.java
index de45533fe0..7d867069df 100644
--- 
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/dto/NoticeReceiverOptions.java
+++ 
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/dto/NoticeReceiverOptions.java
@@ -69,5 +69,13 @@ public class NoticeReceiverOptions {
     private String serverChanToken;
     @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
     private String gotifyToken;
+    private String ntfyServerUrl;
+    private String ntfyTopic;
+    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
+    private String ntfyToken;
+    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
+    private String wpushToken;
+    private String wpushChannel;
+    private String wpushTopicCode;
     private Set<String> clearSecrets = new LinkedHashSet<>();
 }
diff --git 
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/dto/NoticeReceiverRequest.java
 
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/dto/NoticeReceiverRequest.java
index 564a483e3e..4b4bd85b18 100644
--- 
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/dto/NoticeReceiverRequest.java
+++ 
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/dto/NoticeReceiverRequest.java
@@ -73,6 +73,12 @@ public class NoticeReceiverRequest {
             case "smnTopicUrn" -> options.setSmnTopicUrn(asString(value));
             case "serverChanToken" -> 
options.setServerChanToken(asString(value));
             case "gotifyToken" -> options.setGotifyToken(asString(value));
+            case "ntfyServerUrl" -> options.setNtfyServerUrl(asString(value));
+            case "ntfyTopic" -> options.setNtfyTopic(asString(value));
+            case "ntfyToken" -> options.setNtfyToken(asString(value));
+            case "wpushToken" -> options.setWpushToken(asString(value));
+            case "wpushChannel" -> options.setWpushChannel(asString(value));
+            case "wpushTopicCode" -> 
options.setWpushTopicCode(asString(value));
             case "clearSecrets" -> options.setClearSecrets(asStringSet(value));
             case "creator", "modifier", "gmtCreate", "gmtUpdate" -> { }
             default -> throw new IllegalArgumentException("Unsupported 
receiver field: " + name);
diff --git 
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/reduce/AlarmGroupReduce.java
 
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/reduce/AlarmGroupReduce.java
index 4db5112dc3..744be7fb95 100644
--- 
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/reduce/AlarmGroupReduce.java
+++ 
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/reduce/AlarmGroupReduce.java
@@ -336,14 +336,21 @@ public class AlarmGroupReduce implements DisposableBean {
 
             long now = System.currentTimeMillis();
             String status = determineGroupStatus(snapshot.values());
+            boolean hasResolvedAlert = snapshot.values().stream()
+                    .anyMatch(alert -> 
CommonConstants.ALERT_STATUS_RESOLVED.equals(alert.getStatus()));
 
             // For firing alerts, check repeat interval without consuming the 
retained snapshot.
             if (CommonConstants.ALERT_STATUS_FIRING.equals(status)) {
                 AlertGroupConverge ruleConfig = 
groupDefines.get(cache.getGroupDefineName());
-                long repeatInterval = ruleConfig.getRepeatInterval() != null
+                // The rule may have been deleted, renamed or disabled while 
this group cache
+                // still holds firing alerts; fall back to the default 
interval like shouldSendGroup.
+                long repeatInterval = ruleConfig != null && 
ruleConfig.getRepeatInterval() != null
                         ? ruleConfig.getRepeatInterval() * MS_PER_SECOND : 
DEFAULT_REPEAT_INTERVAL;
 
-                if (cache.getLastRepeatTime() > 0
+                // The throttle only suppresses repeated firing notifications; 
it must never
+                // swallow a pending resolved transition.
+                if (!hasResolvedAlert
+                        && cache.getLastRepeatTime() > 0
                         && now - cache.getLastRepeatTime() < repeatInterval) {
                     return;
                 }
@@ -362,8 +369,16 @@ public class AlarmGroupReduce implements DisposableBean {
             if (!alarmInhibitReduce.inhibitAlarm(groupAlert)) {
                 return;
             }
-            snapshot.forEach((fingerprint, alert) ->
-                    cache.getAlertFingerprints().remove(fingerprint, alert));
+            // The resolved members have now been emitted, so drop them from 
the group. Firing
+            // members are retained until they recover, keeping the group 
firing while any member
+            // is still active instead of flushing the whole cache after every 
send.
+            if (hasResolvedAlert) {
+                snapshot.forEach((fingerprint, alert) -> {
+                    if 
(CommonConstants.ALERT_STATUS_RESOLVED.equals(alert.getStatus())) {
+                        cache.getAlertFingerprints().remove(fingerprint, 
alert);
+                    }
+                });
+            }
             cache.setLastSendTime(now);
             if (CommonConstants.ALERT_STATUS_FIRING.equals(status)) {
                 cache.setLastRepeatTime(now);
diff --git 
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/NoticeReceiverContractMapper.java
 
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/NoticeReceiverContractMapper.java
index db57576c97..8360f160b1 100644
--- 
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/NoticeReceiverContractMapper.java
+++ 
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/NoticeReceiverContractMapper.java
@@ -33,7 +33,8 @@ public class NoticeReceiverContractMapper {
 
     private static final Set<String> SECRET_FIELDS = Set.of(
             "hookUrl", "hookAuthToken", "wechatId", "accessToken", 
"tgBotToken", "slackWebHookUrl",
-            "appSecret", "discordBotToken", "smnAk", "smnSk", 
"serverChanToken", "gotifyToken");
+            "appSecret", "discordBotToken", "smnAk", "smnSk", 
"serverChanToken", "gotifyToken",
+            "ntfyToken", "wpushToken");
     private static final Map<Byte, String> TYPE_KEYS = Map.ofEntries(
             Map.entry((byte) 0, "sms"), Map.entry((byte) 1, "email"), 
Map.entry((byte) 2, "webhook"),
             Map.entry((byte) 3, "wechat-official"), Map.entry((byte) 4, 
"wecom-robot"),
@@ -41,7 +42,8 @@ public class NoticeReceiverContractMapper {
             Map.entry((byte) 7, "telegram-bot"), Map.entry((byte) 8, 
"slack-webhook"),
             Map.entry((byte) 9, "discord-bot"), Map.entry((byte) 10, 
"wecom-app"),
             Map.entry((byte) 11, "huawei-smn"), Map.entry((byte) 12, 
"server-chan"),
-            Map.entry((byte) 13, "gotify"), Map.entry((byte) 14, 
"feishu-app"));
+            Map.entry((byte) 13, "gotify"), Map.entry((byte) 14, "feishu-app"),
+            Map.entry((byte) 15, "ntfy"), Map.entry((byte) 16, "wpush"));
     private static final Map<Byte, Set<String>> ALLOWED_FIELDS = Map.ofEntries(
             Map.entry((byte) 0, Set.of("phone")),
             Map.entry((byte) 1, Set.of("email")),
@@ -57,7 +59,9 @@ public class NoticeReceiverContractMapper {
             Map.entry((byte) 11, Set.of("smnAk", "smnSk", "smnProjectId", 
"smnRegion", "smnTopicUrn")),
             Map.entry((byte) 12, Set.of("serverChanToken")),
             Map.entry((byte) 13, Set.of("gotifyToken")),
-            Map.entry((byte) 14, Set.of("appId", "appSecret", 
"larkReceiveType", "userId", "chatId", "partyId")));
+            Map.entry((byte) 14, Set.of("appId", "appSecret", 
"larkReceiveType", "userId", "chatId", "partyId")),
+            Map.entry((byte) 15, Set.of("ntfyServerUrl", "ntfyTopic", 
"ntfyToken")),
+            Map.entry((byte) 16, Set.of("wpushToken", "wpushChannel", 
"wpushTopicCode")));
 
     public NoticeReceiver toEntity(NoticeReceiverRequest request, 
NoticeReceiver existing) {
         Byte type = request.getType();
@@ -106,6 +110,10 @@ public class NoticeReceiverContractMapper {
         options.setSmnProjectId(receiver.getSmnProjectId());
         options.setSmnRegion(receiver.getSmnRegion());
         options.setSmnTopicUrn(receiver.getSmnTopicUrn());
+        options.setNtfyServerUrl(receiver.getNtfyServerUrl());
+        options.setNtfyTopic(receiver.getNtfyTopic());
+        options.setWpushChannel(receiver.getWpushChannel());
+        options.setWpushTopicCode(receiver.getWpushTopicCode());
         options.setClearSecrets(null);
         Set<String> configuredSecrets = configuredSecrets(receiver);
         return new NoticeReceiverResponse(
@@ -132,6 +140,10 @@ public class NoticeReceiverContractMapper {
         target.setSmnProjectId(source.getSmnProjectId());
         target.setSmnRegion(source.getSmnRegion());
         target.setSmnTopicUrn(source.getSmnTopicUrn());
+        target.setNtfyServerUrl(source.getNtfyServerUrl());
+        target.setNtfyTopic(source.getNtfyTopic());
+        target.setWpushChannel(source.getWpushChannel());
+        target.setWpushTopicCode(source.getWpushTopicCode());
         target.setHookUrl(secret("hookUrl", source.getHookUrl(), existing == 
null ? null : existing.getHookUrl(), source));
         target.setHookAuthToken(secret("hookAuthToken", 
source.getHookAuthToken(), existing == null ? null : 
existing.getHookAuthToken(), source));
         target.setWechatId(secret("wechatId", source.getWechatId(), existing 
== null ? null : existing.getWechatId(), source));
@@ -144,6 +156,8 @@ public class NoticeReceiverContractMapper {
         target.setSmnSk(secret("smnSk", source.getSmnSk(), existing == null ? 
null : existing.getSmnSk(), source));
         target.setServerChanToken(secret("serverChanToken", 
source.getServerChanToken(), existing == null ? null : 
existing.getServerChanToken(), source));
         target.setGotifyToken(secret("gotifyToken", source.getGotifyToken(), 
existing == null ? null : existing.getGotifyToken(), source));
+        target.setNtfyToken(secret("ntfyToken", source.getNtfyToken(), 
existing == null ? null : existing.getNtfyToken(), source));
+        target.setWpushToken(secret("wpushToken", source.getWpushToken(), 
existing == null ? null : existing.getWpushToken(), source));
     }
 
     private String secret(String field, String supplied, String existing, 
NoticeReceiverOptions options) {
@@ -198,6 +212,8 @@ public class NoticeReceiverContractMapper {
             }
             case 12 -> require(receiver.getServerChanToken(), 
"serverChanToken");
             case 13 -> require(receiver.getGotifyToken(), "gotifyToken");
+            case 15 -> require(receiver.getNtfyTopic(), "ntfyTopic");
+            case 16 -> require(receiver.getWpushToken(), "wpushToken");
             case 14 -> {
                 require(receiver.getAppId(), "appId");
                 require(receiver.getAppSecret(), "appSecret");
@@ -245,6 +261,12 @@ public class NoticeReceiverContractMapper {
         add(fields, "smnTopicUrn", options.getSmnTopicUrn());
         add(fields, "serverChanToken", options.getServerChanToken());
         add(fields, "gotifyToken", options.getGotifyToken());
+        add(fields, "ntfyServerUrl", options.getNtfyServerUrl());
+        add(fields, "ntfyTopic", options.getNtfyTopic());
+        add(fields, "ntfyToken", options.getNtfyToken());
+        add(fields, "wpushToken", options.getWpushToken());
+        add(fields, "wpushChannel", options.getWpushChannel());
+        add(fields, "wpushTopicCode", options.getWpushTopicCode());
         return fields;
     }
 
@@ -262,6 +284,8 @@ public class NoticeReceiverContractMapper {
         add(fields, "smnSk", receiver.getSmnSk());
         add(fields, "serverChanToken", receiver.getServerChanToken());
         add(fields, "gotifyToken", receiver.getGotifyToken());
+        add(fields, "ntfyToken", receiver.getNtfyToken());
+        add(fields, "wpushToken", receiver.getWpushToken());
         return fields;
     }
 
diff --git 
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/SmsLocalSmsClientImpl.java
 
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/SmsLocalSmsClientImpl.java
index 030b65dbc7..2f03bd05cc 100644
--- 
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/SmsLocalSmsClientImpl.java
+++ 
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/SmsLocalSmsClientImpl.java
@@ -66,11 +66,8 @@ public class SmsLocalSmsClientImpl implements SmsClient {
         }
 
         try (CloseableHttpClient httpClient = HttpClients.createDefault()) {
-            String content = alert.getCommonAnnotations().get("summary");
-            if (Objects.isNull(content) || 
Objects.isNull(alert.getCommonAnnotations().get("description"))) {
-                content = alert.getAlerts().get(0).getContent();
-            }
-            SmsMessage smsMessage = new SmsMessage(FROM, receiver.getPhone(), 
content);
+            SmsAlertFields fields = SmsAlertFields.from(alert);
+            SmsMessage smsMessage = new SmsMessage(FROM, receiver.getPhone(), 
fields.content());
 
             String payload = JsonUtil.toJson(smsMessage);
 
diff --git 
a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/controller/NoticeConfigControllerTest.java
 
b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/controller/NoticeConfigControllerTest.java
index c28dc3ac8a..eba72d541c 100644
--- 
a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/controller/NoticeConfigControllerTest.java
+++ 
b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/controller/NoticeConfigControllerTest.java
@@ -81,6 +81,9 @@ class NoticeConfigControllerTest {
     @Mock
     private NoticeReceiverContractService noticeReceiverService;
 
+    @Mock
+    private org.apache.hertzbeat.alert.AlerterProperties alerterProperties;
+
     @InjectMocks
     private NoticeConfigController noticeConfigController;
 
@@ -639,6 +642,44 @@ class NoticeConfigControllerTest {
                 .andReturn();
     }
 
+    @Test
+    void previewNoticeTemplate() throws Exception {
+        NoticeTemplate noticeTemplate = new NoticeTemplate();
+        noticeTemplate.setId(5L);
+        noticeTemplate.setName("preview-test");
+        noticeTemplate.setType((byte) 5);
+        noticeTemplate.setContent("""
+                [${title}] status=${status}
+                <#list alerts as alert>
+                ${alert.labels.alertname} - ${alert.content}
+                </#list>""");
+        
when(alerterProperties.getConsoleUrl()).thenReturn("http://localhost:1157";);
+
+        this.mockMvc.perform(post("/api/notice/template/preview")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(JsonUtil.toJson(noticeTemplate)))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value((int) 
CommonConstants.SUCCESS_CODE))
+                
.andExpect(jsonPath("$.data").value(containsString("HighCPUUsage")))
+                .andReturn();
+    }
+
+    @Test
+    void previewNoticeTemplateWithInvalidContent() throws Exception {
+        NoticeTemplate noticeTemplate = new NoticeTemplate();
+        noticeTemplate.setId(5L);
+        noticeTemplate.setName("preview-test-invalid");
+        noticeTemplate.setType((byte) 5);
+        noticeTemplate.setContent("${undefinedVariable}");
+
+        this.mockMvc.perform(post("/api/notice/template/preview")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(JsonUtil.toJson(noticeTemplate)))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value((int) 
CommonConstants.FAIL_CODE))
+                .andReturn();
+    }
+
     private NoticeReceiverResponse safeResponse(NoticeReceiver receiver) {
         return new NoticeReceiverContractMapper().toResponse(receiver);
     }
diff --git 
a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/reduce/AlarmGroupReduceTest.java
 
b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/reduce/AlarmGroupReduceTest.java
index 7b2352b085..45d809b518 100644
--- 
a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/reduce/AlarmGroupReduceTest.java
+++ 
b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/reduce/AlarmGroupReduceTest.java
@@ -17,25 +17,6 @@
  * under the License.
  */
 
-/*
- * 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.hertzbeat.alert.reduce;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -44,6 +25,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.Mockito.atLeastOnce;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.clearInvocations;
 import static org.mockito.Mockito.doAnswer;
@@ -51,6 +33,7 @@ import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
+import java.lang.reflect.Field;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
@@ -64,6 +47,7 @@ import java.util.concurrent.atomic.AtomicInteger;
 import org.apache.hertzbeat.alert.dao.AlertGroupConvergeDao;
 import org.apache.hertzbeat.common.config.VirtualThreadProperties;
 import org.apache.hertzbeat.common.entity.alerter.AlertGroupConverge;
+import org.apache.hertzbeat.common.entity.alerter.GroupAlert;
 import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
 import org.apache.hertzbeat.common.observability.gateway.AuthTokenScopes;
 import org.junit.jupiter.api.AfterEach;
@@ -288,11 +272,14 @@ class AlarmGroupReduceTest {
         }).when(alarmInhibitReduce).inhibitAlarm(any());
         alarmGroupReduce.processGroupAlert(groupAlert("fp-1", "firing"));
 
-        dispatchAndDrain();
         dispatchAndDrain();
         dispatchAndDrain();
 
-        verify(alarmInhibitReduce, times(2)).inhibitAlarm(any());
+        ArgumentCaptor<GroupAlert> captor = 
ArgumentCaptor.forClass(GroupAlert.class);
+        verify(alarmInhibitReduce, 
atLeastOnce()).inhibitAlarm(captor.capture());
+        assertTrue(captor.getAllValues().stream().anyMatch(group ->
+                group.getAlerts().stream().anyMatch(a -> 
"fp-2".equals(a.getFingerprint()))),
+                "concurrently inserted alert was dropped with the emitted 
snapshot");
     }
 
     @Test
@@ -313,6 +300,89 @@ class AlarmGroupReduceTest {
                 && 
group.getWorkspaceId().equals(group.getAlerts().getFirst().getWorkspaceId())));
     }
 
+    /**
+     * Regression for issue #4160 (Bug 1): while one member of a group is 
still firing, the
+     * recovery of another member must not flip the whole group to resolved. 
The group has to
+     * stay firing until every member has actually cleared.
+     */
+    @Test
+    void whenOneMemberRecoversButAnotherStillFiring_groupMustNotResolve() 
throws Exception {
+        
alarmGroupReduce.refreshGroupDefines(Collections.singletonList(instanceRule(3600)));
+
+        alarmGroupReduce.processGroupAlert(instanceAlert("cpu", "firing", 
"host1"));
+        alarmGroupReduce.processGroupAlert(instanceAlert("mem", "firing", 
"host1"));
+        dispatchAndDrain();
+
+        alarmGroupReduce.processGroupAlert(instanceAlert("cpu", "resolved", 
"host1"));
+        dispatchAndDrain();
+
+        ArgumentCaptor<GroupAlert> captor = 
ArgumentCaptor.forClass(GroupAlert.class);
+        verify(alarmInhibitReduce, 
atLeastOnce()).inhibitAlarm(captor.capture());
+        List<GroupAlert> groups = captor.getAllValues();
+
+        assertTrue(groups.stream().noneMatch(g -> 
"resolved".equals(g.getStatus())),
+                "group wrongly resolved while a member alert was still 
firing");
+        assertTrue(groups.stream().anyMatch(g -> "firing".equals(g.getStatus())
+                        && g.getAlerts().stream().anyMatch(
+                                a -> "cpu".equals(a.getFingerprint()) && 
"resolved".equals(a.getStatus()))),
+                "CPU recovery was not communicated within the still-firing 
group");
+    }
+
+    /**
+     * Regression for issue #4160 (Bug 2): a resolved transition that happens 
while the group is
+     * firing and inside the firing repeat-interval window must still be 
emitted. The firing
+     * throttle may only suppress repeated firing notifications, never a 
pending recovery.
+     */
+    @Test
+    void whenMemberRecoversInsideRepeatInterval_recoveryMustStillBeEmitted() 
throws Exception {
+        
alarmGroupReduce.refreshGroupDefines(Collections.singletonList(instanceRule(3600)));
+
+        alarmGroupReduce.processGroupAlert(instanceAlert("cpu", "firing", 
"host1"));
+        alarmGroupReduce.processGroupAlert(instanceAlert("mem", "firing", 
"host1"));
+        dispatchAndDrain();
+
+        alarmGroupReduce.processGroupAlert(instanceAlert("cpu", "firing", 
"host1"));
+        alarmGroupReduce.processGroupAlert(instanceAlert("mem", "resolved", 
"host1"));
+        dispatchAndDrain();
+
+        ArgumentCaptor<GroupAlert> captor = 
ArgumentCaptor.forClass(GroupAlert.class);
+        verify(alarmInhibitReduce, 
atLeastOnce()).inhibitAlarm(captor.capture());
+        List<GroupAlert> groups = captor.getAllValues();
+
+        assertTrue(groups.stream().anyMatch(g -> 
g.getAlerts().stream().anyMatch(
+                        a -> "mem".equals(a.getFingerprint()) && 
"resolved".equals(a.getStatus()))),
+                "memory recovery was silently dropped by the firing 
repeat-interval throttle");
+    }
+
+    /**
+     * Regression: deleting (or renaming/disabling) a converge rule while its 
group cache still
+     * holds a firing alert must not break the periodic group dispatch with an 
NPE. The orphaned
+     * group has to fall back to the default repeat interval, like 
shouldSendGroup already does
+     * for the group wait/interval.
+     */
+    @Test
+    void whenRuleDeleted_firingGroupCacheMustStillBeDispatched() throws 
Exception {
+        
alarmGroupReduce.refreshGroupDefines(Collections.singletonList(instanceRule(3600)));
+
+        alarmGroupReduce.processGroupAlert(instanceAlert("cpu", "firing", 
"host1"));
+
+        alarmGroupReduce.refreshGroupDefines(Collections.emptyList());
+
+        Field cachesField = 
AlarmGroupReduce.class.getDeclaredField("groupCacheMap");
+        cachesField.setAccessible(true);
+        Map<?, ?> caches = (Map<?, ?>) cachesField.get(alarmGroupReduce);
+        for (Object cache : caches.values()) {
+            Field createTimeField = 
cache.getClass().getDeclaredField("createTime");
+            createTimeField.setAccessible(true);
+            createTimeField.setLong(cache, System.currentTimeMillis() - 
60_000);
+        }
+
+        dispatchAndDrain();
+
+        verify(alarmInhibitReduce, atLeastOnce()).inhibitAlarm(argThat(group ->
+                group.getAlerts().stream().anyMatch(a -> 
"cpu".equals(a.getFingerprint()))));
+    }
+
     private void dispatchAndDrain() throws Exception {
         alarmGroupReduce.dispatchCheckAndSendGroups();
         alarmGroupReduce.pauseAdmission();
@@ -330,6 +400,26 @@ class AlarmGroupReduceTest {
         return rule;
     }
 
+    private AlertGroupConverge instanceRule(long repeatInterval) {
+        AlertGroupConverge rule = new AlertGroupConverge();
+        rule.setName("instance-rule");
+        rule.setGroupLabels(Collections.singletonList("instance"));
+        rule.setGroupWait(0L);
+        rule.setGroupInterval(0L);
+        rule.setRepeatInterval(repeatInterval);
+        return rule;
+    }
+
+    private SingleAlert instanceAlert(String fingerprint, String status, 
String instance) {
+        return SingleAlert.builder()
+                .workspaceId(AuthTokenScopes.DEFAULT_WORKSPACE_ID)
+                .fingerprint(fingerprint)
+                .status(status)
+                .labels(createLabels("instance", instance))
+                .annotations(new HashMap<>())
+                .build();
+    }
+
     private SingleAlert groupAlert(String fingerprint, String status) {
         return groupAlert(AuthTokenScopes.DEFAULT_WORKSPACE_ID, fingerprint, 
status);
     }
diff --git 
a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/service/AlertDefineJsonImExportServiceTest.java
 
b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/service/AlertDefineJsonImExportServiceTest.java
index c11284cdc6..a9301b0a86 100644
--- 
a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/service/AlertDefineJsonImExportServiceTest.java
+++ 
b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/service/AlertDefineJsonImExportServiceTest.java
@@ -58,6 +58,7 @@ class AlertDefineJsonImExportServiceTest {
         alertDefine.setName("App1");
         alertDefine.setType("realtime");
         alertDefine.setDatasource("lifecycle-promql");
+        alertDefine.setQueryLanguage("promql");
         alertDefine.setExpr("Expr1");
         alertDefine.setPeriod(3000);
         alertDefine.setTimes(3);
@@ -101,9 +102,11 @@ class AlertDefineJsonImExportServiceTest {
         assertTrue(result.contains("App1"));
         assertTrue(result.contains("realtime"));
         assertTrue(result.contains("\"datasource\":\"lifecycle-promql\""));
+        assertTrue(result.contains("\"queryLanguage\":\"promql\""));
         List<ExportAlertDefineDTO> parsed = service.parseImport(
                 new ByteArrayInputStream(outputStream.toByteArray()));
         assertEquals("lifecycle-promql", 
parsed.getFirst().getAlertDefine().getDatasource());
+        assertEquals("promql", 
parsed.getFirst().getAlertDefine().getQueryLanguage());
     }
 
     @Test
diff --git a/hertzbeat-startup/src/main/resources/application-test.yml 
b/hertzbeat-startup/src/main/resources/application-test.yml
index 36f97a3c1c..a4cf3aad1c 100644
--- a/hertzbeat-startup/src/main/resources/application-test.yml
+++ b/hertzbeat-startup/src/main/resources/application-test.yml
@@ -165,14 +165,6 @@ alerter:
       access-key-secret:
       sign-name:
       template-code:
-    unisms:
-      # auth-mode: simple or hmac
-      auth-mode: simple
-      access-key-id: YOUR_ACCESS_KEY_ID
-      # hmac mode need to fill in access-key-secret
-      access-key-secret: YOUR_ACCESS_KEY_SECRET
-      signature: YOUR_SMS_SIGNATURE
-      template-id: YOUR_TEMPLATE_ID
     smslocal:
       api-key: YOUR_API_KEY_HERE
     aws:
diff --git 
a/hertzbeat-warehouse/src/test/java/org/apache/hertzbeat/warehouse/db/GreptimeSqlQueryExecutorTest.java
 
b/hertzbeat-warehouse/src/test/java/org/apache/hertzbeat/warehouse/db/GreptimeSqlQueryExecutorTest.java
index b380ccf113..f26c520f04 100644
--- 
a/hertzbeat-warehouse/src/test/java/org/apache/hertzbeat/warehouse/db/GreptimeSqlQueryExecutorTest.java
+++ 
b/hertzbeat-warehouse/src/test/java/org/apache/hertzbeat/warehouse/db/GreptimeSqlQueryExecutorTest.java
@@ -20,6 +20,7 @@
 package org.apache.hertzbeat.warehouse.db;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -29,6 +30,7 @@ import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
+import java.net.URLDecoder;
 import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
 import java.time.Duration;
@@ -88,6 +90,37 @@ class GreptimeSqlQueryExecutorTest {
         queryGuard.close();
     }
 
+    @Test
+    @SuppressWarnings("unchecked")
+    void testRequestBodyIsFormEncoded() {
+        when(restTemplate.exchange(
+            any(String.class),
+            eq(HttpMethod.POST),
+            any(HttpEntity.class),
+            eq(GreptimeSqlQueryContent.class)
+        )).thenReturn(new ResponseEntity<>(new GreptimeSqlQueryContent(), 
HttpStatus.OK));
+
+        // SQL carrying form-urlencoded metacharacters that would pollute the 
request body
+        // if concatenated raw: '&' would start a second sql= parameter and 
override the query.
+        String sql = "SELECT * FROM t WHERE name = 'x&sql=DROP TABLE t'";
+        greptimeSqlQueryExecutor.execute(sql);
+
+        ArgumentCaptor<HttpEntity<String>> captor = 
ArgumentCaptor.forClass(HttpEntity.class);
+        verify(restTemplate).exchange(
+            any(String.class),
+            eq(HttpMethod.POST),
+            captor.capture(),
+            eq(GreptimeSqlQueryContent.class));
+
+        String body = captor.getValue().getBody();
+        assertNotNull(body);
+        // The body must be a single sql= parameter; the '&' inside the SQL 
must be encoded.
+        String encodedValue = body.substring("sql=".length());
+        assertFalse(encodedValue.contains("&"), "raw '&' in body causes HTTP 
parameter pollution");
+        // Decoding the value must yield exactly the original SQL, lossless 
round-trip.
+        assertEquals(sql, URLDecoder.decode(encodedValue, 
StandardCharsets.UTF_8));
+    }
+
     @Test
     void testExecuteSuccess() {
         // Mock successful response
diff --git a/script/docker-compose/hertzbeat-mysql-iotdb/docker-compose.yaml 
b/script/docker-compose/hertzbeat-mysql-iotdb/docker-compose.yaml
index 9bef6f8244..910c116576 100644
--- a/script/docker-compose/hertzbeat-mysql-iotdb/docker-compose.yaml
+++ b/script/docker-compose/hertzbeat-mysql-iotdb/docker-compose.yaml
@@ -31,7 +31,7 @@ services:
       retries: 5
       start_period: 30s
     ports:
-      - '13306:3306'
+      - '127.0.0.1:13306:3306'
     environment:
       TZ: Asia/Shanghai
       MARIADB_ROOT_PASSWORD: 123456
@@ -55,8 +55,8 @@ services:
     environment:
       TZ: Asia/Shanghai
     ports:
-      - "18181:8181"
-      - "16667:6667"
+      - "127.0.0.1:18181:8181"
+      - "127.0.0.1:16667:6667"
     volumes:
       - ./dbdata/iotdbdata:/iotdb/data
     networks:
@@ -68,6 +68,7 @@ services:
     hostname: hertzbeat
     restart: always
     environment:
+      HERTZBEAT_COLLECTOR_MYSQL_QUERY_ENGINE: auto
       TZ: Asia/Shanghai
       LANG: zh_CN.UTF-8
     depends_on:
@@ -81,7 +82,10 @@ services:
       - ./ext-lib:/opt/hertzbeat/ext-lib
       - ./logs:/opt/hertzbeat/logs
     ports:
-      - "1157:1157"
-      - "1158:1158"
+      - "${HERTZBEAT_BIND_ADDRESS:-127.0.0.1}:1157:1157"
+      - "${HERTZBEAT_BIND_ADDRESS:-127.0.0.1}:1158:1158"
+      # OTLP/gRPC ingestion. 14317 rather than the OpenTelemetry standard 
4317, which a local
+      # OTel Collector normally holds - and a clash on a published port stops 
the container.
+      - "${HERTZBEAT_OTLP_BIND_ADDRESS:-127.0.0.1}:14317:14317"
     networks:
       - hertzbeat
diff --git a/script/docker-compose/hertzbeat-mysql-tdengine/docker-compose.yaml 
b/script/docker-compose/hertzbeat-mysql-tdengine/docker-compose.yaml
index 0a30f8b52d..7eeae071be 100644
--- a/script/docker-compose/hertzbeat-mysql-tdengine/docker-compose.yaml
+++ b/script/docker-compose/hertzbeat-mysql-tdengine/docker-compose.yaml
@@ -31,7 +31,7 @@ services:
       retries: 5
       start_period: 30s
     ports:
-      - '13306:3306'
+      - '127.0.0.1:13306:3306'
     environment:
       TZ: Asia/Shanghai
       MARIADB_ROOT_PASSWORD: 123456
@@ -55,7 +55,7 @@ services:
     environment:
       TZ: Asia/Shanghai
     ports:
-      - "16041:6041"
+      - "127.0.0.1:16041:6041"
     volumes:
       - ./dbdata/taosdata:/var/lib/taos/
     networks:
@@ -67,6 +67,7 @@ services:
     hostname: hertzbeat
     restart: always
     environment:
+      HERTZBEAT_COLLECTOR_MYSQL_QUERY_ENGINE: auto
       TZ: Asia/Shanghai
       LANG: zh_CN.UTF-8
     depends_on:
@@ -80,7 +81,10 @@ services:
       - ./ext-lib:/opt/hertzbeat/ext-lib
       - ./logs:/opt/hertzbeat/logs
     ports:
-      - "1157:1157"
-      - "1158:1158"
+      - "${HERTZBEAT_BIND_ADDRESS:-127.0.0.1}:1157:1157"
+      - "${HERTZBEAT_BIND_ADDRESS:-127.0.0.1}:1158:1158"
+      # OTLP/gRPC ingestion. 14317 rather than the OpenTelemetry standard 
4317, which a local
+      # OTel Collector normally holds - and a clash on a published port stops 
the container.
+      - "${HERTZBEAT_OTLP_BIND_ADDRESS:-127.0.0.1}:14317:14317"
     networks:
       - hertzbeat
diff --git 
a/script/docker-compose/hertzbeat-mysql-victoria-metrics/docker-compose.yaml 
b/script/docker-compose/hertzbeat-mysql-victoria-metrics/docker-compose.yaml
index a18e4986fa..d17e258fca 100644
--- a/script/docker-compose/hertzbeat-mysql-victoria-metrics/docker-compose.yaml
+++ b/script/docker-compose/hertzbeat-mysql-victoria-metrics/docker-compose.yaml
@@ -31,7 +31,7 @@ services:
       retries: 5
       start_period: 30s
     ports:
-      - '13306:3306'
+      - '127.0.0.1:13306:3306'
     environment:
       TZ: Asia/Shanghai
       MARIADB_ROOT_PASSWORD: 123456
@@ -55,7 +55,7 @@ services:
     environment:
       TZ: Asia/Shanghai
     ports:
-      - "18428:8428"
+      - "127.0.0.1:18428:8428"
     volumes:
       - ./dbdata/tsdbdata:/victoria-metrics-data
     networks:
@@ -67,6 +67,7 @@ services:
     hostname: hertzbeat
     restart: always
     environment:
+      HERTZBEAT_COLLECTOR_MYSQL_QUERY_ENGINE: auto
       TZ: Asia/Shanghai
       LANG: zh_CN.UTF-8
     depends_on:
@@ -80,7 +81,10 @@ services:
       - ./ext-lib:/opt/hertzbeat/ext-lib
       - ./logs:/opt/hertzbeat/logs
     ports:
-      - "1157:1157"
-      - "1158:1158"
+      - "${HERTZBEAT_BIND_ADDRESS:-127.0.0.1}:1157:1157"
+      - "${HERTZBEAT_BIND_ADDRESS:-127.0.0.1}:1158:1158"
+      # OTLP/gRPC ingestion. 14317 rather than the OpenTelemetry standard 
4317, which a local
+      # OTel Collector normally holds - and a clash on a published port stops 
the container.
+      - "${HERTZBEAT_OTLP_BIND_ADDRESS:-127.0.0.1}:14317:14317"
     networks:
       - hertzbeat
diff --git 
a/script/docker-compose/hertzbeat-postgresql-greptimedb/docker-compose.yaml 
b/script/docker-compose/hertzbeat-postgresql-greptimedb/docker-compose.yaml
index b5954fdd36..04e5c8fa41 100644
--- a/script/docker-compose/hertzbeat-postgresql-greptimedb/docker-compose.yaml
+++ b/script/docker-compose/hertzbeat-postgresql-greptimedb/docker-compose.yaml
@@ -32,8 +32,11 @@ services:
       timeout: 5s
       retries: 5
       start_period: 30s
+    # Bind data-store ports to localhost only: these services carry default
+    # credentials (see POSTGRES_PASSWORD / the greptime static user provider)
+    # and are only meant for the operator on the host, not for remote access.
     ports:
-      - '15432:5432'
+      - '127.0.0.1:15432:5432'
     environment:
       POSTGRES_USER: root
       POSTGRES_PASSWORD: 123456
@@ -46,7 +49,7 @@ services:
       - hertzbeat
 
   greptime:
-    image: greptime/greptimedb:latest
+    image: greptime/greptimedb:v1.1.3
     container_name: compose-greptimedb
     hostname: greptime
     restart: always
@@ -58,11 +61,14 @@ services:
       start_period: 30s
     environment:
         TZ: Asia/Shanghai
+    # Bind data-store ports to localhost only: these services use the default
+    # static user provider (greptime=greptime) and are only meant for the
+    # operator on the host, not for remote access.
     ports:
-      - "14000:4000"
-      - "14001:4001"
-      - "14002:4002"
-      - "14003:4003"
+      - "127.0.0.1:14000:4000"
+      - "127.0.0.1:14001:4001"
+      - "127.0.0.1:14002:4002"
+      - "127.0.0.1:14003:4003"
     volumes:
       - greptime-tsdb-data:/greptimedb_data
     command:
@@ -100,9 +106,14 @@ services:
       - ./conf/sureness.yml:/opt/hertzbeat/config/sureness.yml
       - ./logs:/opt/hertzbeat/logs
       - ./ext-lib:/opt/hertzbeat/ext-lib
+    # Keep the quick-start manager and collector listeners local by default.
+    # Set HERTZBEAT_BIND_ADDRESS explicitly only after replacing the bundled
+    # credentials and applying the required network access controls.
     ports:
-      - "1157:1157"
-      - "4317:4317"
-      - "1158:1158"
+      - "${HERTZBEAT_BIND_ADDRESS:-127.0.0.1}:1157:1157"
+      - "${HERTZBEAT_BIND_ADDRESS:-127.0.0.1}:1158:1158"
+      # OTLP/gRPC ingestion. 14317 rather than the OpenTelemetry standard 
4317, which a local
+      # OTel Collector normally holds - and a clash on a published port stops 
the container.
+      - "${HERTZBEAT_OTLP_BIND_ADDRESS:-127.0.0.1}:14317:14317"
     networks:
       - hertzbeat
diff --git 
a/script/docker-compose/hertzbeat-postgresql-victoria-metrics-observability/docker-compose.yaml
 
b/script/docker-compose/hertzbeat-postgresql-victoria-metrics-observability/docker-compose.yaml
index eb1bf28678..3612eb526b 100644
--- 
a/script/docker-compose/hertzbeat-postgresql-victoria-metrics-observability/docker-compose.yaml
+++ 
b/script/docker-compose/hertzbeat-postgresql-victoria-metrics-observability/docker-compose.yaml
@@ -29,8 +29,11 @@ services:
       timeout: 5s
       retries: 5
       start_period: 30s
+    # Bind data-store ports to localhost only: these services carry default
+    # credentials (see POSTGRES_PASSWORD) and are only meant for the operator
+    # on the host, not for remote access.
     ports:
-      - "15432:5432"
+      - "127.0.0.1:15432:5432"
     environment:
       POSTGRES_USER: root
       POSTGRES_PASSWORD: 123456
@@ -56,7 +59,7 @@ services:
     environment:
       TZ: Asia/Shanghai
     ports:
-      - "18428:8428"
+      - "127.0.0.1:18428:8428"
     volumes:
       - ./dbdata/tsdbdata:/victoria-metrics-data
     networks:
@@ -81,9 +84,14 @@ services:
       - 
../hertzbeat-postgresql-victoria-metrics/conf/sureness.yml:/opt/hertzbeat/config/sureness.yml
       - ./logs/hertzbeat:/opt/hertzbeat/logs
       - ./ext-lib:/opt/hertzbeat/ext-lib
+    # Keep the quick-start manager and collector listeners local by default.
+    # Set HERTZBEAT_BIND_ADDRESS explicitly only after replacing the bundled
+    # credentials and applying the required network access controls.
     ports:
-      - "4317:4317"
-      - "1157:1157"
-      - "1158:1158"
+      - "${HERTZBEAT_BIND_ADDRESS:-127.0.0.1}:1157:1157"
+      - "${HERTZBEAT_BIND_ADDRESS:-127.0.0.1}:1158:1158"
+      # OTLP/gRPC ingestion. 14317 rather than the OpenTelemetry standard 
4317, which a local
+      # OTel Collector normally holds - and a clash on a published port stops 
the container.
+      - "${HERTZBEAT_OTLP_BIND_ADDRESS:-127.0.0.1}:14317:14317"
     networks:
       - hertzbeat
diff --git 
a/script/docker-compose/hertzbeat-postgresql-victoria-metrics/docker-compose.yaml
 
b/script/docker-compose/hertzbeat-postgresql-victoria-metrics/docker-compose.yaml
index 6bf55aa02d..867d81086d 100644
--- 
a/script/docker-compose/hertzbeat-postgresql-victoria-metrics/docker-compose.yaml
+++ 
b/script/docker-compose/hertzbeat-postgresql-victoria-metrics/docker-compose.yaml
@@ -32,10 +32,10 @@ services:
       retries: 5
       start_period: 30s
     ports:
-      - '15432:5432'
+      - '127.0.0.1:15432:5432'
     environment:
-      POSTGRES_USER: root
-      POSTGRES_PASSWORD: 123456
+      POSTGRES_USER: ${POSTGRES_USER:-root}
+      POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?Please set POSTGRES_PASSWORD 
(e.g., in .env)}"
       TZ: Asia/Shanghai
       PGDATA: /var/lib/postgresql/data/pgdata
     volumes:
@@ -58,7 +58,7 @@ services:
     environment:
       TZ: Asia/Shanghai
     ports:
-      - "18428:8428"
+      - "127.0.0.1:18428:8428"
     volumes:
       - ./dbdata/tsdbdata:/victoria-metrics-data
     networks:
@@ -70,8 +70,11 @@ services:
     hostname: hertzbeat
     restart: always
     environment:
+      HERTZBEAT_COLLECTOR_MYSQL_QUERY_ENGINE: auto
       TZ: Asia/Shanghai
       LANG: zh_CN.UTF-8
+      SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-root}
+      SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:?Please set 
POSTGRES_PASSWORD in your .env file}
     depends_on:
       postgres:
         condition: service_healthy
@@ -83,7 +86,10 @@ services:
       - ./logs:/opt/hertzbeat/logs
       - ./ext-lib:/opt/hertzbeat/ext-lib
     ports:
-      - "1157:1157"
-      - "1158:1158"
+      - "${HERTZBEAT_BIND_ADDRESS:-127.0.0.1}:1157:1157"
+      - "${HERTZBEAT_BIND_ADDRESS:-127.0.0.1}:1158:1158"
+      # OTLP/gRPC ingestion. 14317 rather than the OpenTelemetry standard 
4317, which a local
+      # OTel Collector normally holds - and a clash on a published port stops 
the container.
+      - "${HERTZBEAT_OTLP_BIND_ADDRESS:-127.0.0.1}:14317:14317"
     networks:
       - hertzbeat
diff --git a/script/docker/collector/Dockerfile 
b/script/docker/collector/Dockerfile
index d0fa7c3d9c..c3caf203b3 100644
--- a/script/docker/collector/Dockerfile
+++ b/script/docker/collector/Dockerfile
@@ -24,7 +24,7 @@ ARG TARGETARCH
 # Install SSH
 RUN  sed -i 's#http://#https://#g' /etc/apt/sources.list.d/ubuntu.sources && \
     apt-get update && apt-get install -y openssh-server
-RUN mkdir /var/run/sshd
+RUN mkdir -p /var/run/sshd
 
 ADD apache-hertzbeat-collector-*-bin-linux_${TARGETARCH}.tar.gz 
/opt/collector-package/
 


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to