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);

Reply via email to