This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new 2cf36f81 feat: support filtered audit log CSV export (#728)
2cf36f81 is described below
commit 2cf36f818b101d29ea64cc76d00f7ce6d9f56f09
Author: yx9o <[email protected]>
AuthorDate: Mon Aug 3 11:14:52 2026 +0800
feat: support filtered audit log CSV export (#728)
---
docs/api-spec.md | 39 ++++---
.../rocketmq/studio/ops/audit/AuditController.java | 10 ++
.../rocketmq/studio/ops/audit/AuditService.java | 56 ++++++++--
.../studio/ops/audit/AuditControllerTest.java | 20 ++++
.../studio/ops/audit/AuditServiceTest.java | 25 +++++
web/src/api/audit.test.ts | 11 ++
web/src/api/audit.ts | 26 +++++
web/src/pages/ops/__tests__/AuditPage.test.tsx | 123 +++++++++++++++++++++
web/src/pages/ops/audit.tsx | 62 +++++++++--
web/src/services/opsService.test.ts | 24 ++++
web/src/services/opsService.ts | 65 ++++++++---
11 files changed, 415 insertions(+), 46 deletions(-)
diff --git a/docs/api-spec.md b/docs/api-spec.md
index cb010f8a..879add48 100644
--- a/docs/api-spec.md
+++ b/docs/api-spec.md
@@ -99,18 +99,19 @@
| 56 | POST | `/api/system-alerts/acknowledge` | 确认告警 |
| 57 | POST | `/api/system-alerts/clear-acknowledged` | 清除已确认告警 |
| 58 | GET | `/api/audit-logs` | 审计日志列表 |
-| 59 | POST | `/api/audit-logs/cleanup` | 清理审计日志 |
-| 60 | GET | `/api/settings/general` | 获取通用设置 |
-| 61 | POST | `/api/settings/general/save` | 保存通用设置 |
-| 62 | GET | `/api/settings/datasources` | 数据源列表 |
-| 63 | POST | `/api/settings/datasources/create` | 创建数据源 |
-| 64 | POST | `/api/settings/datasources/update` | 更新数据源 |
-| 65 | POST | `/api/settings/datasources/delete` | 删除数据源 |
-| 66 | POST | `/api/settings/datasources/test` | 测试数据源连接 |
-| 67 | POST | `/api/ai/chat` | AI 对话(SSE) |
-| 68 | POST | `/api/ai/execute` | 执行 AI 指令 |
-| 69 | GET | `/api/ai/tools` | 可用工具列表 |
-| 70 | POST | `/api/metrics/query` | 查询监控指标数据 |
+| 59 | GET | `/api/audit-logs/export` | 导出审计日志 |
+| 60 | POST | `/api/audit-logs/cleanup` | 清理审计日志 |
+| 61 | GET | `/api/settings/general` | 获取通用设置 |
+| 62 | POST | `/api/settings/general/save` | 保存通用设置 |
+| 63 | GET | `/api/settings/datasources` | 数据源列表 |
+| 64 | POST | `/api/settings/datasources/create` | 创建数据源 |
+| 65 | POST | `/api/settings/datasources/update` | 更新数据源 |
+| 66 | POST | `/api/settings/datasources/delete` | 删除数据源 |
+| 67 | POST | `/api/settings/datasources/test` | 测试数据源连接 |
+| 68 | POST | `/api/ai/chat` | AI 对话(SSE) |
+| 69 | POST | `/api/ai/execute` | 执行 AI 指令 |
+| 70 | GET | `/api/ai/tools` | 可用工具列表 |
+| 71 | POST | `/api/metrics/query` | 查询监控指标数据 |
## 通用响应格式
@@ -1469,7 +1470,19 @@ GET
/api/audit-logs?page={page}&pageSize={pageSize}&search={search}&operationTyp
| `ipAddress` | `string` | 操作 IP 地址 |
| `result` | `string` | 结果: `success` / `failure` |
-### 13.2 清理审计日志
+### 13.2 导出审计日志
+
+```
+GET
/api/audit-logs/export?search={search}&operationType={type}&startDate={start}&endDate={end}&result={result}
+```
+
+查询参数与列表接口相同,但不包含 `page` 和 `pageSize`。接口返回全部匹配记录,不受当前表格分页影响。
+
+**Response `data`:** 带 UTF-8 BOM 的 CSV 字符串。CSV 字段使用双引号包裹,并转义逗号、双引号和换行符。以
`=`、`+`、`-`、`@`、制表符或换行符开头的字段会添加单引号前缀,避免电子表格将其作为公式执行。
+
+`startDate` 或 `endDate` 格式错误,以及 `startDate` 晚于 `endDate` 时,接口返回 HTTP 400。
+
+### 13.3 清理审计日志
```
POST /api/audit-logs/cleanup
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditController.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditController.java
index 32d40492..21a6da68 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditController.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditController.java
@@ -49,6 +49,16 @@ public class AuditController {
operationType, startDate, endDate, result));
}
+ @GetMapping("/export")
+ public Result<String> exportLogs(
+ @RequestParam(required = false) String search,
+ @RequestParam(required = false) String operationType,
+ @RequestParam(required = false) String startDate,
+ @RequestParam(required = false) String endDate,
+ @RequestParam(required = false) String result) {
+ return Result.ok(auditService.exportLogs(search, operationType,
startDate, endDate, result));
+ }
+
@PostMapping("/cleanup")
public Result<Map<String, Integer>> cleanupLogs(@Valid
@RequestBody(required = false) AuditCleanupDTO request) {
int beforeDays = request == null || request.getBeforeDays() == null ?
30 : request.getBeforeDays();
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditService.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditService.java
index 27588437..3595c90b 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditService.java
@@ -34,6 +34,9 @@ import java.util.List;
@RequiredArgsConstructor
public class AuditService {
+ private static final String CSV_HEADER =
+
"timestamp,operator,operationType,target,detail,ipAddress,result\r\n";
+
private final AuditRepository auditRepository;
@@ -44,13 +47,7 @@ public class AuditService {
log.info("Querying audit logs, page={}, pageSize={}, search={},
operationType={}, result={}",
page, pageSize, search, operationType, result);
- LocalDateTime start = parseDate(startDate, true, "startDate");
- LocalDateTime end = parseDate(endDate, false, "endDate");
- if (start != null && end != null && start.isAfter(end)) {
- throw new BusinessException(400, "startDate must not be after
endDate");
- }
-
- List<AuditRecordVO> allRecords = auditRepository.findAll(search,
operationType, start, end, result);
+ List<AuditRecordVO> allRecords = findRecords(search, operationType,
startDate, endDate, result);
long total = allRecords.size();
long offset = (long) (page - 1) * pageSize;
@@ -61,6 +58,23 @@ public class AuditService {
return PageResult.of(pageRecords, total, page, pageSize);
}
+ public String exportLogs(String search, String operationType, String
startDate,
+ String endDate, String result) {
+ List<AuditRecordVO> records = findRecords(search, operationType,
startDate, endDate, result);
+ StringBuilder csv = new StringBuilder("\uFEFF").append(CSV_HEADER);
+ for (AuditRecordVO record : records) {
+ appendCsvRow(csv,
+ record.getTimestamp(),
+ record.getOperator(),
+ record.getOperationType(),
+ record.getTarget(),
+ record.getDetail(),
+ record.getIpAddress(),
+ record.getResult());
+ }
+ return csv.toString();
+ }
+
public int cleanupLogs(int beforeDays) {
if (beforeDays <= 0) {
@@ -80,6 +94,34 @@ public class AuditService {
}
}
+ private List<AuditRecordVO> findRecords(String search, String
operationType, String startDate,
+ String endDate, String result) {
+ LocalDateTime start = parseDate(startDate, true, "startDate");
+ LocalDateTime end = parseDate(endDate, false, "endDate");
+ if (start != null && end != null && start.isAfter(end)) {
+ throw new BusinessException(400, "startDate must not be after
endDate");
+ }
+ return auditRepository.findAll(search, operationType, start, end,
result);
+ }
+
+ private void appendCsvRow(StringBuilder csv, Object... values) {
+ for (int i = 0; i < values.length; i++) {
+ if (i > 0) {
+ csv.append(',');
+ }
+ csv.append(toCsvCell(values[i]));
+ }
+ csv.append("\r\n");
+ }
+
+ private String toCsvCell(Object value) {
+ String text = value == null ? "" : value.toString();
+ if (!text.isEmpty() && "=+-@\t\r\n".indexOf(text.charAt(0)) >= 0) {
+ text = "'" + text;
+ }
+ return '"' + text.replace("\"", "\"\"") + '"';
+ }
+
private LocalDateTime parseDate(String dateStr, boolean startOfDay, String
parameterName) {
if (dateStr == null || dateStr.isEmpty()) {
return null;
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditControllerTest.java
index b27dfe40..453a5793 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditControllerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditControllerTest.java
@@ -157,4 +157,24 @@ class AuditControllerTest {
verify(auditService).queryLogs(eq(1), eq(20), isNull(), isNull(),
isNull(), isNull(), isNull());
}
+
+ @Test
+ void exportLogsShouldForwardFilters() throws Exception {
+ String csv =
"\uFEFFtimestamp,operator\r\n\"2026-08-01T09:30\",\"admin\"\r\n";
+ when(auditService.exportLogs(eq("topic"), eq("DELETE"),
eq("2026-08-01"),
+ eq("2026-08-02"), eq("SUCCESS"))).thenReturn(csv);
+
+ mockMvc.perform(get("/api/audit-logs/export")
+ .param("search", "topic")
+ .param("operationType", "DELETE")
+ .param("startDate", "2026-08-01")
+ .param("endDate", "2026-08-02")
+ .param("result", "SUCCESS"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data").value(csv));
+
+ verify(auditService).exportLogs(eq("topic"), eq("DELETE"),
eq("2026-08-01"),
+ eq("2026-08-02"), eq("SUCCESS"));
+ }
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditServiceTest.java
index fefb3651..9b45bdfe 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditServiceTest.java
@@ -261,4 +261,29 @@ class AuditServiceTest {
verify(auditRepository).findAll(eq("admin"), eq("DELETE"),
any(LocalDateTime.class),
any(LocalDateTime.class), eq("FAILURE"));
}
+
+ @Test
+ void exportLogsShouldUseFiltersAndEscapeCsvValues() {
+ AuditRecordVO record = AuditRecordVO.builder()
+ .timestamp(LocalDateTime.of(2026, 8, 1, 9, 30))
+ .operator("=cmd")
+ .operationType("DELETE")
+ .target("topic,a")
+ .detail("removed \"topic\"\nfrom cluster")
+ .ipAddress("\n=127.0.0.1")
+ .result("SUCCESS")
+ .build();
+ when(auditRepository.findAll(eq("topic"), eq("DELETE"),
any(LocalDateTime.class),
+ any(LocalDateTime.class), eq("SUCCESS")))
+ .thenReturn(List.of(record));
+
+ String csv = auditService.exportLogs(
+ "topic", "DELETE", "2026-08-01", "2026-08-02", "SUCCESS");
+
+
assertThat(csv).isEqualTo("\uFEFFtimestamp,operator,operationType,target,detail,ipAddress,result\r\n"
+ + "\"2026-08-01T09:30\",\"'=cmd\",\"DELETE\",\"topic,a\","
+ + "\"removed \"\"topic\"\"\nfrom
cluster\",\"'\n=127.0.0.1\",\"SUCCESS\"\r\n");
+ verify(auditRepository).findAll(eq("topic"), eq("DELETE"),
any(LocalDateTime.class),
+ any(LocalDateTime.class), eq("SUCCESS"));
+ }
}
diff --git a/web/src/api/audit.test.ts b/web/src/api/audit.test.ts
index 2593226d..5e6112a6 100644
--- a/web/src/api/audit.test.ts
+++ b/web/src/api/audit.test.ts
@@ -17,6 +17,7 @@
import { afterEach, describe, expect, it } from 'vitest';
import MockAdapter from 'axios-mock-adapter';
+import { exportAuditLogs } from './audit';
import client from './client';
import { cleanupAuditLogs, listAuditRecords } from './ops';
@@ -49,4 +50,14 @@ describe('audit log API', () => {
await expect(cleanupAuditLogs(30)).resolves.toEqual({ deleted: 3 });
});
+
+ it('exports all records matching the supplied filters', async () => {
+ const csv = '\uFEFFtimestamp,operator\r\n"2026-08-01T09:30","admin"\r\n';
+ mock.onGet('/audit-logs/export').reply((config) => {
+ expect(config.params).toEqual({ search: 'topic', result: 'SUCCESS' });
+ return [200, { code: 200, data: csv }];
+ });
+
+ await expect(exportAuditLogs({ search: 'topic', result: 'SUCCESS'
})).resolves.toBe(csv);
+ });
});
diff --git a/web/src/api/audit.ts b/web/src/api/audit.ts
new file mode 100644
index 00000000..26b55734
--- /dev/null
+++ b/web/src/api/audit.ts
@@ -0,0 +1,26 @@
+/*
+ * 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 client from './client';
+import type { AuditQuery } from './ops';
+
+export type AuditFilter = Omit<AuditQuery, 'page' | 'pageSize'>;
+
+export async function exportAuditLogs(params?: AuditFilter): Promise<string> {
+ const res = await client.get<{ data: string }>('/audit-logs/export', {
params });
+ return res.data.data;
+}
diff --git a/web/src/pages/ops/__tests__/AuditPage.test.tsx
b/web/src/pages/ops/__tests__/AuditPage.test.tsx
new file mode 100644
index 00000000..89eeb760
--- /dev/null
+++ b/web/src/pages/ops/__tests__/AuditPage.test.tsx
@@ -0,0 +1,123 @@
+/*
+ * 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 { App } from 'antd';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import type React from 'react';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from
'vitest';
+import { LangProvider } from '../../../i18n/LangContext';
+import * as opsService from '../../../services/opsService';
+import AuditPage from '../audit';
+
+vi.mock('../../../services/opsService', () => ({
+ cleanupAuditLogs: vi.fn(),
+ exportAuditLogs: vi.fn(),
+ listAuditRecords: vi.fn(),
+}));
+
+const renderWithProviders = (ui: React.ReactElement) =>
+ render(
+ <App>
+ <LangProvider>{ui}</LangProvider>
+ </App>,
+ );
+
+describe('Audit page', () => {
+ let createObjectURL: ReturnType<typeof vi.fn>;
+ let revokeObjectURL: ReturnType<typeof vi.fn>;
+ let clickSpy: ReturnType<typeof vi.spyOn>;
+
+ beforeAll(() => {
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+ });
+
+ beforeEach(() => {
+ vi.mocked(opsService.listAuditRecords).mockResolvedValue({
+ items: [
+ {
+ id: 'audit-1',
+ timestamp: '2026-08-01 10:00:00',
+ operator: 'admin',
+ operationType: '删除Topic',
+ target: 'topic-a',
+ detail: 'removed topic-a',
+ ipAddress: '127.0.0.1',
+ result: 'SUCCESS',
+ },
+ ],
+ total: 1,
+ page: 1,
+ size: 20,
+ });
+ vi.mocked(opsService.exportAuditLogs).mockResolvedValue(
+ '\uFEFFtimestamp,operator\r\n"2026-08-01 10:00:00","admin"\r\n',
+ );
+ createObjectURL = vi.fn().mockReturnValue('blob:audit');
+ revokeObjectURL = vi.fn();
+ Object.defineProperty(URL, 'createObjectURL', {
+ configurable: true,
+ value: createObjectURL,
+ });
+ Object.defineProperty(URL, 'revokeObjectURL', {
+ configurable: true,
+ value: revokeObjectURL,
+ });
+ clickSpy = vi.spyOn(HTMLAnchorElement.prototype,
'click').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ clickSpy.mockRestore();
+ vi.clearAllMocks();
+ });
+
+ it('exports all audit records matching the current filters', async () => {
+ const user = userEvent.setup();
+ renderWithProviders(<AuditPage />);
+
+ expect(await screen.findByText('topic-a')).toBeInTheDocument();
+ await user.type(screen.getByPlaceholderText('搜索操作人或操作对象'), 'topic-a');
+ await user.click(screen.getByRole('button', { name: /导出/ }));
+
+ await waitFor(() =>
+ expect(opsService.exportAuditLogs).toHaveBeenCalledWith({
+ search: 'topic-a',
+ operationType: undefined,
+ startDate: undefined,
+ endDate: undefined,
+ result: undefined,
+ }),
+ );
+ expect(createObjectURL).toHaveBeenCalledTimes(1);
+ const blob = createObjectURL.mock.calls[0][0] as Blob;
+ await expect(blob.text()).resolves.toContain('"2026-08-01
10:00:00","admin"');
+ expect(clickSpy).toHaveBeenCalledTimes(1);
+ expect(revokeObjectURL).toHaveBeenCalledWith('blob:audit');
+ });
+});
diff --git a/web/src/pages/ops/audit.tsx b/web/src/pages/ops/audit.tsx
index 92491699..762cbdce 100644
--- a/web/src/pages/ops/audit.tsx
+++ b/web/src/pages/ops/audit.tsx
@@ -31,12 +31,15 @@ import {
message,
} from 'antd';
import { Trash } from '@phosphor-icons/react';
+import { DownloadOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
+import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import PageHeader from '../../components/PageHeader';
import { useLang } from '../../i18n/LangContext';
+import type { AuditFilter } from '../../api/audit';
import type { AuditRecord } from '../../api/ops';
-import { cleanupAuditLogs, listAuditRecords } from '../../services/opsService';
+import { cleanupAuditLogs, exportAuditLogs, listAuditRecords } from
'../../services/opsService';
const operationTypeColors: Record<string, string> = {
创建Topic: 'blue',
@@ -58,6 +61,19 @@ const operationTypeOptions = [
'删除消费组',
];
+const buildAuditFilter = (
+ searchText: string,
+ selectedType: string | undefined,
+ dateRange: [Dayjs | null, Dayjs | null] | null,
+ resultFilter: string,
+): AuditFilter => ({
+ search: searchText || undefined,
+ operationType: selectedType,
+ startDate: dateRange?.[0]?.format('YYYY-MM-DD'),
+ endDate: dateRange?.[1]?.format('YYYY-MM-DD'),
+ result: resultFilter === 'all' ? undefined : resultFilter,
+});
+
const AuditPage: React.FC = () => {
const { t } = useLang();
const [records, setRecords] = useState<AuditRecord[]>([]);
@@ -72,20 +88,15 @@ const AuditPage: React.FC = () => {
const [resultFilter, setResultFilter] = useState('all');
const [cleanupModalOpen, setCleanupModalOpen] = useState(false);
const [cleanupDays, setCleanupDays] = useState(30);
+ const [exporting, setExporting] = useState(false);
useEffect(() => {
let cancelled = false;
- const startDate = dateRange?.[0]?.format('YYYY-MM-DD');
- const endDate = dateRange?.[1]?.format('YYYY-MM-DD');
void listAuditRecords({
page,
pageSize,
- search: searchText || undefined,
- operationType: selectedType,
- startDate,
- endDate,
- result: resultFilter === 'all' ? undefined : resultFilter,
+ ...buildAuditFilter(searchText, selectedType, dateRange, resultFilter),
})
.then((result) => {
if (cancelled) return;
@@ -118,6 +129,26 @@ const AuditPage: React.FC = () => {
}
};
+ const handleExport = async () => {
+ setExporting(true);
+ try {
+ const csv = await exportAuditLogs(
+ buildAuditFilter(searchText, selectedType, dateRange, resultFilter),
+ );
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download =
`rocketmq-audit-logs-${dayjs().format('YYYY-MM-DD')}.csv`;
+ anchor.click();
+ URL.revokeObjectURL(url);
+ } catch {
+ message.error('导出审计日志失败,请稍后重试');
+ } finally {
+ setExporting(false);
+ }
+ };
+
const columns: ColumnsType<AuditRecord> = [
{
title: t('audit.time'),
@@ -214,9 +245,18 @@ const AuditPage: React.FC = () => {
]}
/>
</Flex>
- <Button danger icon={<Trash size={14} />} onClick={() =>
setCleanupModalOpen(true)}>
- {t('audit.cleanup')}
- </Button>
+ <Flex gap={8}>
+ <Button
+ icon={<DownloadOutlined />}
+ loading={exporting}
+ onClick={() => void handleExport()}
+ >
+ {t('common.export')}
+ </Button>
+ <Button danger icon={<Trash size={14} />} onClick={() =>
setCleanupModalOpen(true)}>
+ {t('audit.cleanup')}
+ </Button>
+ </Flex>
</Flex>
{/* ─── Table ─── */}
diff --git a/web/src/services/opsService.test.ts
b/web/src/services/opsService.test.ts
index eafa7468..2571a12b 100644
--- a/web/src/services/opsService.test.ts
+++ b/web/src/services/opsService.test.ts
@@ -20,6 +20,7 @@ import type { AuditRecord } from '../api/ops';
import { mockAuditRecords } from '../mock/audit';
import {
createAlertRule,
+ exportAuditLogs,
listAlertRules,
listAuditRecords,
listSystemAlerts,
@@ -119,4 +120,27 @@ describe('ops service mock data', () => {
expect(result.items.map((item) => item.id)).toContain('audit-null-safe');
});
+
+ it('exports filtered audit records as escaped CSV', async () => {
+ const record = {
+ id: 'audit-csv-export',
+ timestamp: '2026-08-01 10:00:00',
+ operator: '=admin',
+ operationType: 'DELETE',
+ target: 'csv-export-target',
+ detail: 'removed "topic", safely',
+ ipAddress: '\n=127.0.0.1',
+ result: 'SUCCESS',
+ } as AuditRecord;
+ insertedRecords.push(record);
+ auditRecords.push(record);
+
+ const csv = await exportAuditLogs({ search: 'csv-export-target' });
+
+
expect(csv).toContain('timestamp,operator,operationType,target,detail,ipAddress,result');
+ expect(csv).toContain(
+ '"2026-08-01 10:00:00","\'=admin","DELETE","csv-export-target",' +
+ '"removed ""topic"", safely","\'\n=127.0.0.1","SUCCESS"',
+ );
+ });
});
diff --git a/web/src/services/opsService.ts b/web/src/services/opsService.ts
index 68d087eb..9750fc1b 100644
--- a/web/src/services/opsService.ts
+++ b/web/src/services/opsService.ts
@@ -1,4 +1,6 @@
import { USE_MOCK } from '../config';
+import { exportAuditLogs as exportAuditLogsApi } from '../api/audit';
+import type { AuditFilter } from '../api/audit';
import * as opsApi from '../api/ops';
import type { AlertRule, SystemAlert, AuditQuery, AuditRecord, PageResult }
from '../api/ops';
import { mockAlertRules } from '../mock/alerts';
@@ -27,6 +29,48 @@ function includesIgnoreCase(value: string | null |
undefined, search: string): b
return (value ?? '').toLowerCase().includes(search);
}
+function filterAuditRecords(params: AuditFilter): AuditRecord[] {
+ return auditRecordsState.filter((record) => {
+ const search = params.search?.trim().toLowerCase();
+ if (
+ search &&
+ !includesIgnoreCase(record.operator, search) &&
+ !includesIgnoreCase(record.target, search) &&
+ !includesIgnoreCase(record.detail, search)
+ ) {
+ return false;
+ }
+ if (params.operationType && record.operationType !== params.operationType)
return false;
+ if (params.startDate && record.timestamp < params.startDate) return false;
+ if (params.endDate && record.timestamp > `${params.endDate} 23:59:59`)
return false;
+ return !params.result || record.result.toUpperCase() ===
params.result.toUpperCase();
+ });
+}
+
+function toCsvCell(value: string | null | undefined): string {
+ let text = value ?? '';
+ if (text.length > 0 && '=+-@\t\r\n'.includes(text[0])) text = `'${text}`;
+ return `"${text.replace(/"/g, '""')}"`;
+}
+
+function formatAuditCsv(records: AuditRecord[]): string {
+ const header =
'timestamp,operator,operationType,target,detail,ipAddress,result\r\n';
+ const rows = records.map((record) =>
+ [
+ record.timestamp,
+ record.operator,
+ record.operationType,
+ record.target,
+ record.detail,
+ record.ipAddress,
+ record.result,
+ ]
+ .map(toCsvCell)
+ .join(','),
+ );
+ return `\uFEFF${header}${rows.length > 0 ? `${rows.join('\r\n')}\r\n` : ''}`;
+}
+
export async function listAlertRules(): Promise<AlertRule[]> {
if (USE_MOCK) return alertRulesState.map(copyAlertRule);
return opsApi.listAlertRules();
@@ -113,21 +157,7 @@ export async function listAuditRecords(params: AuditQuery
= {}): Promise<PageRes
const page = params.page ?? 1;
const pageSize = params.pageSize ?? 20;
- const records = auditRecordsState.filter((record) => {
- const search = params.search?.trim().toLowerCase();
- if (
- search &&
- !includesIgnoreCase(record.operator, search) &&
- !includesIgnoreCase(record.target, search) &&
- !includesIgnoreCase(record.detail, search)
- ) {
- return false;
- }
- if (params.operationType && record.operationType !== params.operationType)
return false;
- if (params.startDate && record.timestamp < params.startDate) return false;
- if (params.endDate && record.timestamp > `${params.endDate} 23:59:59`)
return false;
- return !params.result || record.result.toUpperCase() ===
params.result.toUpperCase();
- });
+ const records = filterAuditRecords(params);
const from = (page - 1) * pageSize;
return {
items: records.slice(from, from + pageSize).map(copyAuditRecord),
@@ -137,6 +167,11 @@ export async function listAuditRecords(params: AuditQuery
= {}): Promise<PageRes
};
}
+export async function exportAuditLogs(params: AuditFilter = {}):
Promise<string> {
+ if (!USE_MOCK) return exportAuditLogsApi(params);
+ return formatAuditCsv(filterAuditRecords(params));
+}
+
export async function cleanupAuditLogs(beforeDays: number): Promise<number> {
if (USE_MOCK) {
const cutoff = new Date(Date.now() - beforeDays * 24 * 60 * 60 * 1000);