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 c55c3f30d feat(audit): add audit risk insights (#3131)
c55c3f30d is described below
commit c55c3f30db95bb97df03214e6e5bcef0c5801316
Author: Xiao Yang <[email protected]>
AuthorDate: Wed Sep 9 20:29:15 2026 +0800
feat(audit): add audit risk insights (#3131)
The audit dashboard summarised volume (matched records, success rate, failed
and partial counts, operator count, top operations, resource type spread)
but
said nothing about risk: an operator had to read the record table to notice
that failures were concentrated on control-plane operations, that one target
kept failing, or that a single operator accounted for most of the activity.
A risk panel below the summary card grades the loaded records into findings
for
failure rate, partial rate, control-plane failures, repeated failing
targets and
operator concentration, using named thresholds rather than inline literals,
and
lists the most repeated failing targets and the highest risk records in two
tables. Global rates come from the server summary while page-scoped figures
are
labelled as such, and a minimum sample guard keeps small result sets from
producing confident conclusions. Operation labels reuse the existing
getAuditOperationPresentation mapping.
The failure and partial rates are not restated as statistics here: the
summary
card directly above already shows the same numbers from the same summary,
so the
rates appear in this panel only inside the graded finding that carries the
threshold. Shared formatting comes from utils/format and both tables size
their
scroll width from their column declarations via tableScrollX.
---
web/src/i18n/translations.ts | 57 +++
web/src/pages/ops/AuditRiskInsights.tsx | 330 +++++++++++++++
web/src/pages/ops/__tests__/AuditPage.test.tsx | 12 +-
.../pages/ops/__tests__/AuditRiskInsights.test.tsx | 158 ++++++++
.../pages/ops/__tests__/auditRiskInsights.test.ts | 207 ++++++++++
web/src/pages/ops/audit.tsx | 2 +
web/src/pages/ops/auditRiskInsightModel.ts | 447 +++++++++++++++++++++
7 files changed, 1207 insertions(+), 6 deletions(-)
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index f71894289..079a5fad0 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -1225,6 +1225,63 @@ const translations: Record<string, Record<Lang, string>>
= {
'audit.cleanupDays': { zh: '清理 {n} 天之前的日志', en: 'Clean logs older than {n}
days' },
'audit.cleanupConfirm': { zh: '确认清理', en: 'Confirm Cleanup' },
'audit.cleanupSuccess': { zh: '已清理 {n} 天之前的日志', en: 'Cleaned logs older than
{n} days' },
+ 'auditInsights.title': { zh: '审计风险洞察', en: 'Audit Risk Insights' },
+ 'auditInsights.level.healthy': { zh: '健康', en: 'Healthy' },
+ 'auditInsights.level.notice': { zh: '提示', en: 'Notice' },
+ 'auditInsights.level.warning': { zh: '告警', en: 'Warning' },
+ 'auditInsights.level.critical': { zh: '严重', en: 'Critical' },
+ 'auditInsights.controlPlaneFailures': {
+ zh: '控制面异常',
+ en: 'Control-plane Failures',
+ },
+ 'auditInsights.topOperator': { zh: '最高频操作人', en: 'Top Operator' },
+ 'auditInsights.currentPageRecords': {
+ zh: '当前页 {count} 条记录',
+ en: '{count} records on this page',
+ },
+ 'auditInsights.operatorShare': { zh: '当前页占比 {value}', en: '{value} of this
page' },
+ 'auditInsights.findings': { zh: '需要关注的审计信号', en: 'Audit signals to review' },
+ 'auditInsights.hotTargets': { zh: '重复异常对象', en: 'Repeated Failed Targets' },
+ 'auditInsights.riskyRecords': { zh: '高风险记录', en: 'Risky Records' },
+ 'auditInsights.noHotTargets': {
+ zh: '当前页没有重复失败或部分成功的对象',
+ en: 'No repeated failed or partial targets on this page',
+ },
+ 'auditInsights.noRiskyRecords': {
+ zh: '当前页没有异常或高风险审计记录',
+ en: 'No failed or high-risk audit records on this page',
+ },
+ 'auditInsights.target': { zh: '对象', en: 'Target' },
+ 'auditInsights.failPartial': { zh: '失败 / 部分成功', en: 'Failed / Partial' },
+ 'auditInsights.issue.noMatchingRecords': {
+ zh: '当前筛选没有匹配的审计记录',
+ en: 'No audit records match the current filters',
+ },
+ 'auditInsights.issue.highFailureRate': {
+ zh: '当前筛选失败率 {value},高于 {threshold} 阈值',
+ en: 'Filtered failure rate is {value}, above the {threshold} threshold',
+ },
+ 'auditInsights.issue.partialOutcomes': {
+ zh: '当前筛选有 {count} 条部分成功记录',
+ en: '{count} filtered audit records are partially successful',
+ },
+ 'auditInsights.issue.controlPlaneFailures': {
+ zh: '当前页有 {count} 条控制面操作失败或部分成功',
+ en: '{count} control-plane operations failed or partially succeeded on
this page',
+ },
+ 'auditInsights.issue.highRiskFailures': {
+ zh: '当前页有 {count} 条高风险操作失败',
+ en: '{count} high-risk operations failed on this page',
+ },
+ 'auditInsights.issue.repeatedTargetFailures': {
+ zh: '{target} 在当前页出现 {count} 次失败或部分成功',
+ en: '{target} has {count} failed or partial audit events on this page',
+ },
+ 'auditInsights.issue.operatorConcentration': {
+ zh: '{operator} 占当前页 {value} 操作',
+ en: '{operator} owns {value} of operations on this page',
+ },
+ 'auditInsights.issue.unknown': { zh: '未知审计风险信号', en: 'Unknown audit risk
signal' },
// ─── AI Page ───
'ai.title': { zh: 'AI 交互', en: 'AI Chat' },
diff --git a/web/src/pages/ops/AuditRiskInsights.tsx
b/web/src/pages/ops/AuditRiskInsights.tsx
new file mode 100644
index 000000000..f16a7cf77
--- /dev/null
+++ b/web/src/pages/ops/AuditRiskInsights.tsx
@@ -0,0 +1,330 @@
+/*
+ * 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 {
+ Alert,
+ Card,
+ Col,
+ Empty,
+ Flex,
+ Progress,
+ Row,
+ Skeleton,
+ Space,
+ Statistic,
+ Table,
+ Tag,
+ Typography,
+} from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import type { AuditSummary } from '../../api/audit';
+import type { AuditRecord } from '../../api/ops';
+import { useLang } from '../../i18n/LangContext';
+import { formatDateTime, formatNumber } from '../../utils/format';
+import { tableScrollX } from '../../utils/table';
+import {
+ auditRiskOperationLabel,
+ buildAuditRiskInsights,
+ type AuditRiskIssue,
+ type AuditRiskLevel,
+ type AuditRiskRecord,
+ type AuditRiskTarget,
+} from './auditRiskInsightModel';
+import {
+ getAuditOperationPresentation,
+ getAuditResourcePresentation,
+ getAuditResultPresentation,
+} from './auditPresentation';
+
+const { Text } = Typography;
+
+interface Props {
+ summary: AuditSummary | null;
+ records: AuditRecord[];
+ loading: boolean;
+}
+
+const levelColor: Record<AuditRiskLevel, string> = {
+ healthy: 'success',
+ notice: 'processing',
+ warning: 'warning',
+ critical: 'error',
+};
+
+const issueTextKey = (issue: AuditRiskIssue) => {
+ switch (issue.code) {
+ case 'NO_MATCHING_RECORDS':
+ return 'auditInsights.issue.noMatchingRecords';
+ case 'HIGH_FAILURE_RATE':
+ return 'auditInsights.issue.highFailureRate';
+ case 'PARTIAL_OUTCOMES':
+ return 'auditInsights.issue.partialOutcomes';
+ case 'CONTROL_PLANE_FAILURES':
+ return 'auditInsights.issue.controlPlaneFailures';
+ case 'HIGH_RISK_FAILURES':
+ return 'auditInsights.issue.highRiskFailures';
+ case 'REPEATED_TARGET_FAILURES':
+ return 'auditInsights.issue.repeatedTargetFailures';
+ case 'OPERATOR_CONCENTRATION':
+ return 'auditInsights.issue.operatorConcentration';
+ default:
+ return 'auditInsights.issue.unknown';
+ }
+};
+
+// Not utils/format's formatPercent: that one uses toFixed and always emits a
decimal, whereas
+// shares read better without a trailing zero, matching the dashboard's
traffic percentages.
+const formatSharePercent = (value: number) =>
+ `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })}%`;
+
+const formatOperationLabel = (operationType: string, t: (key: string) =>
string): string => {
+ const presentation = getAuditOperationPresentation(operationType);
+ return presentation.labelKey ? t(presentation.labelKey) :
auditRiskOperationLabel(operationType);
+};
+
+const formatResourceLabel = (resourceType: string, t: (key: string) =>
string): string => {
+ const presentation = getAuditResourcePresentation(resourceType);
+ return presentation.labelKey ? t(presentation.labelKey) : presentation.label;
+};
+
+const formatResultLabel = (result: string, t: (key: string) => string): string
=> {
+ const presentation = getAuditResultPresentation(result);
+ return presentation.labelKey ? t(presentation.labelKey) : presentation.label;
+};
+
+const AuditRiskInsights = ({ summary, records, loading }: Props) => {
+ const { t } = useLang();
+ const insights = buildAuditRiskInsights(summary, records);
+ const visibleIssues = insights.issues.slice(0, 5);
+
+ const renderIssue = (issue: AuditRiskIssue) => (
+ <Tag
+ key={`${issue.code}-${issue.target ?? issue.operator ?? 'global'}`}
+ color={levelColor[issue.level]}
+ >
+ {t(issueTextKey(issue), {
+ count: issue.count ?? 0,
+ value: issue.percent == null ? '-' : formatSharePercent(issue.percent),
+ operator: issue.operator ?? '-',
+ target: issue.target ?? '-',
+ threshold: issue.threshold == null ? '-' :
formatSharePercent(issue.threshold),
+ })}
+ </Tag>
+ );
+
+ const hotTargetColumns: ColumnsType<AuditRiskTarget> = [
+ {
+ title: t('auditInsights.target'),
+ dataIndex: 'target',
+ key: 'target',
+ render: (target: string, row) => (
+ <Flex vertical gap={2}>
+ <Text strong>{target}</Text>
+ <Text type="secondary">
+ {formatResourceLabel(row.resourceType, t)} · {row.clusterId}
+ </Text>
+ </Flex>
+ ),
+ },
+ {
+ title: t('auditInsights.failPartial'),
+ key: 'failPartial',
+ width: 130,
+ align: 'right',
+ render: (_, row) => `${row.failed} / ${row.partial}`,
+ },
+ {
+ title: t('audit.opType'),
+ dataIndex: 'operationTypes',
+ key: 'operationTypes',
+ render: (operationTypes: string[]) => (
+ <Space size={[4, 4]} wrap>
+ {operationTypes.slice(0, 3).map((operationType) => (
+ <Tag key={operationType} style={{ marginInlineEnd: 0 }}>
+ {formatOperationLabel(operationType, t)}
+ </Tag>
+ ))}
+ </Space>
+ ),
+ },
+ {
+ title: t('audit.time'),
+ dataIndex: 'latestAt',
+ key: 'latestAt',
+ width: 180,
+ render: (latestAt: string | null) => formatDateTime(latestAt),
+ },
+ ];
+
+ const riskyRecordColumns: ColumnsType<AuditRiskRecord> = [
+ {
+ title: t('audit.time'),
+ dataIndex: 'timestamp',
+ key: 'timestamp',
+ width: 180,
+ render: (timestamp: string) => formatDateTime(timestamp),
+ },
+ {
+ title: t('audit.operator'),
+ dataIndex: 'operator',
+ key: 'operator',
+ width: 130,
+ },
+ {
+ title: t('audit.opType'),
+ dataIndex: 'operationType',
+ key: 'operationType',
+ render: (operationType: string) => {
+ const presentation = getAuditOperationPresentation(operationType);
+ return <Tag
color={presentation.color}>{formatOperationLabel(operationType, t)}</Tag>;
+ },
+ },
+ {
+ title: t('audit.target'),
+ dataIndex: 'target',
+ key: 'target',
+ ellipsis: true,
+ },
+ {
+ title: t('audit.result'),
+ dataIndex: 'result',
+ key: 'result',
+ width: 100,
+ align: 'center',
+ render: (result: string) => {
+ const presentation = getAuditResultPresentation(result);
+ return <Tag color={presentation.color}>{formatResultLabel(result,
t)}</Tag>;
+ },
+ },
+ ];
+
+ if (loading) {
+ return (
+ <Card title={t('auditInsights.title')} style={{ marginBottom: 16 }}>
+ <Skeleton active paragraph={{ rows: 4 }} />
+ </Card>
+ );
+ }
+
+ return (
+ <Card
+ title={t('auditInsights.title')}
+ extra={
+ <Tag
color={levelColor[insights.level]}>{t(`auditInsights.level.${insights.level}`)}</Tag>
+ }
+ style={{ marginBottom: 16 }}
+ >
+ <Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
+ <Col xs={12} lg={12}>
+ <Card size="small">
+ <Statistic
+ title={t('auditInsights.controlPlaneFailures')}
+ value={insights.controlPlaneFailureCount}
+ valueStyle={{ color: insights.controlPlaneFailureCount ?
'#cf1322' : undefined }}
+ />
+ <Text type="secondary">
+ {t('auditInsights.currentPageRecords', {
+ count: formatNumber(insights.pageRecordCount),
+ })}
+ </Text>
+ </Card>
+ </Col>
+ <Col xs={12} lg={12}>
+ <Card size="small">
+ <Statistic
+ title={t('auditInsights.topOperator')}
+ value={insights.topOperator?.name ?? '-'}
+ />
+ <Text type="secondary">
+ {insights.topOperator
+ ? t('auditInsights.operatorShare', {
+ value: formatSharePercent(insights.topOperator.percent),
+ })
+ : t('common.noData')}
+ </Text>
+ </Card>
+ </Col>
+ </Row>
+
+ {visibleIssues.length > 0 && (
+ <Alert
+ showIcon
+ type={
+ insights.level === 'critical'
+ ? 'error'
+ : insights.level === 'warning'
+ ? 'warning'
+ : 'info'
+ }
+ message={t('auditInsights.findings')}
+ description={
+ <Flex gap={8} wrap>
+ {visibleIssues.map(renderIssue)}
+ </Flex>
+ }
+ style={{ marginBottom: 16 }}
+ />
+ )}
+
+ <Row gutter={[12, 12]}>
+ <Col xs={24} lg={12}>
+ <Card size="small" title={t('auditInsights.hotTargets')}>
+ {insights.hotTargets.length === 0 ? (
+ <Empty
+ image={Empty.PRESENTED_IMAGE_SIMPLE}
+ description={t('auditInsights.noHotTargets')}
+ />
+ ) : (
+ <Table
+ size="small"
+ rowKey="key"
+ dataSource={insights.hotTargets}
+ scroll={{ x: tableScrollX(hotTargetColumns) }}
+ columns={hotTargetColumns}
+ pagination={false}
+ />
+ )}
+ </Card>
+ </Col>
+ <Col xs={24} lg={12}>
+ <Card size="small" title={t('auditInsights.riskyRecords')}>
+ {insights.riskyRecords.length === 0 ? (
+ <Flex vertical gap={8}>
+ <Progress percent={100} status="success" showInfo={false} />
+ <Empty
+ image={Empty.PRESENTED_IMAGE_SIMPLE}
+ description={t('auditInsights.noRiskyRecords')}
+ />
+ </Flex>
+ ) : (
+ <Table
+ size="small"
+ rowKey="id"
+ dataSource={insights.riskyRecords}
+ scroll={{ x: tableScrollX(riskyRecordColumns) }}
+ columns={riskyRecordColumns}
+ pagination={false}
+ />
+ )}
+ </Card>
+ </Col>
+ </Row>
+ </Card>
+ );
+};
+
+export default AuditRiskInsights;
diff --git a/web/src/pages/ops/__tests__/AuditPage.test.tsx
b/web/src/pages/ops/__tests__/AuditPage.test.tsx
index fed3c0322..3d07f0fb3 100644
--- a/web/src/pages/ops/__tests__/AuditPage.test.tsx
+++ b/web/src/pages/ops/__tests__/AuditPage.test.tsx
@@ -129,7 +129,7 @@ describe('Audit page', () => {
const user = userEvent.setup();
renderWithProviders(<AuditPage />);
- expect(await screen.findByText('topic-a')).toBeInTheDocument();
+ expect(await screen.findAllByText('topic-a')).not.toHaveLength(0);
await user.type(screen.getByPlaceholderText('搜索操作人或操作对象'), 'topic-a');
await waitFor(() =>
expect(opsService.listAuditRecords).toHaveBeenLastCalledWith(
@@ -179,9 +179,9 @@ describe('Audit page', () => {
renderWithProviders(<AuditPage />);
- expect(await screen.findByText('重载 Proxy 配置')).toBeInTheDocument();
- expect(screen.getByText('Proxy')).toBeInTheDocument();
- expect(screen.getByText('成功')).toBeInTheDocument();
+ expect(await screen.findAllByText('重载 Proxy 配置')).not.toHaveLength(0);
+ expect(screen.getAllByText('Proxy')).not.toHaveLength(0);
+ expect(screen.getAllByText('成功')).not.toHaveLength(0);
expect(screen.getByText('topic: orders')).toBeInTheDocument();
expect(screen.getByText('timestamp: 1784246400000')).toBeInTheDocument();
});
@@ -208,12 +208,12 @@ describe('Audit page', () => {
const user = userEvent.setup();
renderWithProviders(<AuditPage />);
- expect(await screen.findByText('topic-a')).toBeInTheDocument();
+ expect(await screen.findAllByText('topic-a')).not.toHaveLength(0);
await user.click(screen.getByRole('combobox', { name: '操作类型' }));
await user.click(
await screen.findByText('创建 Topic', { selector:
'.ant-select-item-option-content' }),
);
- expect(screen.getByText('成功')).toBeInTheDocument();
+ expect(screen.getAllByText('成功')).not.toHaveLength(0);
await user.click(screen.getByRole('combobox', { name: '资源类型' }));
await user.click(
await screen.findByText('消费组', {
diff --git a/web/src/pages/ops/__tests__/AuditRiskInsights.test.tsx
b/web/src/pages/ops/__tests__/AuditRiskInsights.test.tsx
new file mode 100644
index 000000000..a97f45779
--- /dev/null
+++ b/web/src/pages/ops/__tests__/AuditRiskInsights.test.tsx
@@ -0,0 +1,158 @@
+/*
+ * 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, within } from '@testing-library/react';
+import { beforeAll, describe, expect, it, vi } from 'vitest';
+import type { AuditSummary } from '../../../api/audit';
+import type { AuditRecord } from '../../../api/ops';
+import { LangProvider } from '../../../i18n/LangContext';
+import AuditRiskInsights from '../AuditRiskInsights';
+
+const summary: AuditSummary = {
+ total: 10,
+ successful: 5,
+ failed: 4,
+ partial: 1,
+ uniqueOperators: 3,
+ latestAt: '2026-08-01 10:00:00',
+ byOperation: [],
+ byResourceType: [],
+};
+
+const records: AuditRecord[] = [
+ {
+ id: 1,
+ timestamp: '2026-08-01 10:05:00',
+ operator: 'admin',
+ operationType: 'DELETE_TOPIC',
+ resourceType: 'TOPIC',
+ target: 'orders',
+ clusterId: 'prod-cn',
+ detail: '',
+ result: 'FAILED',
+ errorMessage: 'topic busy',
+ },
+ {
+ id: 2,
+ timestamp: '2026-08-01 10:06:00',
+ operator: 'admin',
+ operationType: 'DELETE_TOPIC',
+ resourceType: 'TOPIC',
+ target: 'orders',
+ clusterId: 'prod-cn',
+ detail: '',
+ result: 'FAILED',
+ errorMessage: 'topic busy',
+ },
+ {
+ id: 3,
+ timestamp: '2026-08-01 10:07:00',
+ operator: 'ops',
+ operationType: 'RELOAD_PROXY_CONFIG',
+ resourceType: 'PROXY',
+ target: '10.0.0.1:8081',
+ clusterId: 'prod-cn',
+ detail: '',
+ result: 'PARTIAL',
+ errorMessage: '',
+ },
+];
+
+const renderPanel = (props = {}) =>
+ render(
+ <App>
+ <LangProvider>
+ <AuditRiskInsights summary={summary} records={records} loading={false}
{...props} />
+ </LangProvider>
+ </App>,
+ );
+
+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(),
+ })),
+ });
+});
+
+describe('AuditRiskInsights', () => {
+ it('renders risk findings without restating the summary rates', () => {
+ renderPanel();
+
+ expect(screen.getByText('审计风险洞察')).toBeInTheDocument();
+ expect(screen.getByText('严重')).toBeInTheDocument();
+ // The failure and partial rates live on the summary card above this
panel, so here they only
+ // appear inside the graded finding that carries the threshold.
+ expect(screen.queryByText('失败率')).not.toBeInTheDocument();
+ expect(screen.queryByText('当前筛选共 10 条')).not.toBeInTheDocument();
+ expect(screen.getByText('控制面异常')).toBeInTheDocument();
+ expect(screen.getByText('最高频操作人')).toBeInTheDocument();
+ expect(screen.getByText('需要关注的审计信号')).toBeInTheDocument();
+ expect(screen.getByText(/当前筛选失败率 40%/u)).toBeInTheDocument();
+ expect(screen.getByText(/当前页有 2 条高风险操作失败/u)).toBeInTheDocument();
+ expect(screen.getByText(/orders 在当前页出现 2 次失败或部分成功/u)).toBeInTheDocument();
+ });
+
+ it('renders repeated target and risky record tables', () => {
+ renderPanel();
+
+ const repeatedTargetCard = screen.getByText('重复异常对象').closest('.ant-card');
+ expect(repeatedTargetCard).not.toBeNull();
+ expect(within(repeatedTargetCard as
HTMLElement).getByText('orders')).toBeInTheDocument();
+ expect(within(repeatedTargetCard as HTMLElement).getByText('2 /
0')).toBeInTheDocument();
+ expect(within(repeatedTargetCard as HTMLElement).getByText('删除
Topic')).toBeInTheDocument();
+
+ const riskyRecordCard = screen.getByText('高风险记录').closest('.ant-card');
+ expect(riskyRecordCard).not.toBeNull();
+ expect(within(riskyRecordCard as
HTMLElement).getAllByText('admin')).toHaveLength(2);
+ expect(within(riskyRecordCard as
HTMLElement).getAllByText('失败')).not.toHaveLength(0);
+ });
+
+ it('shows an empty state when the filtered result has no records', () => {
+ renderPanel({
+ summary: {
+ ...summary,
+ total: 0,
+ successful: 0,
+ failed: 0,
+ partial: 0,
+ },
+ records: [],
+ });
+
+ expect(screen.getByText('提示')).toBeInTheDocument();
+ expect(screen.getByText('当前筛选没有匹配的审计记录')).toBeInTheDocument();
+ expect(screen.getByText('当前页没有重复失败或部分成功的对象')).toBeInTheDocument();
+ expect(screen.getByText('当前页没有异常或高风险审计记录')).toBeInTheDocument();
+ });
+
+ it('renders a loading skeleton while audit data is refreshing', () => {
+ renderPanel({ loading: true });
+
+ expect(screen.getByText('审计风险洞察')).toBeInTheDocument();
+ expect(screen.queryByText('失败率')).not.toBeInTheDocument();
+ });
+});
diff --git a/web/src/pages/ops/__tests__/auditRiskInsights.test.ts
b/web/src/pages/ops/__tests__/auditRiskInsights.test.ts
new file mode 100644
index 000000000..693eba982
--- /dev/null
+++ b/web/src/pages/ops/__tests__/auditRiskInsights.test.ts
@@ -0,0 +1,207 @@
+/*
+ * 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 { describe, expect, it } from 'vitest';
+import type { AuditSummary } from '../../../api/audit';
+import type { AuditRecord } from '../../../api/ops';
+import {
+ buildAuditRiskInsights,
+ isFailedAuditResult,
+ isHighRiskAuditOperation,
+ isPartialAuditResult,
+ normalizeAuditResult,
+} from '../auditRiskInsightModel';
+
+const summary = (overrides: Partial<AuditSummary> = {}): AuditSummary => ({
+ total: 10,
+ successful: 6,
+ failed: 3,
+ partial: 1,
+ uniqueOperators: 3,
+ latestAt: '2026-08-01 10:00:00',
+ byOperation: [],
+ byResourceType: [],
+ ...overrides,
+});
+
+const record = (overrides: Partial<AuditRecord> = {}): AuditRecord => ({
+ id: 1,
+ timestamp: '2026-08-01 10:00:00',
+ operator: 'admin',
+ operationType: 'CREATE_TOPIC',
+ resourceType: 'TOPIC',
+ target: 'orders',
+ clusterId: 'prod-cn',
+ detail: '',
+ result: 'SUCCESS',
+ errorMessage: '',
+ ...overrides,
+});
+
+describe('audit risk insights', () => {
+ it('normalizes backend result aliases before classifying failures', () => {
+ expect(normalizeAuditResult(' failure ')).toBe('FAILED');
+ expect(isFailedAuditResult('FAILURE')).toBe(true);
+ expect(isFailedAuditResult('FAILED')).toBe(true);
+ expect(isPartialAuditResult('partial')).toBe(true);
+ expect(isFailedAuditResult('SUCCESS')).toBe(false);
+ });
+
+ it('classifies destructive and control-plane operations as high-risk
signals', () => {
+ expect(isHighRiskAuditOperation('DELETE_TOPIC')).toBe(true);
+ expect(isHighRiskAuditOperation('RESET_OFFSET')).toBe(true);
+ expect(isHighRiskAuditOperation('REMOVE_PROXY_ADDRESS')).toBe(true);
+ expect(isHighRiskAuditOperation('UPDATE_CLUSTER_CONFIG')).toBe(true);
+ expect(isHighRiskAuditOperation('SEND_MESSAGE')).toBe(false);
+ });
+
+ it('combines filtered summary rates with current-page risky records', () => {
+ const insights = buildAuditRiskInsights(
+ summary({ total: 10, successful: 5, failed: 4, partial: 1 }),
+ [
+ record({
+ id: 1,
+ operationType: 'DELETE_TOPIC',
+ target: 'orders',
+ result: 'FAILED',
+ timestamp: '2026-08-01 10:05:00',
+ }),
+ record({
+ id: 2,
+ operationType: 'DELETE_TOPIC',
+ target: 'orders',
+ result: 'FAILED',
+ timestamp: '2026-08-01 10:06:00',
+ }),
+ record({
+ id: 3,
+ operationType: 'RELOAD_PROXY_CONFIG',
+ resourceType: 'PROXY',
+ target: '10.0.0.1:8081',
+ result: 'PARTIAL',
+ timestamp: '2026-08-01 10:07:00',
+ }),
+ record({
+ id: 4,
+ operationType: 'SEND_MESSAGE',
+ resourceType: 'MESSAGE',
+ target: 'orders',
+ result: 'SUCCESS',
+ }),
+ ],
+ );
+
+ expect(insights.level).toBe('critical');
+ expect(insights.failureRate).toBe(40);
+ expect(insights.partialRate).toBe(10);
+ expect(insights.highRiskFailureCount).toBe(2);
+ expect(insights.controlPlaneFailureCount).toBe(3);
+ expect(insights.hotTargets[0]).toEqual(
+ expect.objectContaining({
+ target: 'orders',
+ failed: 2,
+ partial: 0,
+ level: 'warning',
+ }),
+ );
+ expect(insights.issues).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ code: 'HIGH_FAILURE_RATE', level: 'critical'
}),
+ expect.objectContaining({ code: 'HIGH_RISK_FAILURES', level:
'critical' }),
+ expect.objectContaining({ code: 'REPEATED_TARGET_FAILURES', target:
'orders' }),
+ ]),
+ );
+ });
+
+ it('detects operator concentration without marking it as an error', () => {
+ const insights = buildAuditRiskInsights(null, [
+ record({ id: 1, operator: 'ops-a' }),
+ record({ id: 2, operator: 'ops-a', target: 'payments' }),
+ record({ id: 3, operator: 'ops-a', target: 'refunds' }),
+ record({ id: 4, operator: 'ops-b', target: 'invoices' }),
+ ]);
+
+ expect(insights.level).toBe('notice');
+ expect(insights.topOperator).toEqual(
+ expect.objectContaining({
+ name: 'ops-a',
+ count: 3,
+ percent: 75,
+ level: 'notice',
+ }),
+ );
+ expect(insights.issues).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ code: 'OPERATOR_CONCENTRATION', level:
'notice' }),
+ ]),
+ );
+ });
+
+ it('falls back to current-page records when server summary is unavailable',
() => {
+ const insights = buildAuditRiskInsights(null, [
+ record({ id: 1, result: 'FAILED' }),
+ record({ id: 2, result: 'PARTIAL' }),
+ record({ id: 3, result: 'SUCCESS' }),
+ ]);
+
+ expect(insights.total).toBe(3);
+ expect(insights.failed).toBe(1);
+ expect(insights.partial).toBe(1);
+ expect(insights.failureRate).toBe(33.3);
+ expect(insights.partialRate).toBe(33.3);
+ });
+
+ it('does not list successful low-risk control-plane records as risky
records', () => {
+ const insights = buildAuditRiskInsights(null, [
+ record({
+ id: 1,
+ operationType: 'CREATE_TOPIC',
+ resourceType: 'TOPIC',
+ result: 'SUCCESS',
+ }),
+ record({
+ id: 2,
+ operationType: 'DELETE_TOPIC',
+ resourceType: 'TOPIC',
+ result: 'SUCCESS',
+ }),
+ ]);
+
+ expect(insights.controlPlaneFailureCount).toBe(0);
+ expect(insights.riskyRecords).toEqual([
+ expect.objectContaining({
+ id: 2,
+ operationType: 'DELETE_TOPIC',
+ reason: 'high-risk',
+ }),
+ ]);
+ });
+
+ it('reports an empty filtered result as a notice', () => {
+ const insights = buildAuditRiskInsights(
+ summary({ total: 0, successful: 0, failed: 0, partial: 0 }),
+ [],
+ );
+
+ expect(insights.level).toBe('notice');
+ expect(insights.issues).toEqual([
+ expect.objectContaining({ code: 'NO_MATCHING_RECORDS', level: 'notice'
}),
+ ]);
+ expect(insights.hotTargets).toEqual([]);
+ expect(insights.riskyRecords).toEqual([]);
+ });
+});
diff --git a/web/src/pages/ops/audit.tsx b/web/src/pages/ops/audit.tsx
index 97e4b33aa..c0d45c7db 100644
--- a/web/src/pages/ops/audit.tsx
+++ b/web/src/pages/ops/audit.tsx
@@ -59,6 +59,7 @@ import {
parseAuditDetail,
} from './auditPresentation';
import AuditSummaryCards from './AuditSummaryCards';
+import AuditRiskInsights from './AuditRiskInsights';
const emptyFilterOptions: AuditFilterOptions = {
operationTypes: [],
@@ -489,6 +490,7 @@ const AuditPage: React.FC = () => {
</Flex>
<AuditSummaryCards summary={summary} loading={summaryLoading} />
+ <AuditRiskInsights summary={summary} records={records} loading={loading
|| summaryLoading} />
{/* ─── Table ─── */}
<Card styles={{ body: { padding: 0 } }}>
diff --git a/web/src/pages/ops/auditRiskInsightModel.ts
b/web/src/pages/ops/auditRiskInsightModel.ts
new file mode 100644
index 000000000..d59c53c39
--- /dev/null
+++ b/web/src/pages/ops/auditRiskInsightModel.ts
@@ -0,0 +1,447 @@
+/*
+ * 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 type { AuditSummary } from '../../api/audit';
+import type { AuditRecord } from '../../api/ops';
+import {
+ getAuditOperationPresentation,
+ isControlPlaneAuditRecord,
+ normalizeAuditCode,
+} from './auditPresentation';
+
+export type AuditRiskLevel = 'healthy' | 'notice' | 'warning' | 'critical';
+
+export type AuditRiskIssueCode =
+ | 'NO_MATCHING_RECORDS'
+ | 'HIGH_FAILURE_RATE'
+ | 'PARTIAL_OUTCOMES'
+ | 'CONTROL_PLANE_FAILURES'
+ | 'HIGH_RISK_FAILURES'
+ | 'REPEATED_TARGET_FAILURES'
+ | 'OPERATOR_CONCENTRATION';
+
+export interface AuditRiskIssue {
+ code: AuditRiskIssueCode;
+ level: AuditRiskLevel;
+ count?: number;
+ percent?: number;
+ operator?: string;
+ target?: string;
+ threshold?: number;
+}
+
+export interface AuditRiskTarget {
+ key: string;
+ target: string;
+ resourceType: string;
+ clusterId: string;
+ operationTypes: string[];
+ total: number;
+ failed: number;
+ partial: number;
+ latestAt: string | null;
+ level: AuditRiskLevel;
+}
+
+export interface AuditRiskOperator {
+ name: string;
+ count: number;
+ failed: number;
+ partial: number;
+ percent: number;
+ level: AuditRiskLevel;
+}
+
+export interface AuditRiskRecord {
+ id: number;
+ timestamp: string;
+ operator: string;
+ operationType: string;
+ resourceType: string;
+ target: string;
+ clusterId: string;
+ result: string;
+ level: AuditRiskLevel;
+ reason: 'failed' | 'partial' | 'high-risk';
+}
+
+export interface AuditRiskInsights {
+ level: AuditRiskLevel;
+ total: number;
+ pageRecordCount: number;
+ failed: number;
+ partial: number;
+ failureRate: number;
+ partialRate: number;
+ controlPlaneFailureCount: number;
+ highRiskFailureCount: number;
+ topOperator: AuditRiskOperator | null;
+ hotTargets: AuditRiskTarget[];
+ riskyRecords: AuditRiskRecord[];
+ issues: AuditRiskIssue[];
+}
+
+const FAILURE_RATE_WARNING_PERCENT = 10;
+const FAILURE_RATE_CRITICAL_PERCENT = 30;
+const PARTIAL_RATE_NOTICE_PERCENT = 15;
+const OPERATOR_CONCENTRATION_NOTICE_PERCENT = 60;
+const REPEATED_TARGET_FAILURE_THRESHOLD = 2;
+const MIN_RECORDS_FOR_RATE = 5;
+const MIN_RECORDS_FOR_OPERATOR_CONCENTRATION = 4;
+
+const levelWeight: Record<AuditRiskLevel, number> = {
+ healthy: 0,
+ notice: 1,
+ warning: 2,
+ critical: 3,
+};
+
+const highRiskOperationCodes = new Set([
+ 'DELETE_TOPIC',
+ 'DELETE_GROUP',
+ 'RESET_OFFSET',
+ 'REMOVE_PROXY_ADDRESS',
+ 'RELOAD_PROXY_CONFIG',
+ 'UPDATE_BROKER_CONFIG',
+ 'UPDATE_CLUSTER_CONFIG',
+ 'RESTART_BROKER',
+ 'DELETE_ACL_RULE',
+ 'DELETE_ACL_USER',
+ 'UPSERT_PLAIN_ACCESS_CONFIG',
+ 'DELETE_DATA_SOURCE',
+ 'DELETE_CLOUD_CREDENTIAL',
+ 'DELETE_ALERT_RULE',
+ 'CLEAR_ACKNOWLEDGED_SYSTEM_ALERTS',
+ 'DELETE_INSTANCE',
+ 'RENEW_K8S_CERTIFICATE',
+ 'DELETE_K8S_CERTIFICATE',
+]);
+
+const roundPercent = (value: number): number => Math.round(value * 10) / 10;
+
+const percent = (count: number, total: number): number => {
+ if (total <= 0) return 0;
+ return roundPercent((count / total) * 100);
+};
+
+const maxLevel = (levels: AuditRiskLevel[]): AuditRiskLevel =>
+ levels.reduce<AuditRiskLevel>(
+ (current, next) => (levelWeight[next] > levelWeight[current] ? next :
current),
+ 'healthy',
+ );
+
+const normalizeText = (value: string | null | undefined, fallback = '-'):
string => {
+ const text = value?.trim();
+ return text || fallback;
+};
+
+export const normalizeAuditResult = (result: string | null | undefined):
string => {
+ const normalized = normalizeAuditCode(result);
+ return normalized === 'FAILURE' ? 'FAILED' : normalized;
+};
+
+export const isFailedAuditResult = (result: string | null | undefined):
boolean =>
+ normalizeAuditResult(result) === 'FAILED';
+
+export const isPartialAuditResult = (result: string | null | undefined):
boolean =>
+ normalizeAuditResult(result) === 'PARTIAL';
+
+export const isUnsuccessfulAuditResult = (result: string | null | undefined):
boolean =>
+ isFailedAuditResult(result) || isPartialAuditResult(result);
+
+export const isHighRiskAuditOperation = (operationType: string | null |
undefined): boolean => {
+ const normalized = normalizeAuditCode(operationType);
+ return (
+ highRiskOperationCodes.has(normalized) ||
+ normalized.startsWith('DELETE_') ||
+ normalized.startsWith('REMOVE_') ||
+ normalized.startsWith('RESET_') ||
+ normalized.startsWith('RESTART_') ||
+ normalized.startsWith('CLEAR_')
+ );
+};
+
+const compareTimestampDesc = (left: string | null, right: string | null):
number => {
+ const leftTime = left ? new Date(left).getTime() : 0;
+ const rightTime = right ? new Date(right).getTime() : 0;
+ return rightTime - leftTime;
+};
+
+const buildTargetKey = (record: AuditRecord): string =>
+ [
+ normalizeText(record.clusterId, 'global'),
+ normalizeAuditCode(record.resourceType) || 'RESOURCE',
+ normalizeText(record.target, '-'),
+ ].join('|');
+
+const toRiskRecord = (record: AuditRecord): AuditRiskRecord | null => {
+ const failed = isFailedAuditResult(record.result);
+ const partial = isPartialAuditResult(record.result);
+ const highRisk = isHighRiskAuditOperation(record.operationType);
+ if (!failed && !partial && !highRisk) return null;
+
+ return {
+ id: record.id,
+ timestamp: record.timestamp,
+ operator: normalizeText(record.operator),
+ operationType: normalizeAuditCode(record.operationType) ||
normalizeText(record.operationType),
+ resourceType: normalizeAuditCode(record.resourceType) ||
normalizeText(record.resourceType),
+ target: normalizeText(record.target),
+ clusterId: normalizeText(record.clusterId),
+ result: normalizeAuditResult(record.result) ||
normalizeText(record.result),
+ level: failed && highRisk ? 'critical' : failed ? 'warning' : partial ?
'notice' : 'notice',
+ reason: failed ? 'failed' : partial ? 'partial' : 'high-risk',
+ };
+};
+
+const buildHotTargets = (records: AuditRecord[]): AuditRiskTarget[] => {
+ const groups = new Map<string, AuditRiskTarget>();
+ records.forEach((record) => {
+ if (!isUnsuccessfulAuditResult(record.result)) return;
+ const key = buildTargetKey(record);
+ const existing =
+ groups.get(key) ??
+ ({
+ key,
+ target: normalizeText(record.target),
+ resourceType: normalizeAuditCode(record.resourceType) ||
normalizeText(record.resourceType),
+ clusterId: normalizeText(record.clusterId),
+ operationTypes: [],
+ total: 0,
+ failed: 0,
+ partial: 0,
+ latestAt: null,
+ level: 'notice',
+ } satisfies AuditRiskTarget);
+ existing.total += 1;
+ if (isFailedAuditResult(record.result)) existing.failed += 1;
+ if (isPartialAuditResult(record.result)) existing.partial += 1;
+ const operationType =
+ normalizeAuditCode(record.operationType) ||
normalizeText(record.operationType);
+ if (!existing.operationTypes.includes(operationType))
+ existing.operationTypes.push(operationType);
+ if (compareTimestampDesc(record.timestamp, existing.latestAt) < 0) {
+ existing.latestAt = record.timestamp;
+ }
+ existing.level =
+ existing.failed >= REPEATED_TARGET_FAILURE_THRESHOLD ||
+ existing.operationTypes.some(isHighRiskAuditOperation)
+ ? 'warning'
+ : 'notice';
+ groups.set(key, existing);
+ });
+
+ return [...groups.values()]
+ .filter((target) => target.total >= REPEATED_TARGET_FAILURE_THRESHOLD)
+ .sort(
+ (left, right) =>
+ levelWeight[right.level] - levelWeight[left.level] ||
+ right.failed - left.failed ||
+ right.partial - left.partial ||
+ compareTimestampDesc(left.latestAt, right.latestAt) ||
+ left.target.localeCompare(right.target),
+ )
+ .slice(0, 5);
+};
+
+const buildTopOperator = (records: AuditRecord[]): AuditRiskOperator | null =>
{
+ if (records.length === 0) return null;
+ const operators = new Map<string, AuditRiskOperator>();
+ records.forEach((record) => {
+ const name = normalizeText(record.operator, 'system');
+ const existing =
+ operators.get(name) ??
+ ({
+ name,
+ count: 0,
+ failed: 0,
+ partial: 0,
+ percent: 0,
+ level: 'healthy',
+ } satisfies AuditRiskOperator);
+ existing.count += 1;
+ if (isFailedAuditResult(record.result)) existing.failed += 1;
+ if (isPartialAuditResult(record.result)) existing.partial += 1;
+ operators.set(name, existing);
+ });
+ const [topOperator] = [...operators.values()].sort(
+ (left, right) => right.count - left.count ||
left.name.localeCompare(right.name),
+ );
+ if (!topOperator) return null;
+
+ topOperator.percent = percent(topOperator.count, records.length);
+ topOperator.level =
+ records.length >= MIN_RECORDS_FOR_OPERATOR_CONCENTRATION &&
+ topOperator.percent >= OPERATOR_CONCENTRATION_NOTICE_PERCENT
+ ? 'notice'
+ : 'healthy';
+ return topOperator;
+};
+
+const buildRiskIssues = (params: {
+ total: number;
+ failed: number;
+ partial: number;
+ failureRate: number;
+ partialRate: number;
+ controlPlaneFailureCount: number;
+ highRiskFailureCount: number;
+ topOperator: AuditRiskOperator | null;
+ hotTargets: AuditRiskTarget[];
+}): AuditRiskIssue[] => {
+ const issues: AuditRiskIssue[] = [];
+
+ if (params.total === 0) {
+ issues.push({ code: 'NO_MATCHING_RECORDS', level: 'notice' });
+ return issues;
+ }
+
+ if (params.total >= MIN_RECORDS_FOR_RATE && params.failureRate >=
FAILURE_RATE_WARNING_PERCENT) {
+ issues.push({
+ code: 'HIGH_FAILURE_RATE',
+ level:
+ params.failureRate >= FAILURE_RATE_CRITICAL_PERCENT || params.failed
>= 5
+ ? 'critical'
+ : 'warning',
+ count: params.failed,
+ percent: params.failureRate,
+ threshold: FAILURE_RATE_WARNING_PERCENT,
+ });
+ }
+
+ if (params.partialRate >= PARTIAL_RATE_NOTICE_PERCENT) {
+ issues.push({
+ code: 'PARTIAL_OUTCOMES',
+ level: 'notice',
+ count: params.partial,
+ percent: params.partialRate,
+ threshold: PARTIAL_RATE_NOTICE_PERCENT,
+ });
+ }
+
+ if (params.controlPlaneFailureCount > 0) {
+ issues.push({
+ code: 'CONTROL_PLANE_FAILURES',
+ level: params.controlPlaneFailureCount >= 3 ? 'warning' : 'notice',
+ count: params.controlPlaneFailureCount,
+ });
+ }
+
+ if (params.highRiskFailureCount > 0) {
+ issues.push({
+ code: 'HIGH_RISK_FAILURES',
+ level: params.highRiskFailureCount >= 2 ? 'critical' : 'warning',
+ count: params.highRiskFailureCount,
+ });
+ }
+
+ const repeatedTarget = params.hotTargets[0];
+ if (repeatedTarget) {
+ issues.push({
+ code: 'REPEATED_TARGET_FAILURES',
+ level: repeatedTarget.level,
+ count: repeatedTarget.total,
+ target: repeatedTarget.target,
+ });
+ }
+
+ if (params.topOperator?.level === 'notice') {
+ issues.push({
+ code: 'OPERATOR_CONCENTRATION',
+ level: 'notice',
+ count: params.topOperator.count,
+ percent: params.topOperator.percent,
+ operator: params.topOperator.name,
+ threshold: OPERATOR_CONCENTRATION_NOTICE_PERCENT,
+ });
+ }
+
+ return issues.sort(
+ (left, right) =>
+ levelWeight[right.level] - levelWeight[left.level] ||
+ (right.count ?? 0) - (left.count ?? 0) ||
+ left.code.localeCompare(right.code),
+ );
+};
+
+export function buildAuditRiskInsights(
+ summary: AuditSummary | null | undefined,
+ records: AuditRecord[] | null | undefined,
+): AuditRiskInsights {
+ const safeRecords = records ?? [];
+ const total = Math.max(0, summary?.total ?? safeRecords.length);
+ const failed = Math.max(
+ 0,
+ summary?.failed ?? safeRecords.filter((record) =>
isFailedAuditResult(record.result)).length,
+ );
+ const partial = Math.max(
+ 0,
+ summary?.partial ?? safeRecords.filter((record) =>
isPartialAuditResult(record.result)).length,
+ );
+ const failureRate = percent(failed, total);
+ const partialRate = percent(partial, total);
+ const hotTargets = buildHotTargets(safeRecords);
+ const topOperator = buildTopOperator(safeRecords);
+ const riskyRecords = safeRecords
+ .map(toRiskRecord)
+ .filter((record): record is AuditRiskRecord => record != null)
+ .sort(
+ (left, right) =>
+ levelWeight[right.level] - levelWeight[left.level] ||
+ compareTimestampDesc(left.timestamp, right.timestamp),
+ )
+ .slice(0, 6);
+ const controlPlaneFailureCount = safeRecords.filter(
+ (record) => isControlPlaneAuditRecord(record) &&
isUnsuccessfulAuditResult(record.result),
+ ).length;
+ const highRiskFailureCount = safeRecords.filter(
+ (record) =>
+ isHighRiskAuditOperation(record.operationType) &&
isFailedAuditResult(record.result),
+ ).length;
+ const issues = buildRiskIssues({
+ total,
+ failed,
+ partial,
+ failureRate,
+ partialRate,
+ controlPlaneFailureCount,
+ highRiskFailureCount,
+ topOperator,
+ hotTargets,
+ });
+
+ return {
+ level: maxLevel(issues.map((issue) => issue.level)),
+ total,
+ pageRecordCount: safeRecords.length,
+ failed,
+ partial,
+ failureRate,
+ partialRate,
+ controlPlaneFailureCount,
+ highRiskFailureCount,
+ topOperator,
+ hotTargets,
+ riskyRecords,
+ issues,
+ };
+}
+
+export function auditRiskOperationLabel(operationType: string): string {
+ const presentation = getAuditOperationPresentation(operationType);
+ return presentation.label;
+}