This is an automated email from the ASF dual-hosted git repository.
zhaoqingran pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git
The following commit(s) were added to refs/heads/master by this push:
new 950138a340 [feat] Add an SQL editor and prevent SQL injection (#3900)
950138a340 is described below
commit 950138a3408de81d979759a63a605760a9f7713d
Author: Yang Chen <[email protected]>
AuthorDate: Fri Dec 12 17:52:31 2025 +0800
[feat] Add an SQL editor and prevent SQL injection (#3900)
Signed-off-by: Yang Chen <[email protected]>
Co-authored-by: Copilot <[email protected]>
---
.../alert/service/impl/DataSourceServiceImpl.java | 46 ++-
.../alert/service/DataSourceServiceTest.java | 157 +++++++-
hertzbeat-common/pom.xml | 6 +-
.../common/support/valid/SqlSecurityException.java | 32 ++
.../common/support/valid/SqlSecurityValidator.java | 150 +++++++
.../support/valid/SqlSecurityValidatorTest.java | 267 +++++++++++++
.../warehouse/constants/WarehouseConstants.java | 2 +
.../warehouse/db/GreptimeSqlQueryExecutor.java | 91 ++---
.../tsdb/greptime/GreptimeDbDataStorage.java | 3 +-
.../warehouse/db/GreptimeSqlQueryExecutorTest.java | 16 +-
material/licenses/backend/LICENSE | 1 +
pom.xml | 8 +-
.../alert-setting/alert-setting.component.html | 23 +-
.../alert/alert-setting/alert-setting.component.ts | 21 +-
.../sql-editor/sql-editor.component.html | 28 ++
.../sql-editor/sql-editor.component.less | 68 ++++
.../components/sql-editor/sql-editor.component.ts | 438 +++++++++++++++++++++
web-app/src/app/shared/shared.module.ts | 6 +-
web-app/src/assets/i18n/en-US.json | 1 -
web-app/src/assets/i18n/ja-JP.json | 1 -
web-app/src/assets/i18n/pt-BR.json | 1 -
web-app/src/assets/i18n/zh-CN.json | 1 -
web-app/src/assets/i18n/zh-TW.json | 1 -
23 files changed, 1279 insertions(+), 89 deletions(-)
diff --git
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/DataSourceServiceImpl.java
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/DataSourceServiceImpl.java
index 4ee3c4e879..93fd115928 100644
---
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/DataSourceServiceImpl.java
+++
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/DataSourceServiceImpl.java
@@ -31,12 +31,16 @@ import org.apache.hertzbeat.alert.expr.AlertExpressionLexer;
import org.apache.hertzbeat.alert.expr.AlertExpressionParser;
import org.apache.hertzbeat.alert.service.DataSourceService;
import org.apache.hertzbeat.common.support.exception.AlertExpressionException;
+import org.apache.hertzbeat.common.support.valid.SqlSecurityException;
+import org.apache.hertzbeat.common.support.valid.SqlSecurityValidator;
import org.apache.hertzbeat.common.util.ResourceBundleUtil;
+import org.apache.hertzbeat.warehouse.constants.WarehouseConstants;
import org.apache.hertzbeat.warehouse.db.QueryExecutor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
+import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.ResourceBundle;
@@ -49,12 +53,18 @@ import java.util.concurrent.TimeUnit;
@Slf4j
public class DataSourceServiceImpl implements DataSourceService {
+ /**
+ * Default allowed tables for SQL queries
+ */
+ private static final List<String> DEFAULT_ALLOWED_TABLES =
List.of(WarehouseConstants.LOG_TABLE_NAME);
+
protected ResourceBundle bundle = ResourceBundleUtil.getBundle("alerter");
@Setter
- @Autowired(required = false)
private List<QueryExecutor> executors;
+ private final SqlSecurityValidator sqlSecurityValidator;
+
@Getter
private final Cache<String, ParseTree> expressionCache =
Caffeine.newBuilder()
.maximumSize(256)
@@ -69,6 +79,11 @@ public class DataSourceServiceImpl implements
DataSourceService {
.recordStats()
.build();
+ public DataSourceServiceImpl(@Autowired(required = false)
List<QueryExecutor> executors) {
+ this.executors = executors != null ? executors :
Collections.emptyList();
+ this.sqlSecurityValidator = new
SqlSecurityValidator(DEFAULT_ALLOWED_TABLES);
+ }
+
@Override
public List<Map<String, Object>> calculate(String datasource, String expr)
{
if (!StringUtils.hasText(expr)) {
@@ -110,11 +125,36 @@ public class DataSourceServiceImpl implements
DataSourceService {
}
// replace all white space
expr = expr.replaceAll("\\s+", " ");
+
+ // SQL security validation for SQL-based datasources
+ if (isSqlDatasource(datasource)) {
+ validateSqlSecurity(expr);
+ }
+
try {
return executor.execute(expr);
} catch (Exception e) {
log.error("Error executing query on datasource {}: {}",
datasource, e.getMessage());
- throw new RuntimeException("Query execution failed", e);
+ throw new AlertExpressionException(e.getMessage());
+ }
+ }
+
+ /**
+ * Check if the datasource is SQL-based
+ */
+ private boolean isSqlDatasource(String datasource) {
+ return datasource != null &&
datasource.equalsIgnoreCase(WarehouseConstants.SQL);
+ }
+
+ /**
+ * Validate SQL statement for security
+ */
+ private void validateSqlSecurity(String sql) {
+ try {
+ sqlSecurityValidator.validate(sql);
+ } catch (SqlSecurityException e) {
+ log.warn("SQL security validation failed: {}", e.getMessage());
+ throw new AlertExpressionException("SQL security validation
failed: " + e.getMessage());
}
}
@@ -133,4 +173,4 @@ public class DataSourceServiceImpl implements
DataSourceService {
AlertExpressionLexer lexer = new
AlertExpressionLexer(CharStreams.fromString(expr));
return new CommonTokenStream(lexer);
}
-}
\ No newline at end of file
+}
diff --git
a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/service/DataSourceServiceTest.java
b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/service/DataSourceServiceTest.java
index dab28ab766..8fd9e4acc2 100644
---
a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/service/DataSourceServiceTest.java
+++
b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/service/DataSourceServiceTest.java
@@ -36,7 +36,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
@@ -48,7 +51,7 @@ class DataSourceServiceTest {
@BeforeEach
void setUp() {
- dataSourceService = new DataSourceServiceImpl();
+ dataSourceService = new DataSourceServiceImpl(null);
}
@Test
@@ -639,4 +642,156 @@ class DataSourceServiceTest {
assertThrows(AlertExpressionException.class, () ->
dataSourceService.calculate("promql",
"http_server_requests_seconds_count{!@~!!#$%^&}"));
}
+
+ @Test
+ void query1() {
+ List<Map<String, Object>> sqlData = List.of(
+ new HashMap<>(Map.of("count", 10, "severity_text", "ERROR"))
+ );
+ QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
+ when(mockExecutor.support("sql")).thenReturn(true);
+ when(mockExecutor.execute(anyString())).thenReturn(sqlData);
+ dataSourceService.setExecutors(List.of(mockExecutor));
+
+ String validSql = "SELECT count(*) FROM hertzbeat_logs WHERE
severity_text = 'ERROR'";
+ List<Map<String, Object>> result = dataSourceService.query("sql",
validSql);
+
+ assertNotNull(result);
+ assertEquals(1, result.size());
+ verify(mockExecutor).execute(anyString());
+ }
+
+ @Test
+ void query2() {
+ QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
+ when(mockExecutor.support("sql")).thenReturn(true);
+ dataSourceService.setExecutors(List.of(mockExecutor));
+
+ assertThrows(AlertExpressionException.class,
+ () -> dataSourceService.query("sql", "INSERT INTO
hertzbeat_logs (body) VALUES ('test')"));
+ verify(mockExecutor, never()).execute(anyString());
+ }
+
+ @Test
+ void query3() {
+ QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
+ when(mockExecutor.support("sql")).thenReturn(true);
+ dataSourceService.setExecutors(List.of(mockExecutor));
+
+ assertThrows(AlertExpressionException.class,
+ () -> dataSourceService.query("sql", "DELETE FROM
hertzbeat_logs WHERE id = 1"));
+ verify(mockExecutor, never()).execute(anyString());
+ }
+
+ @Test
+ void query4() {
+ QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
+ when(mockExecutor.support("sql")).thenReturn(true);
+ dataSourceService.setExecutors(List.of(mockExecutor));
+
+ assertThrows(AlertExpressionException.class,
+ () -> dataSourceService.query("sql", "UPDATE hertzbeat_logs
SET body = 'hacked' WHERE id = 1"));
+ verify(mockExecutor, never()).execute(anyString());
+ }
+
+ @Test
+ void query5() {
+ QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
+ when(mockExecutor.support("sql")).thenReturn(true);
+ dataSourceService.setExecutors(List.of(mockExecutor));
+
+ assertThrows(AlertExpressionException.class,
+ () -> dataSourceService.query("sql", "DROP TABLE
hertzbeat_logs"));
+ verify(mockExecutor, never()).execute(anyString());
+ }
+
+ @Test
+ void query6() {
+ QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
+ when(mockExecutor.support("sql")).thenReturn(true);
+ dataSourceService.setExecutors(List.of(mockExecutor));
+
+ assertThrows(AlertExpressionException.class,
+ () -> dataSourceService.query("sql", "SELECT * FROM
hertzbeat_logs UNION SELECT * FROM users"));
+ verify(mockExecutor, never()).execute(anyString());
+ }
+
+ @Test
+ void query7() {
+ QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
+ when(mockExecutor.support("sql")).thenReturn(true);
+ dataSourceService.setExecutors(List.of(mockExecutor));
+
+ assertThrows(AlertExpressionException.class,
+ () -> dataSourceService.query("sql", "SELECT * FROM
hertzbeat_logs WHERE id IN (SELECT id FROM other_table)"));
+ verify(mockExecutor, never()).execute(anyString());
+ }
+
+ @Test
+ void query8() {
+ QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
+ when(mockExecutor.support("sql")).thenReturn(true);
+ dataSourceService.setExecutors(List.of(mockExecutor));
+
+ assertThrows(AlertExpressionException.class,
+ () -> dataSourceService.query("sql", "SELECT * FROM users"));
+ verify(mockExecutor, never()).execute(anyString());
+ }
+
+ @Test
+ void query9() {
+ QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
+ when(mockExecutor.support("sql")).thenReturn(true);
+ dataSourceService.setExecutors(List.of(mockExecutor));
+
+ assertThrows(AlertExpressionException.class,
+ () -> dataSourceService.query("sql", "WITH cte AS (SELECT *
FROM hertzbeat_logs) SELECT * FROM cte"));
+ verify(mockExecutor, never()).execute(anyString());
+ }
+
+ @Test
+ void query10() {
+ List<Map<String, Object>> sqlData = List.of(
+ new HashMap<>(Map.of("errorCount", 5))
+ );
+ QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
+ when(mockExecutor.support("sql")).thenReturn(true);
+ when(mockExecutor.execute(anyString())).thenReturn(sqlData);
+ dataSourceService.setExecutors(List.of(mockExecutor));
+
+ String complexSql = "SELECT count(*) AS errorCount FROM hertzbeat_logs
"
+ + "WHERE time_unix_nano >= NOW() AND severity_text = 'ERROR' "
+ + "GROUP BY severity_text HAVING count(*) > 2 ORDER BY
errorCount LIMIT 10";
+
+ List<Map<String, Object>> result = dataSourceService.query("sql",
complexSql);
+ assertNotNull(result);
+ verify(mockExecutor).execute(anyString());
+ }
+
+ @Test
+ void query11() {
+ List<Map<String, Object>> prometheusData = List.of(
+ new HashMap<>(Map.of("__value__", 100.0))
+ );
+ QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
+ when(mockExecutor.support("promql")).thenReturn(true);
+ when(mockExecutor.execute(anyString())).thenReturn(prometheusData);
+ dataSourceService.setExecutors(List.of(mockExecutor));
+
+ List<Map<String, Object>> result = dataSourceService.query("promql",
"node_cpu_seconds_total > 50");
+
+ assertNotNull(result);
+ verify(mockExecutor).execute(anyString());
+ }
+
+ @Test
+ void query12() {
+ QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
+ when(mockExecutor.support("sql")).thenReturn(true);
+ dataSourceService.setExecutors(List.of(mockExecutor));
+
+ assertThrows(AlertExpressionException.class,
+ () -> dataSourceService.query("sql", "SELEC * FORM
hertzbeat_logs"));
+ verify(mockExecutor, never()).execute(anyString());
+ }
}
diff --git a/hertzbeat-common/pom.xml b/hertzbeat-common/pom.xml
index ea93cd48c8..379245e6ae 100644
--- a/hertzbeat-common/pom.xml
+++ b/hertzbeat-common/pom.xml
@@ -171,7 +171,7 @@
<groupId>org.apache.arrow</groupId>
<artifactId>arrow-memory-netty</artifactId>
</dependency>
-
+
<dependency>
<groupId>org.xerial.snappy</groupId>
<artifactId>snappy-java</artifactId>
@@ -183,6 +183,10 @@
<version>${javaparser.version}</version>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>com.github.jsqlparser</groupId>
+ <artifactId>jsqlparser</artifactId>
+ </dependency>
</dependencies>
</project>
diff --git
a/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/support/valid/SqlSecurityException.java
b/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/support/valid/SqlSecurityException.java
new file mode 100644
index 0000000000..d343cb7ef8
--- /dev/null
+++
b/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/support/valid/SqlSecurityException.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
+ * (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.common.support.valid;
+
+/**
+ * SQL security validation exception
+ */
+public class SqlSecurityException extends RuntimeException {
+
+ public SqlSecurityException(String message) {
+ super(message);
+ }
+
+ public SqlSecurityException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git
a/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/support/valid/SqlSecurityValidator.java
b/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/support/valid/SqlSecurityValidator.java
new file mode 100644
index 0000000000..6d58dff18b
--- /dev/null
+++
b/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/support/valid/SqlSecurityValidator.java
@@ -0,0 +1,150 @@
+/*
+ * 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.common.support.valid;
+
+import lombok.extern.slf4j.Slf4j;
+import net.sf.jsqlparser.JSQLParserException;
+import net.sf.jsqlparser.parser.CCJSqlParserUtil;
+import net.sf.jsqlparser.statement.Statement;
+import net.sf.jsqlparser.statement.select.LateralSubSelect;
+import net.sf.jsqlparser.statement.select.ParenthesedSelect;
+import net.sf.jsqlparser.statement.select.Select;
+import net.sf.jsqlparser.statement.select.SetOperationList;
+import net.sf.jsqlparser.statement.select.WithItem;
+import net.sf.jsqlparser.util.TablesNamesFinder;
+import org.springframework.util.CollectionUtils;
+
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * SQL Security Validator using JSqlParser 5.1+.
+ * Security Policy:
+ * 1. Only SELECT statements are allowed.
+ * 2. All referenced tables must be in the whitelist.
+ * 3. Subqueries, UNION, CTE, LATERAL are blocked.
+ */
+@Slf4j
+public class SqlSecurityValidator {
+
+ private final Set<String> allowedTables;
+
+ public SqlSecurityValidator(Collection<String> allowedTables) {
+ if (CollectionUtils.isEmpty(allowedTables)) {
+ this.allowedTables = new HashSet<>();
+ } else {
+ this.allowedTables = allowedTables.stream()
+ .map(this::normalizeIdentifier)
+ .collect(Collectors.toSet());
+ }
+ }
+
+ public void validate(String sql) throws SqlSecurityException {
+ if (sql == null || sql.trim().isEmpty()) {
+ throw new SqlSecurityException("SQL statement cannot be empty");
+ }
+
+ Statement statement;
+ try {
+ statement = CCJSqlParserUtil.parse(sql);
+ } catch (JSQLParserException e) {
+ log.warn("Failed to parse SQL: {}", sql, e);
+ throw new SqlSecurityException("Invalid SQL syntax: " +
e.getMessage(), e);
+ }
+
+ if (!(statement instanceof Select select)) {
+ throw new SqlSecurityException("Only SELECT statements are
allowed.");
+ }
+
+ // Check for CTE at top level
+ if (select.getWithItemsList() != null &&
!select.getWithItemsList().isEmpty()) {
+ throw new SqlSecurityException("CTE (WITH clause) is not allowed");
+ }
+
+ // Use custom TablesNamesFinder that throws on dangerous structures
+ SecurityTablesNamesFinder finder = new SecurityTablesNamesFinder();
+ List<String> tables;
+ try {
+ tables = finder.getTableList(statement);
+ } catch (SecurityViolationException e) {
+ throw new SqlSecurityException(e.getMessage());
+ }
+
+ validateTables(tables);
+ }
+
+ private void validateTables(List<String> tables) throws
SqlSecurityException {
+ if (CollectionUtils.isEmpty(tables)) {
+ return;
+ }
+ if (allowedTables.isEmpty()) {
+ throw new SqlSecurityException("No access allowed: whitelist is
empty.");
+ }
+
+ for (String table : tables) {
+ String normalizedTable = normalizeIdentifier(table);
+ if (!allowedTables.contains(normalizedTable)) {
+ throw new SqlSecurityException("Access to table '" + table +
"' is not allowed. "
+ + "Allowed tables: " + allowedTables);
+ }
+ }
+ }
+
+ private String normalizeIdentifier(String identifier) {
+ if (identifier == null) {
+ return "";
+ }
+ return identifier.replace("\"", "").replace("`", "").replace("'",
"").toLowerCase();
+ }
+
+ private static class SecurityViolationException extends RuntimeException {
+ SecurityViolationException(String message) {
+ super(message);
+ }
+ }
+
+ /**
+ * Custom TablesNamesFinder that throws exceptions on dangerous SQL
structures.
+ * Extends TablesNamesFinder with proper generic type to avoid raw type
warnings.
+ */
+ private static class SecurityTablesNamesFinder extends
TablesNamesFinder<Void> {
+
+ @Override
+ public Void visit(ParenthesedSelect parenthesedSelect, Object context)
{
+ throw new SecurityViolationException("Subqueries are not allowed");
+ }
+
+ @Override
+ public Void visit(SetOperationList setOpList, Object context) {
+ throw new SecurityViolationException("UNION and set operations are
not allowed");
+ }
+
+ @Override
+ public Void visit(LateralSubSelect lateralSubSelect, Object context) {
+ throw new SecurityViolationException("LATERAL subqueries are not
allowed");
+ }
+
+ @Override
+ public Void visit(WithItem withItem, Object context) {
+ throw new SecurityViolationException("CTE (WITH clause) is not
allowed");
+ }
+ }
+}
diff --git
a/hertzbeat-common/src/test/java/org/apache/hertzbeat/common/support/valid/SqlSecurityValidatorTest.java
b/hertzbeat-common/src/test/java/org/apache/hertzbeat/common/support/valid/SqlSecurityValidatorTest.java
new file mode 100644
index 0000000000..782ee31383
--- /dev/null
+++
b/hertzbeat-common/src/test/java/org/apache/hertzbeat/common/support/valid/SqlSecurityValidatorTest.java
@@ -0,0 +1,267 @@
+/*
+ * 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.common.support.valid;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Test for {@link SqlSecurityValidator}
+ */
+class SqlSecurityValidatorTest {
+
+ private SqlSecurityValidator validator;
+
+ @BeforeEach
+ void setUp() {
+ validator = new SqlSecurityValidator(Arrays.asList("hertzbeat_logs",
"app_logs", "access_logs"));
+ }
+
+ @Test
+ void testValidSelectStatement() {
+ assertDoesNotThrow(() -> validator.validate("SELECT * FROM
hertzbeat_logs"));
+ assertDoesNotThrow(() -> validator.validate("SELECT id, message FROM
hertzbeat_logs WHERE level = 'ERROR'"));
+ assertDoesNotThrow(() -> validator.validate("SELECT COUNT(*) FROM
app_logs"));
+ assertDoesNotThrow(() -> validator.validate("select * from
HERTZBEAT_LOGS")); // case insensitive
+ }
+
+ @Test
+ void testSelectWithJoin() {
+ assertDoesNotThrow(() -> validator.validate(
+ "SELECT a.id, b.message FROM hertzbeat_logs a JOIN app_logs b
ON a.id = b.id"));
+ }
+
+ @Test
+ void testSelectWithSubqueryNotAllowed() {
+ assertThrows(SqlSecurityException.class,
+ () -> validator.validate("SELECT * FROM hertzbeat_logs WHERE
id IN (SELECT id FROM app_logs)"));
+ }
+
+ @Test
+ void testSelectWithSubqueryInFromNotAllowed() {
+ assertThrows(SqlSecurityException.class,
+ () -> validator.validate("SELECT * FROM (SELECT * FROM
hertzbeat_logs) AS subq"));
+ }
+
+ @Test
+ void testEmptySql() {
+ assertThrows(SqlSecurityException.class, () ->
validator.validate(null));
+ assertThrows(SqlSecurityException.class, () -> validator.validate(""));
+ assertThrows(SqlSecurityException.class, () -> validator.validate("
"));
+ }
+
+ @Test
+ void testInvalidSqlSyntax() {
+ assertThrows(SqlSecurityException.class,
+ () -> validator.validate("SELECT * FORM hertzbeat_logs")); //
typo: FORM instead of FROM
+ }
+
+ @Test
+ void testInsertNotAllowed() {
+ assertThrows(SqlSecurityException.class,
+ () -> validator.validate("INSERT INTO hertzbeat_logs (message)
VALUES ('test')"));
+ }
+
+ @Test
+ void testUpdateNotAllowed() {
+ assertThrows(SqlSecurityException.class,
+ () -> validator.validate("UPDATE hertzbeat_logs SET message =
'test' WHERE id = 1"));
+ }
+
+ @Test
+ void testDeleteNotAllowed() {
+ assertThrows(SqlSecurityException.class,
+ () -> validator.validate("DELETE FROM hertzbeat_logs WHERE id
= 1"));
+ }
+
+ @Test
+ void testDropNotAllowed() {
+ assertThrows(SqlSecurityException.class,
+ () -> validator.validate("DROP TABLE hertzbeat_logs"));
+ }
+
+ @Test
+ void testTruncateNotAllowed() {
+ assertThrows(SqlSecurityException.class,
+ () -> validator.validate("TRUNCATE TABLE hertzbeat_logs"));
+ }
+
+ @Test
+ void testAlterNotAllowed() {
+ assertThrows(SqlSecurityException.class,
+ () -> validator.validate("ALTER TABLE hertzbeat_logs ADD
COLUMN new_col VARCHAR(100)"));
+ }
+
+ @Test
+ void testCreateNotAllowed() {
+ assertThrows(SqlSecurityException.class,
+ () -> validator.validate("CREATE TABLE new_table (id INT)"));
+ }
+
+ @Test
+ void testUnauthorizedTable() {
+ assertThrows(SqlSecurityException.class,
+ () -> validator.validate("SELECT * FROM users"));
+ }
+
+ @Test
+ void testUnauthorizedTableInJoin() {
+ assertThrows(SqlSecurityException.class,
+ () -> validator.validate("SELECT * FROM hertzbeat_logs JOIN
users ON hertzbeat_logs.user_id = users.id"));
+ }
+
+ @Test
+ void testUnauthorizedTableInSubquery() {
+ assertThrows(SqlSecurityException.class,
+ () -> validator.validate("SELECT * FROM hertzbeat_logs WHERE
user_id IN (SELECT id FROM users)"));
+ }
+
+ @Test
+ void testTableWithQuotes() {
+ assertDoesNotThrow(() -> validator.validate("SELECT * FROM
\"hertzbeat_logs\""));
+ assertDoesNotThrow(() -> validator.validate("SELECT * FROM
`hertzbeat_logs`"));
+ }
+
+ @Test
+ void testEmptyAllowedTables() {
+ SqlSecurityValidator emptyValidator = new
SqlSecurityValidator(Collections.emptyList());
+ assertThrows(SqlSecurityException.class,
+ () -> emptyValidator.validate("SELECT * FROM any_table"));
+ }
+
+ @Test
+ void testNullAllowedTables() {
+ SqlSecurityValidator nullValidator = new SqlSecurityValidator(null);
+ assertThrows(SqlSecurityException.class,
+ () -> nullValidator.validate("SELECT * FROM any_table"));
+ }
+
+ @Test
+ void testComplexSelectWithAggregation() {
+ assertDoesNotThrow(() -> validator.validate(
+ "SELECT level, COUNT(*) as cnt FROM hertzbeat_logs GROUP BY
level HAVING COUNT(*) > 10 ORDER BY cnt DESC LIMIT 100"));
+ }
+
+ @Test
+ void testSelectWithUnionNotAllowed() {
+ assertThrows(SqlSecurityException.class,
+ () -> validator.validate("SELECT * FROM hertzbeat_logs UNION
SELECT * FROM app_logs"));
+ }
+
+ @Test
+ void testSelectWithUnionAllNotAllowed() {
+ assertThrows(SqlSecurityException.class,
+ () -> validator.validate("SELECT * FROM hertzbeat_logs UNION
ALL SELECT * FROM app_logs"));
+ }
+
+ @Test
+ void testSelectWithIntersectNotAllowed() {
+ assertThrows(SqlSecurityException.class,
+ () -> validator.validate("SELECT * FROM hertzbeat_logs
INTERSECT SELECT * FROM app_logs"));
+ }
+
+ @Test
+ void testSelectWithExceptNotAllowed() {
+ assertThrows(SqlSecurityException.class,
+ () -> validator.validate("SELECT * FROM hertzbeat_logs EXCEPT
SELECT * FROM app_logs"));
+ }
+
+ @Test
+ void testLateralSubqueryNotAllowed() {
+ assertThrows(SqlSecurityException.class,
+ () -> validator.validate("SELECT * FROM hertzbeat_logs,
LATERAL (SELECT * FROM app_logs) AS t"));
+ }
+
+ @Test
+ void testWithClauseNotAllowed() {
+ assertThrows(SqlSecurityException.class,
+ () -> validator.validate("WITH cte AS (SELECT * FROM
hertzbeat_logs) SELECT * FROM cte"));
+ }
+
+ @Test
+ void testSqlInjectionAttemptDropTable() {
+ assertThrows(SqlSecurityException.class,
+ () -> validator.validate("DROP TABLE users"));
+ }
+
+ @Test
+ void testSqlInjectionAttemptUnauthorizedTable() {
+ assertThrows(SqlSecurityException.class,
+ () -> validator.validate("SELECT * FROM users"));
+ }
+
+ @Test
+ void testSqlInjectionAttemptDeleteFrom() {
+ assertThrows(SqlSecurityException.class,
+ () -> validator.validate("DELETE FROM hertzbeat_logs WHERE
1=1"));
+ }
+
+ @Test
+ void testBypassInSelectItems() {
+ assertThrows(SqlSecurityException.class, () -> validator.validate(
+ "SELECT (SELECT password FROM secret_table) FROM hertzbeat_logs"));
+ }
+
+ @Test
+ void testBypassInWhereClauseAnd() {
+ assertThrows(SqlSecurityException.class, () -> validator.validate(
+ "SELECT * FROM hertzbeat_logs WHERE 1=1 AND id IN (SELECT id FROM
secret_table)"));
+ }
+
+ @Test
+ void testBypassInFunction() {
+ assertThrows(SqlSecurityException.class, () -> validator.validate(
+ "SELECT * FROM hertzbeat_logs WHERE id = abs((SELECT count(*) FROM
secret_table))"));
+ }
+
+ @Test
+ void testBypassInCaseWhen() {
+ assertThrows(SqlSecurityException.class, () -> validator.validate(
+ "SELECT * FROM hertzbeat_logs WHERE status = (CASE WHEN (SELECT 1
FROM secret_table)=1 THEN 1 ELSE 0 END)"));
+ }
+
+ @Test
+ void testBypassWithAndExpression() {
+ assertThrows(SqlSecurityException.class, () -> validator.validate(
+ "SELECT * FROM hertzbeat_logs WHERE 1=1 AND id = (SELECT id FROM
secret_table)"));
+ }
+
+ @Test
+ void testBypassWithGreaterThan() {
+ assertThrows(SqlSecurityException.class, () -> validator.validate(
+ "SELECT * FROM hertzbeat_logs WHERE id > (SELECT count(*) FROM
secret_table)"));
+ }
+
+ @Test
+ void testBypassWithBetween() {
+ assertThrows(SqlSecurityException.class, () -> validator.validate(
+ "SELECT * FROM hertzbeat_logs WHERE id BETWEEN 1 AND (SELECT id
FROM secret_table)"));
+ }
+
+ @Test
+ void testBypassWithMathOperations() {
+ assertThrows(SqlSecurityException.class, () -> validator.validate(
+ "SELECT * FROM hertzbeat_logs WHERE id = 1 + (SELECT id FROM
secret_table)"));
+ }
+}
diff --git
a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/constants/WarehouseConstants.java
b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/constants/WarehouseConstants.java
index ad9cfb8253..45ad3f038f 100644
---
a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/constants/WarehouseConstants.java
+++
b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/constants/WarehouseConstants.java
@@ -68,4 +68,6 @@ public interface WarehouseConstants {
String INSTANT = "instant";
+ String LOG_TABLE_NAME = "hertzbeat_logs";
+
}
diff --git
a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/db/GreptimeSqlQueryExecutor.java
b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/db/GreptimeSqlQueryExecutor.java
index 7e4c64341b..9d874868c6 100644
---
a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/db/GreptimeSqlQueryExecutor.java
+++
b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/db/GreptimeSqlQueryExecutor.java
@@ -63,61 +63,64 @@ public class GreptimeSqlQueryExecutor extends
SqlQueryExecutor {
@Override
public List<Map<String, Object>> execute(String queryString) {
List<Map<String, Object>> results = new LinkedList<>();
- try {
- HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
- headers.setAccept(List.of(MediaType.APPLICATION_JSON));
- if (StringUtils.hasText(greptimeProperties.username())
- && StringUtils.hasText(greptimeProperties.password())) {
- String authStr = greptimeProperties.username() + ":" +
greptimeProperties.password();
- String encodedAuth = Base64Util.encode(authStr);
- headers.add(HttpHeaders.AUTHORIZATION, NetworkConstants.BASIC
+ SignConstants.BLANK + encodedAuth);
- }
- String requestBody = "sql=" + queryString;
- HttpEntity<String> httpEntity = new HttpEntity<>(requestBody,
headers);
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+ headers.setAccept(List.of(MediaType.APPLICATION_JSON));
+ if (StringUtils.hasText(greptimeProperties.username())
+ && StringUtils.hasText(greptimeProperties.password())) {
+ String authStr = greptimeProperties.username() + ":" +
greptimeProperties.password();
+ String encodedAuth = Base64Util.encode(authStr);
+ headers.add(HttpHeaders.AUTHORIZATION, NetworkConstants.BASIC +
SignConstants.BLANK + encodedAuth);
+ }
+
+ String requestBody = "sql=" + queryString;
+ HttpEntity<String> httpEntity = new HttpEntity<>(requestBody, headers);
- String url = greptimeProperties.httpEndpoint() + QUERY_PATH;
- if (StringUtils.hasText(greptimeProperties.database())) {
- url += "?db=" + greptimeProperties.database();
- }
+ String url = greptimeProperties.httpEndpoint() + QUERY_PATH;
+ if (StringUtils.hasText(greptimeProperties.database())) {
+ url += "?db=" + greptimeProperties.database();
+ }
- ResponseEntity<GreptimeSqlQueryContent> responseEntity =
restTemplate.exchange(url,
+ ResponseEntity<GreptimeSqlQueryContent> responseEntity;
+ try {
+ responseEntity = restTemplate.exchange(url,
HttpMethod.POST, httpEntity,
GreptimeSqlQueryContent.class);
+ } catch (Exception e) {
+ log.error("Exception occurred while querying GreptimeDB SQL: {}",
e.getMessage(), e);
+ throw new RuntimeException("Failed to execute GreptimeDB SQL
query", e);
+ }
- if (responseEntity.getStatusCode().is2xxSuccessful()) {
- GreptimeSqlQueryContent responseBody =
responseEntity.getBody();
- if (responseBody != null && responseBody.getCode() == 0
- && responseBody.getOutput() != null &&
!responseBody.getOutput().isEmpty()) {
-
- for (GreptimeSqlQueryContent.Output output :
responseBody.getOutput()) {
- if (output.getRecords() != null &&
output.getRecords().getRows() != null) {
- GreptimeSqlQueryContent.Output.Records.Schema
schema = output.getRecords().getSchema();
- List<List<Object>> rows =
output.getRecords().getRows();
-
- for (List<Object> row : rows) {
- Map<String, Object> rowMap = new HashMap<>();
- if (schema != null &&
schema.getColumnSchemas() != null) {
- for (int i = 0; i <
Math.min(schema.getColumnSchemas().size(), row.size()); i++) {
- String columnName =
schema.getColumnSchemas().get(i).getName();
- Object value = row.get(i);
- rowMap.put(columnName, value);
- }
- } else {
- for (int i = 0; i < row.size(); i++) {
- rowMap.put("col_" + i, row.get(i));
- }
+ if (responseEntity.getStatusCode().is2xxSuccessful()) {
+ GreptimeSqlQueryContent responseBody = responseEntity.getBody();
+ if (responseBody != null && responseBody.getCode() == 0
+ && responseBody.getOutput() != null &&
!responseBody.getOutput().isEmpty()) {
+
+ for (GreptimeSqlQueryContent.Output output :
responseBody.getOutput()) {
+ if (output.getRecords() != null &&
output.getRecords().getRows() != null) {
+ GreptimeSqlQueryContent.Output.Records.Schema schema =
output.getRecords().getSchema();
+ List<List<Object>> rows =
output.getRecords().getRows();
+
+ for (List<Object> row : rows) {
+ Map<String, Object> rowMap = new HashMap<>();
+ if (schema != null && schema.getColumnSchemas() !=
null) {
+ for (int i = 0; i <
Math.min(schema.getColumnSchemas().size(), row.size()); i++) {
+ String columnName =
schema.getColumnSchemas().get(i).getName();
+ Object value = row.get(i);
+ rowMap.put(columnName, value);
+ }
+ } else {
+ for (int i = 0; i < row.size(); i++) {
+ rowMap.put("col_" + i, row.get(i));
}
- results.add(rowMap);
}
+ results.add(rowMap);
}
}
}
- } else {
- log.error("query metrics data from greptime failed. {}",
responseEntity);
}
- } catch (Exception e) {
- log.error("query metrics data from greptime error. {}",
e.getMessage(), e);
+ } else {
+ log.error("query metrics data from greptime failed. {}",
responseEntity);
}
return results;
}
diff --git
a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/greptime/GreptimeDbDataStorage.java
b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/greptime/GreptimeDbDataStorage.java
index 3fe4aafd31..ada81ec742 100644
---
a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/greptime/GreptimeDbDataStorage.java
+++
b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/greptime/GreptimeDbDataStorage.java
@@ -61,6 +61,7 @@ import org.apache.hertzbeat.common.entity.message.CollectRep;
import org.apache.hertzbeat.common.util.Base64Util;
import org.apache.hertzbeat.common.util.JsonUtil;
import org.apache.hertzbeat.common.util.TimePeriodUtil;
+import org.apache.hertzbeat.warehouse.constants.WarehouseConstants;
import
org.apache.hertzbeat.warehouse.store.history.tsdb.AbstractHistoryDataStorage;
import org.apache.hertzbeat.warehouse.store.history.tsdb.vm.PromQlQueryContent;
import org.apache.hertzbeat.warehouse.db.GreptimeSqlQueryExecutor;
@@ -90,7 +91,7 @@ public class GreptimeDbDataStorage extends
AbstractHistoryDataStorage {
private static final String LABEL_KEY_NAME = "__name__";
private static final String LABEL_KEY_FIELD = "__field__";
private static final String LABEL_KEY_INSTANCE = "instance";
- private static final String LOG_TABLE_NAME = "hertzbeat_logs";
+ private static final String LOG_TABLE_NAME =
WarehouseConstants.LOG_TABLE_NAME;
private static final String LABEL_KEY_START_TIME = "start";
private static final String LABEL_KEY_END_TIME = "end";
private static final int LOG_BATCH_SIZE = 500;
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 99f83855ae..1445ed5f0c 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
@@ -21,7 +21,7 @@ package org.apache.hertzbeat.warehouse.db;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
-import static org.junit.jupiter.api.Assertions.assertTrue;
+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.when;
@@ -62,7 +62,7 @@ class GreptimeSqlQueryExecutorTest {
when(greptimeProperties.database()).thenReturn("hertzbeat");
when(greptimeProperties.username()).thenReturn("username");
when(greptimeProperties.password()).thenReturn("password");
-
+
greptimeSqlQueryExecutor = new
GreptimeSqlQueryExecutor(greptimeProperties, restTemplate);
}
@@ -70,7 +70,7 @@ class GreptimeSqlQueryExecutorTest {
void testExecuteSuccess() {
// Mock successful response
GreptimeSqlQueryContent mockResponse = createMockResponse();
- ResponseEntity<GreptimeSqlQueryContent> responseEntity =
+ ResponseEntity<GreptimeSqlQueryContent> responseEntity =
new ResponseEntity<>(mockResponse, HttpStatus.OK);
when(restTemplate.exchange(
@@ -101,11 +101,7 @@ class GreptimeSqlQueryExecutorTest {
)).thenThrow(new RuntimeException("Connection error"));
// Execute
- List<Map<String, Object>> result =
greptimeSqlQueryExecutor.execute("SELECT * FROM metrics");
-
- // Verify returns empty list on error
- assertNotNull(result);
- assertTrue(result.isEmpty());
+ assertThrows(RuntimeException.class, () ->
greptimeSqlQueryExecutor.execute("SELECT * FROM metrics"));
}
private GreptimeSqlQueryContent createMockResponse() {
@@ -117,7 +113,7 @@ class GreptimeSqlQueryExecutorTest {
columnSchemas.add(new
GreptimeSqlQueryContent.Output.Records.Schema.ColumnSchema("metric_name",
"String"));
columnSchemas.add(new
GreptimeSqlQueryContent.Output.Records.Schema.ColumnSchema("value", "Float64"));
- GreptimeSqlQueryContent.Output.Records.Schema schema =
+ GreptimeSqlQueryContent.Output.Records.Schema schema =
new GreptimeSqlQueryContent.Output.Records.Schema();
schema.setColumnSchemas(columnSchemas);
@@ -126,7 +122,7 @@ class GreptimeSqlQueryExecutorTest {
rows.add(List.of("cpu", 85.5));
// Build response structure
- GreptimeSqlQueryContent.Output.Records records =
+ GreptimeSqlQueryContent.Output.Records records =
new GreptimeSqlQueryContent.Output.Records();
records.setSchema(schema);
records.setRows(rows);
diff --git a/material/licenses/backend/LICENSE
b/material/licenses/backend/LICENSE
index 826b50efca..12f85ea0e7 100644
--- a/material/licenses/backend/LICENSE
+++ b/material/licenses/backend/LICENSE
@@ -212,6 +212,7 @@ The text of each license is the standard Apache 2.0 license.
https://mvnrepository.com/artifact/com.fasterxml.woodstox/woodstox-core/6.5.1
Apache-2.0
https://mvnrepository.com/artifact/com.fasterxml/classmate/1.6.0 Apache-2.0
https://mvnrepository.com/artifact/com.github.ben-manes.caffeine/caffeine/2.9.3
Apache-2.0
+ https://mvnrepository.com/artifact/com.github.jsqlparser/jsqlparser/5.1
Apache-2.0
https://mvnrepository.com/artifact/com.google.android/annotations/4.1.1.4
Apache-2.0
https://mvnrepository.com/artifact/com.google.api.grpc/proto-google-common-protos/2.17.0
Apache-2.0
https://mvnrepository.com/artifact/com.google.code.findbugs/jsr305/3.0.2
Apache-2.0
diff --git a/pom.xml b/pom.xml
index 5897eb51b6..08ad1b0a1b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -34,7 +34,7 @@
<url>https://hertzbeat.apache.org/</url>
<description>
- Apache HertzBeat™, a real-time observability system with agentless,
performance cluster, prometheus-compatible,
+ Apache HertzBeat™, a real-time observability system with agentless,
performance cluster, prometheus-compatible,
custom monitoring and status page building capabilities.
</description>
@@ -116,6 +116,7 @@
<springdoc.version>2.8.5</springdoc.version>
<spring-boot-starter-sureness.version>1.1.0</spring-boot-starter-sureness.version>
<javaparser.version>3.26.1</javaparser.version>
+ <jsqlparser.version>5.1</jsqlparser.version>
<nekohtml.version>1.9.22</nekohtml.version>
<json-path.version>2.9.0</json-path.version>
<gson.version>2.10.1</gson.version>
@@ -329,6 +330,11 @@
<artifactId>commons-jexl3</artifactId>
<version>${commons-jexl3}</version>
</dependency>
+ <dependency>
+ <groupId>com.github.jsqlparser</groupId>
+ <artifactId>jsqlparser</artifactId>
+ <version>${jsqlparser.version}</version>
+ </dependency>
<!-- database migration -->
<dependency>
<groupId>org.flywaydb</groupId>
diff --git
a/web-app/src/app/routes/alert/alert-setting/alert-setting.component.html
b/web-app/src/app/routes/alert/alert-setting/alert-setting.component.html
index 2cd18a9f29..f1e726e893 100644
--- a/web-app/src/app/routes/alert/alert-setting/alert-setting.component.html
+++ b/web-app/src/app/routes/alert/alert-setting/alert-setting.component.html
@@ -198,7 +198,7 @@
</nz-form-control>
</nz-form-item>
<!-- Data Type Selection -->
- <nz-form-item>
+ <nz-form-item *ngIf="isManageModalAdd">
<nz-form-label nzSpan="7" nzRequired="true" nzFor="dataType"
[nzTooltipTitle]="'alert.setting.datatype.tip' | i18n">
{{ 'alert.setting.datatype' | i18n }}
</nz-form-label>
@@ -733,19 +733,14 @@
<nz-form-item *ngIf="define.type == 'periodic_log'">
<nz-form-label [nzSpan]="7" [nzNoColon]="true"
nzFor="periodic_expr"></nz-form-label>
<nz-form-control [nzSpan]="12" [nzErrorTip]="'validation.required' |
i18n">
- <nz-textarea-count [nzMaxCharacterCount]="100">
- <textarea
- [(ngModel)]="define.expr"
- required
- rows="3"
- nz-input
- name="periodic_expr"
- id="periodic_expr"
- [placeholder]="'alert.setting.log.query.placeholder' | i18n"
- >
- </textarea>
- </nz-textarea-count>
- <button nz-button nzType="primary" style="width: 100%;
margin-bottom: 10px" (click)="onPreviewLogExpr()">
+ <app-sql-editor
+ [(ngModel)]="define.expr"
+ name="periodic_expr"
+ [height]="'150px'"
+ tableName="hertzbeat_logs"
+ required
+ ></app-sql-editor>
+ <button nz-button nzType="primary" style="width: 100%; margin-top:
10px; margin-bottom: 10px" (click)="onPreviewLogExpr()">
<i nz-icon nzType="eye" nzTheme="outline"></i>
{{ 'common.preview.button' | i18n }}
</button>
diff --git
a/web-app/src/app/routes/alert/alert-setting/alert-setting.component.ts
b/web-app/src/app/routes/alert/alert-setting/alert-setting.component.ts
index 776320e040..8e51fc531e 100644
--- a/web-app/src/app/routes/alert/alert-setting/alert-setting.component.ts
+++ b/web-app/src/app/routes/alert/alert-setting/alert-setting.component.ts
@@ -136,7 +136,7 @@ export class AlertSettingComponent implements OnInit {
previewData: any[] = [];
previewColumns: Array<{ title: string; key: string; width?: string }> = [];
previewTableLoading = false;
-
+ private defaultSql = `SELECT count(*) AS errorCount FROM hertzbeat_logs
WHERE time_unix_nano >= NOW() - INTERVAL '30 second' AND severity_text =
'ERROR' HAVING count(*) > 2`;
/**
* Initialize log fields(todo: from backend api)
*/
@@ -346,7 +346,14 @@ export class AlertSettingComponent implements OnInit {
}
private updateAlertDefineType() {
- // Combine main type with data type
+ // First: Reset form state when switching data source type
+ this.userExpr = '';
+ this.cascadeValues = [];
+ this.currentMetrics = [];
+ this.resetQbDataDefault();
+ this.clearPreview();
+
+ // Second: Init state when switching data source type
if (this.alertType === 'realtime' && this.dataType === 'metric') {
this.define.type = 'realtime_metric';
} else if (this.alertType === 'realtime' && this.dataType === 'log') {
@@ -358,14 +365,9 @@ export class AlertSettingComponent implements OnInit {
} else if (this.alertType === 'periodic' && this.dataType === 'log') {
this.define.type = 'periodic_log';
this.define.datasource = 'sql';
+ this.define.expr = this.defaultSql;
this.updateLogQbConfig();
}
-
- // Reset form state when switching data source type
- this.userExpr = '';
- this.cascadeValues = [];
- this.currentMetrics = [];
- this.resetQbDataDefault();
}
onSelectTypeModalCancel() {
@@ -1027,6 +1029,9 @@ export class AlertSettingComponent implements OnInit {
onManageModalCancel() {
this.cascadeValues = [];
this.isExpr = false;
+ if (this.isManageModalAdd) {
+ this.dataType = 'metric';
+ }
this.resetQbDataDefault();
this.isManageModalVisible = false;
}
diff --git
a/web-app/src/app/shared/components/sql-editor/sql-editor.component.html
b/web-app/src/app/shared/components/sql-editor/sql-editor.component.html
new file mode 100644
index 0000000000..aab273844e
--- /dev/null
+++ b/web-app/src/app/shared/components/sql-editor/sql-editor.component.html
@@ -0,0 +1,28 @@
+<!--
+ ~ 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.
+-->
+
+<div class="sql-editor-wrapper" [style.height]="height">
+ <nz-code-editor
+ class="sql-editor"
+ [ngModel]="code"
+ (ngModelChange)="onCodeChange($event)"
+ [nzEditorOption]="editorOption"
+ (nzEditorInitialized)="onEditorInit($event)"
+ ></nz-code-editor>
+</div>
diff --git
a/web-app/src/app/shared/components/sql-editor/sql-editor.component.less
b/web-app/src/app/shared/components/sql-editor/sql-editor.component.less
new file mode 100644
index 0000000000..b86b9dc9ca
--- /dev/null
+++ b/web-app/src/app/shared/components/sql-editor/sql-editor.component.less
@@ -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.
+ */
+
+.sql-editor-wrapper {
+ border: 1px solid #d9d9d9;
+ border-radius: 4px;
+ width: 100%;
+ position: relative;
+
+ &:hover {
+ border-color: #40a9ff;
+ }
+
+ &:focus-within {
+ border-color: #40a9ff;
+ box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
+ }
+
+ .sql-editor {
+ height: 100%;
+ width: 100%;
+ }
+}
+
+:host {
+ display: block;
+ position: relative;
+}
+
+:host ::ng-deep {
+ .monaco-editor {
+ .margin {
+ background-color: #fafafa;
+ }
+ }
+
+ .monaco-editor .suggest-widget {
+ z-index: 9999 !important;
+
+ .suggest-widget-status-bar {
+ display: none !important;
+ }
+ }
+
+ .monaco-editor .editor-widget {
+ z-index: 9999 !important;
+ }
+
+ .monaco-editor .overflowingContentWidgets {
+ z-index: 9999 !important;
+ }
+}
diff --git
a/web-app/src/app/shared/components/sql-editor/sql-editor.component.ts
b/web-app/src/app/shared/components/sql-editor/sql-editor.component.ts
new file mode 100644
index 0000000000..ece97fc719
--- /dev/null
+++ b/web-app/src/app/shared/components/sql-editor/sql-editor.component.ts
@@ -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 (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.
+ */
+
+import { Component, EventEmitter, forwardRef, Input, OnDestroy, Output } from
'@angular/core';
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
+
+declare const monaco: any;
+
+export interface SqlValidationError {
+ message: string;
+ startLine: number;
+ startColumn: number;
+ endLine: number;
+ endColumn: number;
+}
+
+export interface LogTableColumn {
+ name: string;
+ type: string;
+ description?: string;
+}
+
+@Component({
+ selector: 'app-sql-editor',
+ templateUrl: './sql-editor.component.html',
+ styleUrls: ['./sql-editor.component.less'],
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => SqlEditorComponent),
+ multi: true
+ }
+ ]
+})
+export class SqlEditorComponent implements OnDestroy, ControlValueAccessor {
+ @Input() height: string = '120px';
+ @Input() tableName: string = 'hertzbeat_logs';
+
+ @Output() readonly editorInit = new EventEmitter<any>();
+ @Output() readonly validationChange = new
EventEmitter<SqlValidationError[]>();
+
+ code: string = '';
+ private editorInstance: any;
+ private validationTimeout: any;
+ editorOption: any = {
+ language: 'sql',
+ theme: 'vs',
+ minimap: { enabled: false },
+ lineNumbers: 'on',
+ scrollBeyondLastLine: false,
+ automaticLayout: true,
+ folding: false,
+ wordWrap: 'on',
+ fontSize: 13,
+ tabSize: 2,
+ suggestOnTriggerCharacters: true,
+ quickSuggestions: true,
+ wordBasedSuggestions: false,
+ fixedOverflowWidgets: true,
+ overviewRulerLanes: 0
+ };
+
+ private completionProvider: any;
+ private onChange: (value: string) => void = () => {};
+ private onTouched: () => void = () => {};
+
+ private readonly LOG_TABLE_COLUMNS: LogTableColumn[] = [
+ { name: 'time_unix_nano', type: 'TimestampNanosecond', description: 'Log
timestamp in nanoseconds' },
+ { name: 'observed_time_unix_nano', type: 'TimestampNanosecond',
description: 'Observed timestamp in nanoseconds' },
+ { name: 'severity_number', type: 'Int32', description: 'Severity level
number (1-24)' },
+ { name: 'severity_text', type: 'String', description: 'Severity text
(DEBUG, INFO, WARN, ERROR, etc.)' },
+ { name: 'body', type: 'Json', description: 'Log message body content' },
+ { name: 'trace_id', type: 'String', description: 'Distributed tracing
trace ID' },
+ { name: 'span_id', type: 'String', description: 'Distributed tracing span
ID' },
+ { name: 'trace_flags', type: 'Int32', description: 'Trace flags' },
+ { name: 'attributes', type: 'Json', description: 'Log attributes as JSON'
},
+ { name: 'resource', type: 'Json', description: 'Resource information as
JSON' },
+ { name: 'instrumentation_scope', type: 'Json', description:
'Instrumentation scope information' },
+ { name: 'dropped_attributes_count', type: 'Int32', description: 'Number of
dropped attributes' }
+ ];
+
+ ngOnDestroy(): void {
+ if (this.completionProvider) {
+ this.completionProvider.dispose();
+ }
+ if (this.validationTimeout) {
+ clearTimeout(this.validationTimeout);
+ }
+ }
+
+ onEditorInit(editor: any): void {
+ this.editorInstance = editor;
+ this.registerCompletionProvider();
+ this.configureSuggestWidget(editor);
+ this.editorInit.emit(editor);
+ }
+
+ private configureSuggestWidget(editor: any): void {
+ if (typeof monaco === 'undefined') {
+ return;
+ }
+ try {
+ editor.updateOptions({
+ suggest: {
+ maxVisibleSuggestions: 8,
+ showStatusBar: false,
+ preview: false
+ }
+ });
+ } catch (e) {
+ console.warn('Failed to configure suggest widget:', e);
+ }
+ }
+
+ writeValue(value: string): void {
+ this.code = value || '';
+ }
+
+ registerOnChange(fn: (value: string) => void): void {
+ this.onChange = fn;
+ }
+
+ registerOnTouched(fn: () => void): void {
+ this.onTouched = fn;
+ }
+
+ onCodeChange(value: string): void {
+ this.code = value;
+ this.onChange(value);
+ this.onTouched();
+ this.validateSqlWithDebounce(value);
+ }
+
+ private validateSqlWithDebounce(sql: string): void {
+ if (this.validationTimeout) {
+ clearTimeout(this.validationTimeout);
+ }
+ this.validationTimeout = setTimeout(() => {
+ const errors = this.validateSql(sql);
+ this.setEditorMarkers(errors);
+ this.validationChange.emit(errors);
+ }, 500);
+ }
+
+ /**
+ * SQL Security Validator - Simple regex-based validation
+ * Mirrors backend SqlSecurityValidator security policy:
+ * 1. Only SELECT statements are allowed
+ * 2. Table must be in whitelist
+ * 3. Dangerous patterns (subqueries, UNION, CTE) are blocked
+ * Note: This is for UX feedback only, real security is enforced by backend
+ */
+ private validateSql(sql: string): SqlValidationError[] {
+ const errors: SqlValidationError[] = [];
+ if (!sql || !sql.trim()) {
+ return errors;
+ }
+
+ const lines = sql.split('\n');
+ const lastLine = lines.length;
+ const lastCol = lines[lastLine - 1].length + 1;
+
+ // Remove string literals and comments to avoid false positives
+ const sanitizedSql = this.removeStringsAndComments(sql);
+ const upperSql = sanitizedSql.toUpperCase().trim();
+
+ // Helper to create error
+ const addError = (message: string) => {
+ errors.push({
+ message,
+ startLine: 1,
+ startColumn: 1,
+ endLine: lastLine,
+ endColumn: lastCol
+ });
+ };
+
+ // 1. Must start with SELECT
+ if (!upperSql.startsWith('SELECT')) {
+ addError('Only SELECT statements are allowed');
+ return errors;
+ }
+
+ // 2. Check for dangerous statements
+ const dangerousPatterns = [
+ { pattern:
/\b(INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER|CREATE|GRANT|REVOKE)\b/i, message:
'Only SELECT statements are allowed' },
+ { pattern: /\bUNION\b/i, message: 'UNION is not allowed' },
+ { pattern: /\bINTERSECT\b/i, message: 'INTERSECT is not allowed' },
+ { pattern: /\bEXCEPT\b/i, message: 'EXCEPT is not allowed' },
+ { pattern: /^\s*WITH\b/i, message: 'CTE (WITH clause) is not allowed' }
+ ];
+
+ for (const { pattern, message } of dangerousPatterns) {
+ if (pattern.test(sanitizedSql)) {
+ addError(message);
+ return errors;
+ }
+ }
+
+ // 3. Check for subqueries (SELECT inside parentheses, but not in function
calls)
+ if (this.hasSubquery(sanitizedSql)) {
+ addError('Subqueries are not allowed');
+ return errors;
+ }
+
+ // 4. Must have FROM clause
+ if (!/\bFROM\b/i.test(sanitizedSql)) {
+ addError('SQL query must contain FROM clause');
+ return errors;
+ }
+
+ // 5. Validate table name
+ const tableMatch =
sanitizedSql.match(/\bFROM\s+([a-zA-Z_][a-zA-Z0-9_]*)/i);
+ if (tableMatch) {
+ const tableName = tableMatch[1];
+ if (tableName.toLowerCase() !== this.tableName.toLowerCase()) {
+ addError(`Access to table '${tableName}' is not allowed. Allowed
table: ${this.tableName}`);
+ }
+ }
+
+ // 6. Basic syntax checks
+ const openParens = (sanitizedSql.match(/\(/g) || []).length;
+ const closeParens = (sanitizedSql.match(/\)/g) || []).length;
+ if (openParens !== closeParens) {
+ addError('Mismatched parentheses');
+ }
+
+ return errors;
+ }
+
+ /**
+ * Remove string literals and comments to avoid false positives in pattern
matching
+ */
+ private removeStringsAndComments(sql: string): string {
+ return (
+ sql
+ // Remove single-quoted strings (handle escaped quotes)
+ .replace(/'(?:[^'\\]|\\.)*'/g, "''")
+ // Remove double-quoted strings
+ .replace(/"(?:[^"\\]|\\.)*"/g, '""')
+ // Remove single-line comments
+ .replace(/--.*$/gm, '')
+ // Remove multi-line comments
+ .replace(/\/\*[\s\S]*?\*\//g, '')
+ );
+ }
+
+ /**
+ * Check for subqueries (nested SELECT statements)
+ * Detects SELECT inside parentheses that's not part of IN(...values...)
+ */
+ private hasSubquery(sql: string): boolean {
+ // Pattern to detect SELECT inside parentheses
+ const subqueryPattern = /\(\s*SELECT\b/i;
+ return subqueryPattern.test(sql);
+ }
+
+ private setEditorMarkers(errors: SqlValidationError[]): void {
+ if (typeof monaco === 'undefined' || !this.editorInstance) {
+ return;
+ }
+
+ const model = this.editorInstance.getModel();
+ if (!model) {
+ return;
+ }
+
+ const markers = errors.map(error => ({
+ severity: monaco.MarkerSeverity.Error,
+ message: error.message,
+ startLineNumber: error.startLine,
+ startColumn: error.startColumn,
+ endLineNumber: error.endLine,
+ endColumn: error.endColumn
+ }));
+
+ monaco.editor.setModelMarkers(model, 'sql-validator', markers);
+ }
+
+ private registerCompletionProvider(): void {
+ if (typeof monaco === 'undefined') {
+ return;
+ }
+
+ if (this.completionProvider) {
+ this.completionProvider.dispose();
+ }
+
+ this.completionProvider =
monaco.languages.registerCompletionItemProvider('sql', {
+ triggerCharacters: [' ', '.', ',', '(', '\n'],
+ provideCompletionItems: (model: any, position: any) => {
+ const textUntilPosition = model.getValueInRange({
+ startLineNumber: 1,
+ startColumn: 1,
+ endLineNumber: position.lineNumber,
+ endColumn: position.column
+ });
+
+ const word = model.getWordUntilPosition(position);
+ const range = {
+ startLineNumber: position.lineNumber,
+ endLineNumber: position.lineNumber,
+ startColumn: word.startColumn,
+ endColumn: word.endColumn
+ };
+
+ const suggestions: any[] = [];
+ const upperText = textUntilPosition.toUpperCase();
+
+ const isAfterFrom = /FROM\s+$/i.test(textUntilPosition) ||
/FROM\s+\w*$/i.test(textUntilPosition);
+ const isAfterSelect = /SELECT\s+$/i.test(textUntilPosition) ||
/SELECT\s+.*,\s*$/i.test(textUntilPosition);
+ const isAfterWhere =
+ /WHERE\s+$/i.test(textUntilPosition) ||
/AND\s+$/i.test(textUntilPosition) || /OR\s+$/i.test(textUntilPosition);
+ const hasTableContext = new RegExp(this.tableName,
'i').test(textUntilPosition);
+ const isEmpty = !textUntilPosition.trim();
+
+ // Empty or start - show templates first
+ if (isEmpty) {
+ suggestions.push({
+ label: `SELECT * FROM ${this.tableName}`,
+ kind: monaco.languages.CompletionItemKind.Snippet,
+ insertText: `SELECT * FROM ${this.tableName} WHERE $0`,
+ insertTextRules:
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
+ detail: 'Query template',
+ sortText: '0',
+ range: range
+ });
+ suggestions.push({
+ label: `SELECT COUNT(*) FROM ${this.tableName}`,
+ kind: monaco.languages.CompletionItemKind.Snippet,
+ insertText: `SELECT COUNT(*) as count FROM ${this.tableName} WHERE
$0`,
+ insertTextRules:
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
+ detail: 'Count template',
+ sortText: '1',
+ range: range
+ });
+ }
+
+ // After FROM - only show table name
+ if (isAfterFrom) {
+ suggestions.push({
+ label: this.tableName,
+ kind: monaco.languages.CompletionItemKind.Class,
+ insertText: this.tableName,
+ detail: 'Log table',
+ sortText: '0',
+ range: range
+ });
+ return { suggestions };
+ }
+
+ // After SELECT - show columns and *
+ if (isAfterSelect) {
+ suggestions.push({
+ label: '*',
+ kind: monaco.languages.CompletionItemKind.Operator,
+ insertText: '*',
+ detail: 'All columns',
+ sortText: '0',
+ range: range
+ });
+ this.LOG_TABLE_COLUMNS.forEach((column, idx) => {
+ suggestions.push({
+ label: column.name,
+ kind: monaco.languages.CompletionItemKind.Field,
+ insertText: column.name,
+ detail: column.type,
+ sortText: `1${idx}`,
+ range: range
+ });
+ });
+ return { suggestions };
+ }
+
+ // After WHERE/AND/OR - show columns for conditions
+ if (isAfterWhere || hasTableContext) {
+ this.LOG_TABLE_COLUMNS.forEach((column, idx) => {
+ suggestions.push({
+ label: column.name,
+ kind: monaco.languages.CompletionItemKind.Field,
+ insertText: column.name,
+ detail: column.type,
+ sortText: `0${idx}`,
+ range: range
+ });
+ });
+ }
+
+ // Show relevant keywords based on context
+ const keywordsToShow = this.getContextKeywords(upperText);
+ keywordsToShow.forEach((keyword, idx) => {
+ suggestions.push({
+ label: keyword,
+ kind: monaco.languages.CompletionItemKind.Keyword,
+ insertText: keyword,
+ detail: 'SQL',
+ sortText: `2${idx}`,
+ range: range
+ });
+ });
+
+ return { suggestions };
+ }
+ });
+ }
+
+ private getContextKeywords(upperText: string): string[] {
+ if (!upperText.includes('SELECT')) {
+ return ['SELECT'];
+ }
+ if (!upperText.includes('FROM')) {
+ return ['FROM'];
+ }
+ if (!upperText.includes('WHERE') && upperText.includes('FROM')) {
+ return ['WHERE', 'ORDER BY', 'LIMIT', 'GROUP BY'];
+ }
+ if (upperText.includes('WHERE')) {
+ return ['AND', 'OR', 'NOT', 'IN', 'LIKE', 'BETWEEN', 'IS NULL', 'IS NOT
NULL', 'ORDER BY', 'LIMIT', 'GROUP BY'];
+ }
+ return ['SELECT', 'FROM', 'WHERE', 'AND', 'OR', 'ORDER BY', 'LIMIT'];
+ }
+}
diff --git a/web-app/src/app/shared/shared.module.ts
b/web-app/src/app/shared/shared.module.ts
index e111363ddb..1c2735e285 100644
--- a/web-app/src/app/shared/shared.module.ts
+++ b/web-app/src/app/shared/shared.module.ts
@@ -9,6 +9,7 @@ import { DelonFormModule } from '@delon/form';
import { AlainThemeModule } from '@delon/theme';
import { NzBreadCrumbModule } from 'ng-zorro-antd/breadcrumb';
import { NzButtonModule } from 'ng-zorro-antd/button';
+import { NzCodeEditorModule } from 'ng-zorro-antd/code-editor';
import { NzDividerComponent } from 'ng-zorro-antd/divider';
import { NzIconModule } from 'ng-zorro-antd/icon';
import { NzInputModule } from 'ng-zorro-antd/input';
@@ -28,6 +29,7 @@ import { LabelSelectorComponent } from
'./components/label-selector/label-select
import { MonitorSelectListComponent } from
'./components/monitor-select-list/monitor-select-list.component';
import { MonitorSelectMenuComponent } from
'./components/monitor-select-menu/monitor-select-menu.component';
import { MultiFuncInputComponent } from
'./components/multi-func-input/multi-func-input.component';
+import { SqlEditorComponent } from
'./components/sql-editor/sql-editor.component';
import { ToolbarComponent } from './components/toolbar/toolbar.component';
import { ElapsedTimePipe } from './pipe/elapsed-time.pipe';
import { I18nElsePipe } from './pipe/i18n-else.pipe';
@@ -44,7 +46,8 @@ const COMPONENTS: Array<Type<void>> = [
FormFieldComponent,
MonitorSelectMenuComponent,
MonitorSelectListComponent,
- LabelSelectorComponent
+ LabelSelectorComponent,
+ SqlEditorComponent
];
const DIRECTIVES: Array<Type<void>> = [TimezonePipe, I18nElsePipe,
ElapsedTimePipe];
@@ -70,6 +73,7 @@ const DIRECTIVES: Array<Type<void>> = [TimezonePipe,
I18nElsePipe, ElapsedTimePi
NzInputModule,
NzIconModule.forChild(icons),
NzSpinModule,
+ NzCodeEditorModule,
AiChatModule
],
declarations: [...COMPONENTS, ...DIRECTIVES, HelpMessageShowComponent],
diff --git a/web-app/src/assets/i18n/en-US.json
b/web-app/src/assets/i18n/en-US.json
index 0e4e09a42c..9a89c2fef0 100644
--- a/web-app/src/assets/i18n/en-US.json
+++ b/web-app/src/assets/i18n/en-US.json
@@ -318,7 +318,6 @@
"alert.setting.rule.string-value.place-holder": "Please input string",
"alert.setting.log.query": "Log Query",
"alert.setting.log.query.tip": "Configure log query conditions to filter
logs that need to be monitored",
- "alert.setting.log.query.placeholder": "Please enter log query conditions",
"alert.setting.log.expr": "Log Alert Expression",
"alert.setting.log.expr.tip": "Configure log alert trigger conditions based
on query results",
"alert.setting.log.expr.placeholder": "Please enter alert expression, e.g.:
count > 10",
diff --git a/web-app/src/assets/i18n/ja-JP.json
b/web-app/src/assets/i18n/ja-JP.json
index 485d539291..2c1d697e1d 100644
--- a/web-app/src/assets/i18n/ja-JP.json
+++ b/web-app/src/assets/i18n/ja-JP.json
@@ -303,7 +303,6 @@
"alert.setting.rule.string-value.place-holder": "文字列を入力してください",
"alert.setting.log.query": "ログクエリ",
"alert.setting.log.query.tip": "監視するログをフィルタリングするためのログクエリ条件を設定",
- "alert.setting.log.query.placeholder": "ログクエリ条件を入力してください",
"alert.setting.log.expr": "ログアラート式",
"alert.setting.log.expr.tip": "クエリ結果に基づいてログアラートトリガー条件を設定",
"alert.setting.log.expr.placeholder": "アラート式を入力してください。例: count > 10",
diff --git a/web-app/src/assets/i18n/pt-BR.json
b/web-app/src/assets/i18n/pt-BR.json
index f84f582eda..ac2bd8f590 100644
--- a/web-app/src/assets/i18n/pt-BR.json
+++ b/web-app/src/assets/i18n/pt-BR.json
@@ -38,7 +38,6 @@
"alert.setting.rule.string-value.place-holder": "Digite o texto",
"alert.setting.log.query": "Consulta de Log",
"alert.setting.log.query.tip": "Configure condições de consulta de log para
filtrar logs que precisam ser monitorados",
- "alert.setting.log.query.placeholder": "Digite as condições de consulta de
log",
"alert.setting.log.expr": "Expressão de Alerta de Log",
"alert.setting.log.expr.tip": "Configure condições de disparo de alerta de
log baseadas nos resultados da consulta",
"alert.setting.log.expr.placeholder": "Digite a expressão de alerta, ex:
count > 10",
diff --git a/web-app/src/assets/i18n/zh-CN.json
b/web-app/src/assets/i18n/zh-CN.json
index 31a7d4bcfc..ca614fefc9 100644
--- a/web-app/src/assets/i18n/zh-CN.json
+++ b/web-app/src/assets/i18n/zh-CN.json
@@ -318,7 +318,6 @@
"alert.setting.rule.string-value.place-holder": "请输入匹配字符串",
"alert.setting.log.query": "日志查询",
"alert.setting.log.query.tip": "配置日志查询条件,用于筛选需要监控的日志",
- "alert.setting.log.query.placeholder": "请输入日志查询条件",
"alert.setting.log.expr": "日志告警表达式",
"alert.setting.log.expr.tip": "配置日志告警触发条件,基于查询结果进行告警判断",
"alert.setting.log.expr.placeholder": "请输入告警表达式,例如: count > 10",
diff --git a/web-app/src/assets/i18n/zh-TW.json
b/web-app/src/assets/i18n/zh-TW.json
index 1dad50dc10..b5f277c382 100644
--- a/web-app/src/assets/i18n/zh-TW.json
+++ b/web-app/src/assets/i18n/zh-TW.json
@@ -298,7 +298,6 @@
"alert.setting.rule.string-value.place-holder": "請輸入匹配字符串",
"alert.setting.log.query": "日誌查詢",
"alert.setting.log.query.tip": "配置日誌查詢條件,用於篩選需要監控的日誌",
- "alert.setting.log.query.placeholder": "請輸入日誌查詢條件",
"alert.setting.log.expr": "日誌告警表達式",
"alert.setting.log.expr.tip": "配置日誌告警觸發條件,基於查詢結果進行告警判斷",
"alert.setting.log.expr.placeholder": "請輸入告警表達式,例如: count > 10",
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]