mirror of
https://github.com/dromara/hertzbeat.git
synced 2026-09-17 09:40:58 +00:00
[feat] Add an SQL editor and prevent SQL injection (#3900)
Signed-off-by: Yang Chen <1597081640@qq.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
+43
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+156
-1
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+32
@@ -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);
|
||||
}
|
||||
}
|
||||
+150
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+267
@@ -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)"));
|
||||
}
|
||||
}
|
||||
+2
@@ -68,4 +68,6 @@ public interface WarehouseConstants {
|
||||
|
||||
String INSTANT = "instant";
|
||||
|
||||
String LOG_TABLE_NAME = "hertzbeat_logs";
|
||||
|
||||
}
|
||||
|
||||
+47
-44
@@ -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<>();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
ResponseEntity<GreptimeSqlQueryContent> responseEntity;
|
||||
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);
|
||||
|
||||
String url = greptimeProperties.httpEndpoint() + QUERY_PATH;
|
||||
if (StringUtils.hasText(greptimeProperties.database())) {
|
||||
url += "?db=" + greptimeProperties.database();
|
||||
}
|
||||
|
||||
ResponseEntity<GreptimeSqlQueryContent> responseEntity = restTemplate.exchange(url,
|
||||
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()) {
|
||||
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 (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));
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
+2
-1
@@ -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;
|
||||
|
||||
+6
-10
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user