[feature] support exporting alerts to Excel (#4332)

Co-authored-by: aias00 <liuhongyu@apache.org>
This commit is contained in:
NekoPunch
2026-08-23 12:56:28 +08:00
committed by GitHub
co-authored by aias00
parent b279d27345
commit 6fec531cf2
14 changed files with 214 additions and 7 deletions
+5
View File
@@ -108,6 +108,11 @@
<version>${easy-poi.version}</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>cn.afterturn</groupId>
<artifactId>easypoi-base</artifactId>
<version>${easy-poi.version}</version>
</dependency>
<dependency>
<groupId>com.huaweicloud.sdk</groupId>
<artifactId>huaweicloud-sdk-smn</artifactId>
@@ -21,6 +21,7 @@ import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import java.util.HashSet;
import java.util.List;
import org.apache.hertzbeat.alert.dto.AlertSummary;
@@ -63,6 +64,17 @@ public class AlertsController {
return ResponseEntity.ok(Message.success(alertPage));
}
@GetMapping("/export")
@Operation(summary = "Export Alarms", description = "Export single alarms matching the filters as an Excel sheet")
public void exportAlerts(
@Parameter(description = "Alarm Status", example = "resolved") @RequestParam(required = false) String status,
@Parameter(description = "Alarm content fuzzy query", example = "linux") @RequestParam(required = false) String search,
@Parameter(description = "Sort field, default activeAt", example = "activeAt") @RequestParam(defaultValue = "activeAt") String sort,
@Parameter(description = "Sort Type", example = "desc") @RequestParam(defaultValue = "desc") String order,
HttpServletResponse response) {
alertService.exportSingleAlerts(status, search, sort, order, response);
}
@GetMapping("/group")
@Operation(summary = "Query Group Alarms")
public ResponseEntity<Message<Page<GroupAlert>>> getGroupAlerts(
@@ -0,0 +1,55 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.alert.dto;
import cn.afterturn.easypoi.excel.annotation.Excel;
import lombok.Data;
/**
* A SingleAlert with its Map and timestamp fields pre-rendered to strings, since easypoi cannot map those to cells.
*/
@Data
public class SingleAlertExportDTO {
@Excel(name = "Status", width = 12)
private String status;
@Excel(name = "Content", width = 60)
private String content;
@Excel(name = "Labels", width = 40)
private String labels;
@Excel(name = "Annotations", width = 40)
private String annotations;
@Excel(name = "Fingerprint", width = 24)
private String fingerprint;
@Excel(name = "Trigger Times", width = 12)
private Integer triggerTimes;
@Excel(name = "Start At", width = 20)
private String startAt;
@Excel(name = "Active At", width = 20)
private String activeAt;
@Excel(name = "End At", width = 20)
private String endAt;
}
@@ -17,6 +17,7 @@
package org.apache.hertzbeat.alert.service;
import jakarta.servlet.http.HttpServletResponse;
import java.util.HashSet;
import java.util.List;
import org.apache.hertzbeat.alert.dto.AlertSummary;
@@ -40,7 +41,17 @@ public interface AlertService {
* @return single alerts
*/
Page<SingleAlert> getSingleAlerts(String status, String search, String sort, String order, int pageIndex, int pageSize);
/**
* export single alerts matching the filters to an Excel sheet
* @param status status
* @param search search
* @param sort sort
* @param order order
* @param response servlet response the Excel sheet is written to
*/
void exportSingleAlerts(String status, String search, String sort, String order, HttpServletResponse response);
/**
* Dynamic conditional query
* @param status Alarm Status
@@ -17,21 +17,33 @@
package org.apache.hertzbeat.alert.service.impl;
import cn.afterturn.easypoi.excel.ExcelExportUtil;
import cn.afterturn.easypoi.excel.entity.ExportParams;
import cn.afterturn.easypoi.excel.entity.enmus.ExcelType;
import jakarta.persistence.criteria.Predicate;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.alert.dao.GroupAlertDao;
import org.apache.hertzbeat.alert.dao.SingleAlertDao;
import org.apache.hertzbeat.alert.dto.AlertSummary;
import org.apache.hertzbeat.alert.dto.SingleAlertExportDTO;
import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce;
import org.apache.hertzbeat.alert.service.AlertService;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.common.entity.alerter.GroupAlert;
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
import org.apache.poi.ss.usermodel.Workbook;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
@@ -47,7 +59,9 @@ import org.springframework.transaction.annotation.Transactional;
@Transactional(rollbackFor = Exception.class)
@Slf4j
public class AlertServiceImpl implements AlertService {
private static final DateTimeFormatter EXPORT_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Autowired
private GroupAlertDao groupAlertDao;
@@ -59,7 +73,13 @@ public class AlertServiceImpl implements AlertService {
@Override
public Page<SingleAlert> getSingleAlerts(String status, String search, String sort, String order, int pageIndex, int pageSize) {
Specification<SingleAlert> specification = (root, query, criteriaBuilder) -> {
Sort sortExp = Sort.by(new Sort.Order(Sort.Direction.fromString(order), sort));
PageRequest pageRequest = PageRequest.of(pageIndex, pageSize, sortExp);
return singleAlertDao.findAll(buildSingleAlertSpecification(status, search), pageRequest);
}
private Specification<SingleAlert> buildSingleAlertSpecification(String status, String search) {
return (root, query, criteriaBuilder) -> {
List<Predicate> andList = new ArrayList<>();
if (status != null) {
Predicate predicate = criteriaBuilder.equal(root.get("status"), status);
@@ -88,9 +108,54 @@ public class AlertServiceImpl implements AlertService {
return query.where(andPredicate, orPredicate).getRestriction();
}
};
}
@Override
public void exportSingleAlerts(String status, String search, String sort, String order, HttpServletResponse response) {
Sort sortExp = Sort.by(new Sort.Order(Sort.Direction.fromString(order), sort));
PageRequest pageRequest = PageRequest.of(pageIndex, pageSize, sortExp);
return singleAlertDao.findAll(specification, pageRequest);
List<SingleAlert> alerts = singleAlertDao.findAll(buildSingleAlertSpecification(status, search), sortExp);
// easypoi mutates the list in place, so it must be mutable (not Stream.toList()).
List<SingleAlertExportDTO> rows = alerts.stream().map(this::toExportRow).collect(Collectors.toList());
try (Workbook workbook = ExcelExportUtil.exportExcel(
new ExportParams("Alert Records", "Alerts", ExcelType.XSSF), SingleAlertExportDTO.class, rows)) {
String fileName = "hertzbeat_alerts_" + System.currentTimeMillis() + ".xlsx";
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader("Content-Disposition", "attachment;filename=" + fileName);
workbook.write(response.getOutputStream());
} catch (IOException e) {
log.error("export alerts to excel error: {}", e.getMessage(), e);
throw new RuntimeException("Failed to export alerts", e);
}
}
private SingleAlertExportDTO toExportRow(SingleAlert alert) {
SingleAlertExportDTO row = new SingleAlertExportDTO();
row.setStatus(alert.getStatus());
row.setContent(alert.getContent());
row.setLabels(mapToString(alert.getLabels()));
row.setAnnotations(mapToString(alert.getAnnotations()));
row.setFingerprint(alert.getFingerprint());
row.setTriggerTimes(alert.getTriggerTimes());
row.setStartAt(formatEpochMilli(alert.getStartAt()));
row.setActiveAt(formatEpochMilli(alert.getActiveAt()));
row.setEndAt(formatEpochMilli(alert.getEndAt()));
return row;
}
private String mapToString(Map<String, String> map) {
if (map == null || map.isEmpty()) {
return "";
}
return map.entrySet().stream()
.map(entry -> entry.getKey() + "=" + entry.getValue())
.collect(Collectors.joining("; "));
}
private String formatEpochMilli(Long epochMilli) {
if (epochMilli == null) {
return "";
}
return EXPORT_TIME_FORMATTER.format(Instant.ofEpochMilli(epochMilli).atZone(ZoneId.systemDefault()));
}
@Override
@@ -54,6 +54,11 @@
<nz-option [nzLabel]="'alert.status.firing' | i18n" [nzValue]="'firing'"></nz-option>
<nz-option [nzLabel]="'alert.status.resolved' | i18n" [nzValue]="'resolved'"></nz-option>
</nz-select>
<button nz-button [nzLoading]="exportButtonLoading" (click)="exportAlerts()">
<i nz-icon nzType="download" nzTheme="outline"></i>
{{ 'alert.center.export' | i18n }}
</button>
</div>
</ng-template>
</app-toolbar>
@@ -22,7 +22,7 @@ import { I18NService } from '@core';
import { ALAIN_I18N_TOKEN } from '@delon/theme';
import { NzModalService } from 'ng-zorro-antd/modal';
import { NzNotificationService } from 'ng-zorro-antd/notification';
import { Subscription } from 'rxjs';
import { finalize, Subscription } from 'rxjs';
import { GroupAlert } from '../../../pojo/GroupAlert';
import { AlertService } from '../../../service/alert.service';
@@ -54,6 +54,7 @@ export class AlertCenterComponent implements OnInit, OnDestroy {
checkedAlertIds = new Set<number>();
filterStatus!: string;
filterContent: string | undefined;
exportButtonLoading: boolean = false;
private alertStream$!: Subscription;
ngOnInit(): void {
@@ -298,4 +299,35 @@ export class AlertCenterComponent implements OnInit, OnDestroy {
}
);
}
exportAlerts() {
this.exportButtonLoading = true;
const exportAlerts$ = this.alertSvc
.exportAlerts(this.filterStatus, this.filterContent)
.pipe(
finalize(() => {
this.exportButtonLoading = false;
exportAlerts$.unsubscribe();
})
)
.subscribe(
response => {
const body = response.body!;
if (body.type == 'application/json') {
this.notifySvc.error(this.i18nSvc.fanyi('common.notify.export-fail'), '');
} else {
const blob = new Blob([body], { type: response.headers.get('Content-Type')! });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.download = response.headers.get('Content-Disposition')!.split(';')[1].split('filename=')[1];
a.href = url;
a.click();
window.URL.revokeObjectURL(url);
}
},
error => {
this.notifySvc.error(this.i18nSvc.fanyi('common.notify.export-fail'), error.msg);
}
);
}
}
+17 -1
View File
@@ -17,7 +17,7 @@
* under the License.
*/
import { HttpClient, HttpParams } from '@angular/common/http';
import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
@@ -30,6 +30,7 @@ const alerts_summary_uri = '/alerts/summary';
const alerts_group_uri = '/alerts/group';
const alerts_group_status_uri = '/alerts/group/status';
const alerts_uri = '/alerts';
const alerts_export_uri = '/alerts/export';
@Injectable({
providedIn: 'root'
@@ -89,6 +90,21 @@ export class AlertService {
return this.http.get<Message<Page<GroupAlert>>>(alerts_group_uri, options);
}
public exportAlerts(status: string | undefined, search: string | undefined): Observable<HttpResponse<Blob>> {
let httpParams = new HttpParams();
if (status != undefined) {
httpParams = httpParams.append('status', status);
}
if (search != undefined && search != '' && search.trim() != '') {
httpParams = httpParams.append('search', search.trim());
}
return this.http.get(alerts_export_uri, {
params: httpParams,
observe: 'response',
responseType: 'blob'
});
}
public deleteGroupAlerts(alertIds: Set<number>): Observable<Message<any>> {
let httpParams = new HttpParams();
alertIds.forEach(alertId => {
+1
View File
@@ -27,6 +27,7 @@
"alert.center.content": "Alert Content",
"alert.center.deal": "Mark Processed",
"alert.center.delete": "Delete Alerts",
"alert.center.export": "Export Alerts",
"alert.center.end-time": "End Time",
"alert.center.filter-priority": "Alert Priority",
"alert.center.filter-status": "Alert Status",
+1
View File
@@ -27,6 +27,7 @@
"alert.center.content": "アラート内容",
"alert.center.deal": "処理済みにマーク",
"alert.center.delete": "アラートを削除",
"alert.center.export": "アラートをエクスポート",
"alert.center.end-time": "終了時間",
"alert.center.filter-priority": "アラート優先度",
"alert.center.filter-status": "アラートステータス",
+1
View File
@@ -27,6 +27,7 @@
"alert.center.content": "알람 내용",
"alert.center.deal": "처리 완료로 표시",
"alert.center.delete": "알람 삭제",
"alert.center.export": "알람 내보내기",
"alert.center.end-time": "종료 시간",
"alert.center.filter-priority": "알람 우선순위",
"alert.center.filter-status": "알람 상태",
+1
View File
@@ -102,6 +102,7 @@
"alert.converge.eval-interval": "Intervalo de Convergência de Repetição de Alerta (s)",
"alert.converge.enable": "Habilitar Convergência",
"alert.center.delete": "Excluir Alertas",
"alert.center.export": "Exportar Alertas",
"alert.center.clear": "Limpar Tudo",
"alert.center.deal": "Marcar como Processado",
"alert.center.no-deal": "Marcar como Pendente",
+1
View File
@@ -27,6 +27,7 @@
"alert.center.content": "告警内容",
"alert.center.deal": "标记已处理",
"alert.center.delete": "删除告警",
"alert.center.export": "导出告警",
"alert.center.end-time": "结束",
"alert.center.filter-priority": "告警级别",
"alert.center.filter-status": "告警状态",
+1
View File
@@ -27,6 +27,7 @@
"alert.center.content": "告警內容",
"alert.center.deal": "標記已處理",
"alert.center.delete": "刪除告警",
"alert.center.export": "匯出告警",
"alert.center.end-time": "结束",
"alert.center.filter-priority": "告警級別",
"alert.center.filter-status": "告警狀態",