diff --git a/hertzbeat-analysis/pom.xml b/hertzbeat-analysis/pom.xml new file mode 100644 index 00000000000..3386feb60d7 --- /dev/null +++ b/hertzbeat-analysis/pom.xml @@ -0,0 +1,59 @@ + + + + + org.apache.hertzbeat + hertzbeat + 2.0-SNAPSHOT + + 4.0.0 + + + + + org.apache.hertzbeat + hertzbeat-common + + + org.apache.hertzbeat + hertzbeat-warehouse + + + org.springframework.boot + spring-boot-starter-web + + + org.apache.commons + commons-math3 + + + + + + hertzbeat-analysis + ${project.artifactId} + + + 17 + ${java.version} + ${java.version} + + + diff --git a/hertzbeat-analysis/src/main/java/org/apache/hertzbeat/analysis/AnalysisModule.java b/hertzbeat-analysis/src/main/java/org/apache/hertzbeat/analysis/AnalysisModule.java new file mode 100644 index 00000000000..9030da9f813 --- /dev/null +++ b/hertzbeat-analysis/src/main/java/org/apache/hertzbeat/analysis/AnalysisModule.java @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (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.analysis; + +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +/** + * Analysis Module Entry Configuration + */ +@Configuration +@ComponentScan("org.apache.hertzbeat.analysis") +public class AnalysisModule { +} diff --git a/hertzbeat-analysis/src/main/java/org/apache/hertzbeat/analysis/algorithm/NlinearModel.java b/hertzbeat-analysis/src/main/java/org/apache/hertzbeat/analysis/algorithm/NlinearModel.java new file mode 100644 index 00000000000..fbd47f8453e --- /dev/null +++ b/hertzbeat-analysis/src/main/java/org/apache/hertzbeat/analysis/algorithm/NlinearModel.java @@ -0,0 +1,195 @@ +/* + * 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.analysis.algorithm; + +import java.util.Arrays; +import org.apache.commons.math3.linear.Array2DRowRealMatrix; +import org.apache.commons.math3.linear.ArrayRealVector; +import org.apache.commons.math3.linear.LUDecomposition; +import org.apache.commons.math3.linear.RealMatrix; +import org.apache.commons.math3.linear.RealVector; +import org.apache.commons.math3.stat.descriptive.moment.StandardDeviation; + +/** + * Industrial-grade Robust NLinear Model. + * Uses Ridge Regression (L2 Regularization) to prevent overfitting and handle singular matrices. + * Note: This class is stateful and not thread-safe. A new instance should be created for each prediction task. + */ +public class NlinearModel { + + private static final int LOOKBACK_WINDOW = 30; + + /** + * Ridge regularization parameter (Lambda). + * A small positive value ensures the matrix is always invertible. + */ + private static final double RIDGE_LAMBDA = 0.01; + + private double[] weights; + private double stdDeviation; + private boolean isFlatLine = false; + private double lastValue = 0.0; + + public void train(double[] historyValues) { + if (historyValues == null || historyValues.length == 0) { + return; + } + + // 1. Critical Fix: Always capture the last value first. + // This ensures that even if we don't have enough data to train the full model, + // we can still fallback to a naive "last-value" prediction instead of returning 0. + this.lastValue = historyValues[historyValues.length - 1]; + + // Check if we have enough data for the sliding window approach + if (historyValues.length < LOOKBACK_WINDOW + 5) { + // Fallback: Calculate simple standard deviation for confidence interval + if (historyValues.length > 1) { + StandardDeviation stdDevCalc = new StandardDeviation(); + this.stdDeviation = stdDevCalc.evaluate(historyValues); + } else { + this.stdDeviation = 0.0; + } + return; + } + + // 2. Pre-check: Flat Line Detection + // If variance is 0 (or very close), logic is simple: prediction = last value + StandardDeviation stdDevCalc = new StandardDeviation(); + double historyStd = stdDevCalc.evaluate(historyValues); + if (historyStd < 0.0001) { + this.isFlatLine = true; + this.stdDeviation = 0.0; + return; + } + this.isFlatLine = false; + + // 3. Prepare Data for Ridge Regression + int n = historyValues.length; + int numSamples = n - LOOKBACK_WINDOW; + int numFeatures = LOOKBACK_WINDOW + 1; // +1 for Intercept + + // Matrix X: [Samples x Features] + // Vector Y: [Samples] + double[][] inputSamples = new double[numSamples][numFeatures]; + double[] targetValues = new double[numSamples]; + + for (int i = 0; i < numSamples; i++) { + double target = historyValues[i + LOOKBACK_WINDOW]; + double anchorValue = historyValues[i + LOOKBACK_WINDOW - 1]; // RevIN anchor + + targetValues[i] = target - anchorValue; // Normalize Y + + // Intercept term (always 1.0) + inputSamples[i][0] = 1.0; + + // Features (Past L points) + for (int j = 0; j < LOOKBACK_WINDOW; j++) { + inputSamples[i][j + 1] = historyValues[i + j] - anchorValue; // Normalize X + } + } + + // 4. Solve Ridge Regression: W = (X'X + lambda*I)^-1 * X'Y + try { + RealMatrix designMatrix = new Array2DRowRealMatrix(inputSamples); + RealVector targetVector = new ArrayRealVector(targetValues); + + RealMatrix transposedMatrix = designMatrix.transpose(); + RealMatrix gramMatrix = transposedMatrix.multiply(designMatrix); + + // Add Lambda to Diagonal (Ridge Regularization) + for (int i = 0; i < numFeatures; i++) { + gramMatrix.addToEntry(i, i, RIDGE_LAMBDA); + } + + // Solve + RealVector momentVector = transposedMatrix.operate(targetVector); + // LUDecomposition is fast and stable for square matrices + RealVector weightVector = new LUDecomposition(gramMatrix).getSolver().solve(momentVector); + + this.weights = weightVector.toArray(); + + // 5. Calculate Training Error (Residual StdDev) + double sumSquaredErrors = 0.0; + for (int i = 0; i < numSamples; i++) { + double prediction = 0.0; + for (int j = 0; j < numFeatures; j++) { + prediction += inputSamples[i][j] * weights[j]; + } + double error = targetValues[i] - prediction; + sumSquaredErrors += error * error; + } + // StdDev of residuals + this.stdDeviation = Math.sqrt(sumSquaredErrors / numSamples); + + } catch (RuntimeException e) { + // Fallback strategy: just predict the last value + this.isFlatLine = true; + this.stdDeviation = historyStd; // Use global std as uncertainty + } + } + + public PredictionResult[] predict(double[] recentHistory, int steps) { + // If untrained or logic fallback + if (isFlatLine || weights == null) { + PredictionResult[] results = new PredictionResult[steps]; + for (int i = 0; i < steps; i++) { + results[i] = PredictionResult.builder() + .forecast(lastValue) + .upperBound(lastValue + 3 * stdDeviation) + .lowerBound(lastValue - 3 * stdDeviation) + .build(); + } + return results; + } + + if (recentHistory.length < LOOKBACK_WINDOW) { + // Should not happen if training succeeded, but as a safeguard + return new PredictionResult[0]; + } + + PredictionResult[] results = new PredictionResult[steps]; + double[] buffer = Arrays.copyOfRange(recentHistory, recentHistory.length - LOOKBACK_WINDOW, recentHistory.length); + + for (int i = 0; i < steps; i++) { + double anchorValue = buffer[buffer.length - 1]; + + // Apply Weights + // weights[0] is Intercept + double predictionNorm = weights[0]; + + for (int j = 0; j < LOOKBACK_WINDOW; j++) { + double feat = buffer[j] - anchorValue; // RevIN + predictionNorm += weights[j + 1] * feat; + } + + double prediction = predictionNorm + anchorValue; + double interval = 3.0 * stdDeviation; + + results[i] = PredictionResult.builder() + .forecast(prediction) + .upperBound(prediction + interval) + .lowerBound(prediction - interval) + .build(); + + // Slide buffer + System.arraycopy(buffer, 1, buffer, 0, LOOKBACK_WINDOW - 1); + buffer[LOOKBACK_WINDOW - 1] = prediction; + } + return results; + } +} diff --git a/hertzbeat-analysis/src/main/java/org/apache/hertzbeat/analysis/algorithm/PredictionResult.java b/hertzbeat-analysis/src/main/java/org/apache/hertzbeat/analysis/algorithm/PredictionResult.java new file mode 100644 index 00000000000..fa3e72aa9b9 --- /dev/null +++ b/hertzbeat-analysis/src/main/java/org/apache/hertzbeat/analysis/algorithm/PredictionResult.java @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.analysis.algorithm; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Prediction result with confidence interval + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class PredictionResult { + + /** + * Prediction Timestamp + */ + private Long time; + + /** + * Predicted Value (y-hat) + */ + private Double forecast; + + /** + * Lower Bound (forecast - 3 * sigma) + */ + private Double lowerBound; + + /** + * Upper Bound (forecast + 3 * sigma) + */ + private Double upperBound; +} diff --git a/hertzbeat-analysis/src/main/java/org/apache/hertzbeat/analysis/algorithm/TimeSeriesPreprocessor.java b/hertzbeat-analysis/src/main/java/org/apache/hertzbeat/analysis/algorithm/TimeSeriesPreprocessor.java new file mode 100644 index 00000000000..39c6e8796b3 --- /dev/null +++ b/hertzbeat-analysis/src/main/java/org/apache/hertzbeat/analysis/algorithm/TimeSeriesPreprocessor.java @@ -0,0 +1,135 @@ +/* + * 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.analysis.algorithm; + +import java.util.ArrayList; +import java.util.List; +import java.util.TreeMap; +import org.apache.hertzbeat.common.entity.warehouse.History; +import org.springframework.stereotype.Component; + +/** + * Time series data preprocessing tool: Resampling & Interpolation + */ +@Component +public class TimeSeriesPreprocessor { + + /** + * Preprocess raw metric data into a fixed-step, gap-filled double array + * @param rawData Raw history data list + * @param stepMillis Time step in milliseconds (e.g., 60000 for 1 minute) + * @param startTime Start timestamp + * @param endTime End timestamp + * @return Cleaned data array where index 0 corresponds to startTime + */ + public double[] preprocess(List rawData, long stepMillis, long startTime, long endTime) { + if (rawData == null || rawData.isEmpty()) { + return new double[0]; + } + + // 1. Bucket Aggregation (Snap to Grid) + TreeMap> buckets = new TreeMap<>(); + for (History point : rawData) { + long timestamp = point.getTime(); + if (timestamp < startTime || timestamp > endTime) { + continue; + } + // Align timestamp to the grid + long bucketTime = ((timestamp - startTime) / stepMillis) * stepMillis + startTime; + buckets.computeIfAbsent(bucketTime, k -> new ArrayList<>()).add(point.getDou()); + } + + // 2. Generate Grid and Impute Missing Values + int expectedSize = (int) ((endTime - startTime) / stepMillis) + 1; + double[] result = new double[expectedSize]; + + Double lastValidValue = null; + int lastValidIndex = -1; + + for (int i = 0; i < expectedSize; i++) { + long currentTime = startTime + (i * stepMillis); + + if (buckets.containsKey(currentTime)) { + // If bucket has data, take average + double avg = buckets.get(currentTime).stream() + .mapToDouble(Double::doubleValue).average().orElse(0.0); + result[i] = avg; + + // Perform linear interpolation for gaps + if (lastValidValue != null && (i - lastValidIndex) > 1) { + fillGap(result, lastValidIndex, i, lastValidValue, avg); + } + + lastValidValue = avg; + lastValidIndex = i; + } else { + // Mark as NaN for now + result[i] = Double.NaN; + } + } + + // 3. Handle Edge Cases (Forward/Backward Fill) + fillEdges(result); + + return result; + } + + /** + * Linear interpolation + */ + private void fillGap(double[] data, int startIndex, int endIndex, double startVal, double endVal) { + int steps = endIndex - startIndex; + double slope = (endVal - startVal) / steps; + + for (int i = 1; i < steps; i++) { + data[startIndex + i] = startVal + (slope * i); + } + } + + /** + * Fill leading/trailing NaNs + */ + private void fillEdges(double[] data) { + if (data.length == 0) { + return; + } + + // Forward Fill (Head) + int firstValid = -1; + for (int i = 0; i < data.length; i++) { + if (!Double.isNaN(data[i])) { + firstValid = i; + break; + } + } + + if (firstValid == -1) { + return; // All NaN + } + for (int i = 0; i < firstValid; i++) { + data[i] = data[firstValid]; + } + + // Backward Fill (Tail) + for (int i = firstValid + 1; i < data.length; i++) { + if (Double.isNaN(data[i])) { + data[i] = data[i - 1]; + } + } + } +} diff --git a/hertzbeat-analysis/src/main/java/org/apache/hertzbeat/analysis/service/AnalysisService.java b/hertzbeat-analysis/src/main/java/org/apache/hertzbeat/analysis/service/AnalysisService.java new file mode 100644 index 00000000000..091a591b7c7 --- /dev/null +++ b/hertzbeat-analysis/src/main/java/org/apache/hertzbeat/analysis/service/AnalysisService.java @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.analysis.service; + +import java.util.List; +import org.apache.hertzbeat.analysis.algorithm.PredictionResult; +import org.apache.hertzbeat.common.entity.warehouse.History; + +/** + * Intelligent Analysis Service Interface + */ +public interface AnalysisService { + + /** + * Train model and forecast future trend with confidence interval + * @param historyData Historical metric data + * @param stepMillis Time step (bucket size) in millis + * @param forecastCount Number of future points to predict + * @return List of prediction results (forecast + bounds) + */ + List forecast(List historyData, long stepMillis, int forecastCount); +} diff --git a/hertzbeat-analysis/src/main/java/org/apache/hertzbeat/analysis/service/impl/AnalysisServiceImpl.java b/hertzbeat-analysis/src/main/java/org/apache/hertzbeat/analysis/service/impl/AnalysisServiceImpl.java new file mode 100644 index 00000000000..cb173d8b46d --- /dev/null +++ b/hertzbeat-analysis/src/main/java/org/apache/hertzbeat/analysis/service/impl/AnalysisServiceImpl.java @@ -0,0 +1,93 @@ +/* + * 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.analysis.service.impl; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.apache.hertzbeat.analysis.algorithm.NlinearModel; +import org.apache.hertzbeat.analysis.algorithm.PredictionResult; +import org.apache.hertzbeat.analysis.algorithm.TimeSeriesPreprocessor; +import org.apache.hertzbeat.analysis.service.AnalysisService; +import org.apache.hertzbeat.common.entity.warehouse.History; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +/** + * Implementation of Analysis Service + */ +@Slf4j +@Service +public class AnalysisServiceImpl implements AnalysisService { + + @Autowired + private TimeSeriesPreprocessor preprocessor; + + @Override + public List forecast(List historyData, long stepMillis, int forecastCount) { + if (historyData == null || historyData.isEmpty()) { + return Collections.emptyList(); + } + + // 1. Determine time range + long minTime = Long.MAX_VALUE; + long maxTime = Long.MIN_VALUE; + for (History h : historyData) { + if (h.getTime() < minTime) { + minTime = h.getTime(); + } + if (h.getTime() > maxTime) { + maxTime = h.getTime(); + } + } + + // Align start/end to step + long startTime = (minTime / stepMillis) * stepMillis; + long endTime = (maxTime / stepMillis) * stepMillis; + + // 2. Preprocess Data + double[] y = preprocessor.preprocess(historyData, stepMillis, startTime, endTime); + + // 3. Train Model (Stateful, so create a new instance for each request) + NlinearModel model = new NlinearModel(); + model.train(y); + + // 4. Forecast + PredictionResult[] predictions = model.predict(y, forecastCount); + + // 5. Convert and add timestamps + List forecastResult = new ArrayList<>(forecastCount); + + // Use the actual last timestamp from preprocessing as the base for future time + // Note: endTime calculated above is the timestamp of the last bucket + long lastTimestamp = endTime; + + for (int i = 0; i < predictions.length; i++) { + PredictionResult result = predictions[i]; + + // Critical: Set the absolute timestamp for the frontend + // i=0 is the first future point, so time = lastTimestamp + 1 * step + result.setTime(lastTimestamp + ((i + 1) * stepMillis)); + + forecastResult.add(result); + } + + return forecastResult; + } +} diff --git a/hertzbeat-collector/hertzbeat-collector-basic/pom.xml b/hertzbeat-collector/hertzbeat-collector-basic/pom.xml index c43972cda0a..2ebff7382c6 100644 --- a/hertzbeat-collector/hertzbeat-collector-basic/pom.xml +++ b/hertzbeat-collector/hertzbeat-collector-basic/pom.xml @@ -189,4 +189,4 @@ ${zookeeper.version} - \ No newline at end of file + diff --git a/hertzbeat-e2e/hertzbeat-log-e2e/src/test/resources/sureness.yml b/hertzbeat-e2e/hertzbeat-log-e2e/src/test/resources/sureness.yml index f04c5a2f7d3..dc51787c8f5 100644 --- a/hertzbeat-e2e/hertzbeat-log-e2e/src/test/resources/sureness.yml +++ b/hertzbeat-e2e/hertzbeat-log-e2e/src/test/resources/sureness.yml @@ -16,7 +16,7 @@ ## -- sureness.yml account source -- ## # config the resource restful api that need auth protection, base rbac -# rule: api===method===role +# rule: api===method===role # eg: /api/v1/source1===get===[admin] means /api/v2/host===post support role[admin] access. # eg: /api/v1/source2===get===[] means /api/v1/source2===get can not access by any role. resourceRole: @@ -71,9 +71,10 @@ resourceRole: - /api/chat/**===get===[admin,user] - /api/chat/**===post===[admin,user] - /api/logs/ingest/**===post===[admin,user] + - /api/analysis/**===get===[admin,user] # config the resource restful api that need bypass auth protection -# rule: api===method +# rule: api===method # eg: /api/v1/source3===get means /api/v1/source3===get can be access by anyone, no need auth. excludedResource: - /api/alert/sse/**===* diff --git a/hertzbeat-manager/pom.xml b/hertzbeat-manager/pom.xml index 5f3671242cb..a57bb2e6767 100644 --- a/hertzbeat-manager/pom.xml +++ b/hertzbeat-manager/pom.xml @@ -27,7 +27,7 @@ ${project.artifactId} jar - + @@ -96,6 +96,11 @@ org.apache.hertzbeat hertzbeat-log + + + org.apache.hertzbeat + hertzbeat-analysis + org.springframework.boot diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/controller/AnalysisController.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/controller/AnalysisController.java new file mode 100644 index 00000000000..164b4d91ecb --- /dev/null +++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/controller/AnalysisController.java @@ -0,0 +1,298 @@ +/* + * 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.manager.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +import org.apache.hertzbeat.analysis.algorithm.PredictionResult; +import org.apache.hertzbeat.analysis.service.AnalysisService; +import org.apache.hertzbeat.common.constants.CommonConstants; +import org.apache.hertzbeat.common.entity.dto.Message; +import org.apache.hertzbeat.common.entity.dto.MetricsHistoryData; +import org.apache.hertzbeat.common.entity.dto.Value; +import org.apache.hertzbeat.common.entity.job.Job; +import org.apache.hertzbeat.common.entity.job.Metrics; +import org.apache.hertzbeat.common.entity.manager.Monitor; +import org.apache.hertzbeat.common.entity.warehouse.History; +import org.apache.hertzbeat.manager.service.AppService; +import org.apache.hertzbeat.manager.service.MonitorService; +import org.apache.hertzbeat.warehouse.service.MetricsDataService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * Analysis and Prediction Controller + */ +@Tag(name = "Analysis Prediction API") +@RestController +@RequestMapping(path = "/api/analysis") +@Slf4j +public class AnalysisController { + + private static final long MAX_DURATION_MS = 10L * 365 * 24 * 3600 * 1000; + + @Autowired + private MetricsDataService metricsDataService; + + @Autowired + private AnalysisService analysisService; + + @Autowired + private AppService appService; + + @Autowired + private MonitorService monitorService; + + @GetMapping("/predict/{instance}/{app}/{metrics}/{metric}") + @Operation(summary = "Predict metric data", description = "Forecast future metric data based on history") + public Message>> getMetricPrediction( + @Parameter(description = "Monitor Instance", example = "127.0.0.1") @PathVariable String instance, + @Parameter(description = "App Type", example = "linux") @PathVariable String app, + @Parameter(description = "Metrics Name", example = "cpu") @PathVariable String metrics, + @Parameter(description = "Metric Name", example = "usage") @PathVariable String metric, + @Parameter(description = "History time range", example = "6h") @RequestParam(required = false) String history + ) { + // 1. Context Analysis + // We separate "User View Window" (history) from "Model Training Window" (dbQueryTime). + // User view: what the user sees (e.g., 1h). We should predict ~20% of this length. + // Training window: what the model needs (e.g., 3 days). We force this to be large. + + String userViewTime = history != null ? history : "6h"; + long userViewMillis = parseSimpleDuration(userViewTime); + if (userViewMillis <= 0) { + userViewMillis = 6 * 60 * 60 * 1000L; // default 6h + } + + // 2. Determine Training Window (Strategy: Max(3 days, 100 * interval)) + String dbQueryTime = "6h"; // initial fallback + try { + List monitors = monitorService.getAppMonitors(app); + if (monitors != null) { + Optional monitorOpt = monitors.stream() + .filter(m -> instance.equals(m.getInstance())) + .findFirst(); + + if (monitorOpt.isPresent()) { + Integer intervals = monitorOpt.get().getIntervals(); + if (intervals != null && intervals > 0) { + long minSeconds = 259200L; // 3 days + long intervalBasedSeconds = intervals * 100L; + long finalSeconds = Math.max(minSeconds, intervalBasedSeconds); + dbQueryTime = finalSeconds + "s"; + log.debug("[Predict] Training window calculated: {} for interval: {}s", dbQueryTime, intervals); + } + } + } + } catch (Exception e) { + log.warn("[Predict] Failed to calculate dynamic history for instance: {}, using default.", instance, e); + } + + // 3. Validate Metric Type + Optional jobOptional = appService.getAppDefineOption(app); + if (jobOptional.isEmpty()) { + return Message.fail(CommonConstants.FAIL_CODE, "Application definition not found: " + app); + } + Job job = jobOptional.get(); + + Optional metricsDefineOpt = job.getMetrics().stream() + .filter(m -> m.getName().equals(metrics)) + .findFirst(); + if (metricsDefineOpt.isEmpty()) { + return Message.fail(CommonConstants.FAIL_CODE, "Metrics group not found: " + metrics); + } + + Optional fieldDefineOpt = metricsDefineOpt.get().getFields().stream() + .filter(f -> f.getField().equals(metric)) + .findFirst(); + if (fieldDefineOpt.isEmpty()) { + return Message.fail(CommonConstants.FAIL_CODE, "Metric field not found: " + metric); + } + + if (fieldDefineOpt.get().getType() != CommonConstants.TYPE_NUMBER) { + return Message.fail(CommonConstants.FAIL_CODE, "Prediction is only supported for numeric metrics."); + } + + // 4. Get Training Data (Using the Long Window) + MetricsHistoryData historyData = metricsDataService.getMetricHistoryData( + instance, app, metrics, metric, dbQueryTime, false); + + if (historyData == null || historyData.getValues() == null || historyData.getValues().isEmpty()) { + return Message.success(Collections.emptyMap()); + } + + Map> resultMap = new HashMap<>(); + + // Capture effectively final variable for lambda + final long viewWindowMillis = userViewMillis; + + // 5. Iterate and Forecast + historyData.getValues().forEach((rowInstance, values) -> { + if (values == null || values.size() < 10) { + return; + } + List validHistory = new ArrayList<>(); + + for (Value v : values) { + try { + if (v.getOrigin() != null && !CommonConstants.NULL_VALUE.equals(v.getOrigin())) { + double val = Double.parseDouble(v.getOrigin()); + validHistory.add(History.builder() + .time(v.getTime()) + .dou(val) + .metricType(CommonConstants.TYPE_NUMBER) + .build()); + } + } catch (NumberFormatException ignored) {} + } + + if (validHistory.size() > 10) { + long step = estimateStep(validHistory); + + // Smart Calculation of Forecast Count + // Rule: Predict 1/5 of the user's current view window + long forecastDuration = viewWindowMillis / 5; + int dynamicCount = (int) (forecastDuration / step); + + // Bounds checking + if (dynamicCount < 5) dynamicCount = 5; // Minimum 5 points + if (dynamicCount > 2000) dynamicCount = 2000; // Safety cap + + log.debug("[Predict] View: {}ms, Forecast: {}ms ({} steps), Step: {}ms", + viewWindowMillis, forecastDuration, dynamicCount, step); + + List forecast = analysisService.forecast(validHistory, step, dynamicCount); + + if (!forecast.isEmpty()) { + resultMap.put(rowInstance, forecast); + } + } + }); + + return Message.success(resultMap); + } + + /** + * Parses a simple duration string (e.g., "1h", "6h", "1d", "1w", "4w", "12w") into milliseconds. + *

+ * Supported time units are: + *

    + *
  • s - seconds (e.g., "30s")
  • + *
  • m - minutes (e.g., "15m")
  • + *
  • h - hours (e.g., "1h", "6h")
  • + *
  • d - days (e.g., "1d")
  • + *
  • w - weeks (e.g., "4w", "12w")
  • + *
+ *

+ * Examples of valid input: + *

    + *
  • "1h" -> 3,600,000
  • + *
  • "6h" -> 21,600,000
  • + *
  • "1d" -> 86,400,000
  • + *
  • "4w" -> 2,419,200,000
  • + *
+ *

+ * If the input is invalid or cannot be parsed, returns 0. + * + * @param timeToken the duration string to parse + * @return the duration in milliseconds, or 0 if input is invalid + */ + private long parseSimpleDuration(String timeToken) { + if (timeToken == null) return 0; + // Define maximum allowed duration: 10 years in milliseconds + try { + String lower = timeToken.toLowerCase().trim(); + if (lower.length() < 2) return 0; + char unit = lower.charAt(lower.length() - 1); + String numberPart = lower.substring(0, lower.length() - 1); + // Only allow digits, and limit length to 12 digits (trillion) + if (!numberPart.matches("\\d{1,12}")) return 0; + long value = Long.parseLong(numberPart); + long durationMs = 0; + switch (unit) { + case 's': + durationMs = value * 1000L; + break; + case 'm': + durationMs = value * 60L * 1000L; + break; + case 'h': + durationMs = value * 3600L * 1000L; + break; + case 'd': + durationMs = value * 24L * 3600L * 1000L; + break; + case 'w': + durationMs = value * 7L * 24L * 3600L * 1000L; + break; + default: + return 0; + } + if (durationMs < 0 || durationMs > MAX_DURATION_MS) { + return 0; + } + return durationMs; + } catch (NumberFormatException e) { + return 0; + } + } + + /** + * Estimates the time step (interval) between consecutive history data points by calculating + * the median of the time differences between their timestamps. + *

+ * Only the first 100 data points are considered for efficiency and because this is typically + * sufficient for a reliable estimation. + *

+ * If there are fewer than 2 data points, or if all time differences are non-positive, + * a default value of 60000 milliseconds (1 minute) is returned. + * + * @param data the list of history data points, assumed to be sorted by time ascending + * @return the estimated time step in milliseconds (median of positive time differences) + */ + private long estimateStep(List data) { + if (data.size() < 2) { + return 60000L; // default 1 min + } + List diffs = new ArrayList<>(); + // Check first 100 points is enough for estimation + int limit = Math.min(data.size(), 100); + for (int i = 1; i < limit; i++) { + long diff = data.get(i).getTime() - data.get(i - 1).getTime(); + if (diff > 0) { + diffs.add(diff); + } + } + if (diffs.isEmpty()) { + return 60000L; + } + Collections.sort(diffs); + return diffs.get(diffs.size() / 2); + } +} diff --git a/hertzbeat-manager/src/test/resources/sureness.yml b/hertzbeat-manager/src/test/resources/sureness.yml index f04c5a2f7d3..dc51787c8f5 100644 --- a/hertzbeat-manager/src/test/resources/sureness.yml +++ b/hertzbeat-manager/src/test/resources/sureness.yml @@ -16,7 +16,7 @@ ## -- sureness.yml account source -- ## # config the resource restful api that need auth protection, base rbac -# rule: api===method===role +# rule: api===method===role # eg: /api/v1/source1===get===[admin] means /api/v2/host===post support role[admin] access. # eg: /api/v1/source2===get===[] means /api/v1/source2===get can not access by any role. resourceRole: @@ -71,9 +71,10 @@ resourceRole: - /api/chat/**===get===[admin,user] - /api/chat/**===post===[admin,user] - /api/logs/ingest/**===post===[admin,user] + - /api/analysis/**===get===[admin,user] # config the resource restful api that need bypass auth protection -# rule: api===method +# rule: api===method # eg: /api/v1/source3===get means /api/v1/source3===get can be access by anyone, no need auth. excludedResource: - /api/alert/sse/**===* diff --git a/hertzbeat-startup/src/main/resources/sureness.yml b/hertzbeat-startup/src/main/resources/sureness.yml index 282e93928a1..8d19327b936 100644 --- a/hertzbeat-startup/src/main/resources/sureness.yml +++ b/hertzbeat-startup/src/main/resources/sureness.yml @@ -16,7 +16,7 @@ ## -- sureness.yml account source -- ## # config the resource restful api that need auth protection, base rbac -# rule: api===method===role +# rule: api===method===role # eg: /api/v1/source1===get===[admin] means /api/v2/host===post support role[admin] access. # eg: /api/v1/source2===get===[] means /api/v1/source2===get can not access by any role. resourceRole: @@ -71,9 +71,10 @@ resourceRole: - /api/chat/**===get===[admin,user] - /api/chat/**===post===[admin] - /api/logs/ingest/**===post===[admin,user] + - /api/analysis/**===get===[admin,user] # config the resource restful api that need bypass auth protection -# rule: api===method +# rule: api===method # eg: /api/v1/source3===get means /api/v1/source3===get can be access by anyone, no need auth. excludedResource: - /api/alert/sse/**===* diff --git a/pom.xml b/pom.xml index 08ad1b0a1b0..ed49ea9541a 100644 --- a/pom.xml +++ b/pom.xml @@ -93,6 +93,7 @@ hertzbeat-base hertzbeat-log hertzbeat-ai + hertzbeat-analysis @@ -149,6 +150,7 @@ 3.2.1 3.10.0 1.27.1 + 3.6.1 0.8.11 2.40.0 @@ -271,6 +273,12 @@ hertzbeat-collector-collector ${hertzbeat.version} + + + org.apache.hertzbeat + hertzbeat-analysis + ${hertzbeat.version} + org.springframework.boot @@ -330,6 +338,11 @@ commons-jexl3 ${commons-jexl3} + + org.apache.commons + commons-math3 + ${commons-math.version} + com.github.jsqlparser jsqlparser diff --git a/script/docker-compose/hertzbeat-mysql-iotdb/conf/sureness.yml b/script/docker-compose/hertzbeat-mysql-iotdb/conf/sureness.yml index 282e93928a1..8d19327b936 100644 --- a/script/docker-compose/hertzbeat-mysql-iotdb/conf/sureness.yml +++ b/script/docker-compose/hertzbeat-mysql-iotdb/conf/sureness.yml @@ -16,7 +16,7 @@ ## -- sureness.yml account source -- ## # config the resource restful api that need auth protection, base rbac -# rule: api===method===role +# rule: api===method===role # eg: /api/v1/source1===get===[admin] means /api/v2/host===post support role[admin] access. # eg: /api/v1/source2===get===[] means /api/v1/source2===get can not access by any role. resourceRole: @@ -71,9 +71,10 @@ resourceRole: - /api/chat/**===get===[admin,user] - /api/chat/**===post===[admin] - /api/logs/ingest/**===post===[admin,user] + - /api/analysis/**===get===[admin,user] # config the resource restful api that need bypass auth protection -# rule: api===method +# rule: api===method # eg: /api/v1/source3===get means /api/v1/source3===get can be access by anyone, no need auth. excludedResource: - /api/alert/sse/**===* diff --git a/script/docker-compose/hertzbeat-mysql-tdengine/conf/sureness.yml b/script/docker-compose/hertzbeat-mysql-tdengine/conf/sureness.yml index 282e93928a1..8d19327b936 100644 --- a/script/docker-compose/hertzbeat-mysql-tdengine/conf/sureness.yml +++ b/script/docker-compose/hertzbeat-mysql-tdengine/conf/sureness.yml @@ -16,7 +16,7 @@ ## -- sureness.yml account source -- ## # config the resource restful api that need auth protection, base rbac -# rule: api===method===role +# rule: api===method===role # eg: /api/v1/source1===get===[admin] means /api/v2/host===post support role[admin] access. # eg: /api/v1/source2===get===[] means /api/v1/source2===get can not access by any role. resourceRole: @@ -71,9 +71,10 @@ resourceRole: - /api/chat/**===get===[admin,user] - /api/chat/**===post===[admin] - /api/logs/ingest/**===post===[admin,user] + - /api/analysis/**===get===[admin,user] # config the resource restful api that need bypass auth protection -# rule: api===method +# rule: api===method # eg: /api/v1/source3===get means /api/v1/source3===get can be access by anyone, no need auth. excludedResource: - /api/alert/sse/**===* diff --git a/script/docker-compose/hertzbeat-mysql-victoria-metrics/conf/sureness.yml b/script/docker-compose/hertzbeat-mysql-victoria-metrics/conf/sureness.yml index 282e93928a1..8d19327b936 100644 --- a/script/docker-compose/hertzbeat-mysql-victoria-metrics/conf/sureness.yml +++ b/script/docker-compose/hertzbeat-mysql-victoria-metrics/conf/sureness.yml @@ -16,7 +16,7 @@ ## -- sureness.yml account source -- ## # config the resource restful api that need auth protection, base rbac -# rule: api===method===role +# rule: api===method===role # eg: /api/v1/source1===get===[admin] means /api/v2/host===post support role[admin] access. # eg: /api/v1/source2===get===[] means /api/v1/source2===get can not access by any role. resourceRole: @@ -71,9 +71,10 @@ resourceRole: - /api/chat/**===get===[admin,user] - /api/chat/**===post===[admin] - /api/logs/ingest/**===post===[admin,user] + - /api/analysis/**===get===[admin,user] # config the resource restful api that need bypass auth protection -# rule: api===method +# rule: api===method # eg: /api/v1/source3===get means /api/v1/source3===get can be access by anyone, no need auth. excludedResource: - /api/alert/sse/**===* diff --git a/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml b/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml index 282e93928a1..8d19327b936 100644 --- a/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml +++ b/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml @@ -16,7 +16,7 @@ ## -- sureness.yml account source -- ## # config the resource restful api that need auth protection, base rbac -# rule: api===method===role +# rule: api===method===role # eg: /api/v1/source1===get===[admin] means /api/v2/host===post support role[admin] access. # eg: /api/v1/source2===get===[] means /api/v1/source2===get can not access by any role. resourceRole: @@ -71,9 +71,10 @@ resourceRole: - /api/chat/**===get===[admin,user] - /api/chat/**===post===[admin] - /api/logs/ingest/**===post===[admin,user] + - /api/analysis/**===get===[admin,user] # config the resource restful api that need bypass auth protection -# rule: api===method +# rule: api===method # eg: /api/v1/source3===get means /api/v1/source3===get can be access by anyone, no need auth. excludedResource: - /api/alert/sse/**===* diff --git a/script/docker-compose/hertzbeat-postgresql-victoria-metrics/conf/sureness.yml b/script/docker-compose/hertzbeat-postgresql-victoria-metrics/conf/sureness.yml index 282e93928a1..8d19327b936 100644 --- a/script/docker-compose/hertzbeat-postgresql-victoria-metrics/conf/sureness.yml +++ b/script/docker-compose/hertzbeat-postgresql-victoria-metrics/conf/sureness.yml @@ -16,7 +16,7 @@ ## -- sureness.yml account source -- ## # config the resource restful api that need auth protection, base rbac -# rule: api===method===role +# rule: api===method===role # eg: /api/v1/source1===get===[admin] means /api/v2/host===post support role[admin] access. # eg: /api/v1/source2===get===[] means /api/v1/source2===get can not access by any role. resourceRole: @@ -71,9 +71,10 @@ resourceRole: - /api/chat/**===get===[admin,user] - /api/chat/**===post===[admin] - /api/logs/ingest/**===post===[admin,user] + - /api/analysis/**===get===[admin,user] # config the resource restful api that need bypass auth protection -# rule: api===method +# rule: api===method # eg: /api/v1/source3===get means /api/v1/source3===get can be access by anyone, no need auth. excludedResource: - /api/alert/sse/**===* diff --git a/script/sureness.yml b/script/sureness.yml index 282e93928a1..8d19327b936 100644 --- a/script/sureness.yml +++ b/script/sureness.yml @@ -16,7 +16,7 @@ ## -- sureness.yml account source -- ## # config the resource restful api that need auth protection, base rbac -# rule: api===method===role +# rule: api===method===role # eg: /api/v1/source1===get===[admin] means /api/v2/host===post support role[admin] access. # eg: /api/v1/source2===get===[] means /api/v1/source2===get can not access by any role. resourceRole: @@ -71,9 +71,10 @@ resourceRole: - /api/chat/**===get===[admin,user] - /api/chat/**===post===[admin] - /api/logs/ingest/**===post===[admin,user] + - /api/analysis/**===get===[admin,user] # config the resource restful api that need bypass auth protection -# rule: api===method +# rule: api===method # eg: /api/v1/source3===get means /api/v1/source3===get can be access by anyone, no need auth. excludedResource: - /api/alert/sse/**===* diff --git a/web-app/src/app/routes/monitor/monitor-data-chart/monitor-data-chart.component.ts b/web-app/src/app/routes/monitor/monitor-data-chart/monitor-data-chart.component.ts index 09c2022c593..fb3bc2999ee 100644 --- a/web-app/src/app/routes/monitor/monitor-data-chart/monitor-data-chart.component.ts +++ b/web-app/src/app/routes/monitor/monitor-data-chart/monitor-data-chart.component.ts @@ -7,7 +7,7 @@ * "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 + * 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 @@ -22,6 +22,7 @@ import { I18NService } from '@core'; import { ALAIN_I18N_TOKEN } from '@delon/theme'; import { EChartsOption } from 'echarts'; import { InViewportAction } from 'ng-in-viewport'; +import { NzNotificationService } from 'ng-zorro-antd/notification'; import { finalize } from 'rxjs/operators'; import { MonitorService } from '../../../service/monitor.service'; @@ -57,13 +58,17 @@ export class MonitorDataChartComponent implements OnInit, OnDestroy { lineHistoryTheme!: EChartsOption; loading: string | null = null; echartsInstance!: any; - // Default historical data period is last 30 minutes - timePeriod: string = '30m'; + // Default historical data period is last 1 hour + timePeriod: string = '1h'; isInViewport = false; private debounceTimer: any = undefined; private worker$: any = null; - constructor(private monitorSvc: MonitorService, @Inject(ALAIN_I18N_TOKEN) private i18nSvc: I18NService) {} + constructor( + private monitorSvc: MonitorService, + private notifySvc: NzNotificationService, + @Inject(ALAIN_I18N_TOKEN) private i18nSvc: I18NService + ) {} handleViewportAction(event: InViewportAction) { if (this.debounceTimer) { @@ -287,6 +292,8 @@ export class MonitorDataChartComponent implements OnInit, OnDestroy { this.worker$.unsubscribe(); this.worker$ = null; this.loading = null; + // Auto load prediction data silently after main data loaded + setTimeout(() => this.loadPredictionData(true), 500); } else if (rsp.progress > 0) { this.loading = `${this.i18nSvc.fanyi('monitor.detail.chart.data-processing')} ${rsp.progress}%`; } @@ -316,6 +323,171 @@ export class MonitorDataChartComponent implements OnInit, OnDestroy { ); } + loadPredictionData(isAuto: boolean = false) { + if (!isAuto) { + this.loading = 'Forecasting...'; + } + + // CRITICAL FIX: Pass 'this.timePeriod' so backend knows the view context (1h, 6h, 1d, 1w) + // and can calculate appropriate forecast duration (e.g. 1/5 of view length). + // We pass null for predictTime to rely on backend auto-calculation. + let predictionData$ = this.monitorSvc + .getMonitorMetricsPredictionData(this.instance, this.app, this.metrics, this.metric, this.timePeriod) + .pipe( + finalize(() => { + if (!isAuto) { + this.loading = null; + } + predictionData$.unsubscribe(); + }) + ) + .subscribe( + (message: any) => { + if (message.code === 0 && message.data) { + // Get current series to append forecast data + const currentSeries = (this.eChartOption.series as any[]) || []; + const newSeries = [...currentSeries]; + + let hasData = false; + // Get translations for chart legend + const forecastName = this.i18nSvc.fanyi('monitor.detail.chart.forecast'); + // Separate names for clarity + const confidenceLowerName = this.i18nSvc.fanyi('monitor.detail.chart.confidence.lower'); + const confidenceUpperName = this.i18nSvc.fanyi('monitor.detail.chart.confidence.upper'); + // Fallback if translation keys don't exist + const lowerName = confidenceLowerName.includes('monitor.detail') ? 'Lower Bound' : confidenceLowerName; + const upperName = confidenceUpperName.includes('monitor.detail') ? 'Upper Bound' : confidenceUpperName; + + // Iterate over prediction results + for (const results of Object.values(message.data)) { + const predictions = results as any[]; + if (!predictions || predictions.length === 0) continue; + hasData = true; + + // Parse prediction data + const forecastLineData = []; + const lowerBoundData = []; + const diffData = []; + const upperBoundData = []; + + for (const p of predictions) { + const val = p.forecast; + const upper = p.upperBound; + const lower = p.lowerBound; + const t = p.time; + + forecastLineData.push([t, val]); + // Basic data for lower bound + lowerBoundData.push([t, lower]); + // Diff data for the stacked band (upper - lower) + diffData.push([t, upper - lower]); + // Actual upper bound data for separate invisible series + upperBoundData.push([t, upper]); + } + + // 1. Lower Bound Series (Base of the stack) + // This series serves two purposes: + // a) It is the bottom edge of the band. + // b) It shows the correct "Lower Bound" value in the tooltip. + newSeries.push({ + name: lowerName, + type: 'line', + data: lowerBoundData, + stack: `confidence-band`, + symbol: 'none', + lineStyle: { opacity: 0 }, + areaStyle: { opacity: 0 }, + // Show tooltip for this series + tooltip: { show: true }, + silent: false // Allow hover to trigger tooltip + }); + + // 2. Band Width Series (Stacked on top of Lower Bound) + // This series draws the filled area. + // It MUST be hidden from tooltip because its value is the "difference", not the absolute value. + newSeries.push({ + name: 'Confidence Band', // Internal name + type: 'line', + data: diffData, + stack: `confidence-band`, + symbol: 'none', + lineStyle: { opacity: 0 }, + areaStyle: { + opacity: 0.3, + color: '#A6C8FF' + }, + // CRITICAL: Hide this series from tooltip so users don't see the diff value + tooltip: { show: false }, + silent: true // Ignore mouse events so it doesn't trigger tooltip + }); + + // 3. Upper Bound Series (Invisible, Non-Stacked) + // This series is purely for the Tooltip. It shows the correct "Upper Bound" value. + newSeries.push({ + name: upperName, + type: 'line', + data: upperBoundData, + // Do NOT stack this series + stack: null, + symbol: 'none', + lineStyle: { opacity: 0 }, // Invisible line + areaStyle: { opacity: 0 }, // No area + // Show tooltip for this series + tooltip: { show: true }, + silent: false + }); + + // 4. Forecast Main Line + newSeries.push({ + name: `${forecastName}`, + type: 'line', + data: forecastLineData, + smooth: true, + lineStyle: { + type: 'dashed', + width: 2, + color: '#ffa318' + }, + itemStyle: { + opacity: 0, + color: '#ffa318' + }, + symbol: 'none', + z: 5 + }); + } + + if (hasData) { + this.eChartOption.series = newSeries; + if (this.echartsInstance) { + this.echartsInstance.setOption({ + series: newSeries + }); + } + if (!isAuto) { + this.notifySvc.success(this.i18nSvc.fanyi('common.notify.success'), 'Forecast data loaded.'); + } + } else { + if (!isAuto) { + this.notifySvc.warning(this.i18nSvc.fanyi('common.notify.warning'), 'Insufficient history data for prediction.'); + } + } + } else { + console.warn(`Prediction failed or no data returned: ${message.msg}`); + if (!isAuto) { + this.notifySvc.error(this.i18nSvc.fanyi('common.notify.error'), message.msg || 'Prediction failed.'); + } + } + }, + error => { + console.error(error); + if (!isAuto) { + this.notifySvc.error(this.i18nSvc.fanyi('common.notify.error'), error.msg || 'Network error during prediction.'); + } + } + ); + } + onChartInit(ec: any) { this.echartsInstance = ec; } diff --git a/web-app/src/app/service/monitor.service.ts b/web-app/src/app/service/monitor.service.ts index 5919ee2779a..93a87e51d27 100644 --- a/web-app/src/app/service/monitor.service.ts +++ b/web-app/src/app/service/monitor.service.ts @@ -7,7 +7,7 @@ * "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 + * 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 @@ -35,6 +35,7 @@ const summary_uri = '/summary'; const warehouse_storage_status_uri = '/warehouse/storage/status'; const grafana_dashboard_uri = '/grafana/dashboard'; const metrics_favorite_uri = '/metrics/favorite'; +const analysis_uri = '/analysis'; @Injectable({ providedIn: 'root' @@ -181,6 +182,25 @@ export class MonitorService { return this.http.get>(`${monitor_uri}/${instance}/metric/${metricFull}`, options); } + /** + * Get metric prediction data + */ + public getMonitorMetricsPredictionData( + instance: string, + app: string, + metrics: string, + metric: string, + history: string + ): Observable> { + let httpParams = new HttpParams(); + httpParams = httpParams.append('forecastCount', 10); + if (history) { + httpParams = httpParams.append('history', history); + } + const options = { params: httpParams }; + return this.http.get>(`${analysis_uri}/predict/${instance}/${app}/${metrics}/${metric}`, options); + } + public getAppsMonitorSummary(): Observable> { return this.http.get>(summary_uri); } diff --git a/web-app/src/assets/i18n/en-US.json b/web-app/src/assets/i18n/en-US.json index 9a89c2fef0c..07c0ede8a0c 100644 --- a/web-app/src/assets/i18n/en-US.json +++ b/web-app/src/assets/i18n/en-US.json @@ -822,6 +822,9 @@ "monitor.detail.chart.zoom": "Zoom In", "monitor.detail.chart.data-loading": "Data Loading", "monitor.detail.chart.data-processing": "In data parsing", + "monitor.detail.chart.forecast": "Forecast", + "monitor.detail.chart.confidence.lower": "Confidence Lower", + "monitor.detail.chart.confidence.upper": "Confidence Upper", "monitor.detail.close-refresh": "Close Auto Refresh", "monitor.detail.config-refresh": "Set Auto Refresh For {{time}} s", "monitor.detail.description": "Desc", diff --git a/web-app/src/assets/i18n/zh-CN.json b/web-app/src/assets/i18n/zh-CN.json index ca614fefc9c..12ee9bf3113 100644 --- a/web-app/src/assets/i18n/zh-CN.json +++ b/web-app/src/assets/i18n/zh-CN.json @@ -825,6 +825,9 @@ "monitor.detail.chart.zoom": "区域缩放", "monitor.detail.chart.data-loading": "数据请求中", "monitor.detail.chart.data-processing": "数据解析中", + "monitor.detail.chart.forecast": "预测值", + "monitor.detail.chart.confidence.lower": "置信区间下界", + "monitor.detail.chart.confidence.upper": "置信区间上界", "monitor.detail.close-refresh": "关闭自动刷新", "monitor.detail.config-refresh": "设置 {{time}} 秒自动刷新", "monitor.detail.description": "描述",