feat(audit): login audit log with account info and IPv4

- 审计日志记录具体账号(用户名<邮箱>)
- 优先显示 IPv4,保留 IPv6
- Program Logs 页面新增 account/IPv4 列
This commit is contained in:
boos
2026-06-10 19:54:32 +08:00
parent d1f3f4e93d
commit d697146941
3 changed files with 144 additions and 33 deletions
+41 -2
View File
@@ -95,7 +95,7 @@ describe('ProgramLogs status label', () => {
expect(targetRow).toBeTruthy();
const tds = targetRow!.findAll((node) => node.type === 'td');
const statusCell = tds[5];
const statusCell = tds[7];
expect(collectText(statusCell).trim()).toBe('成功');
const statusBadge = statusCell.find((node) => node.type === 'span');
expect(String(statusBadge.props.className || '')).toContain('badge-success');
@@ -131,12 +131,51 @@ describe('ProgramLogs status label', () => {
expect(targetRow).toBeTruthy();
const tds = targetRow!.findAll((node) => node.type === 'td');
const statusCell = tds[5];
const statusCell = tds[7];
expect(collectText(statusCell).trim()).toBe('成功');
const statusBadge = statusCell.find((node) => node.type === 'span');
expect(String(statusBadge.props.className || '')).toContain('badge-success');
});
it('shows login account and client IP as first-class columns', async () => {
apiMock.getEvents.mockResolvedValue([
{
id: 8,
type: 'user_login',
title: '用户登录成功:姜先生 <user@example.test>',
account: '姜先生 <user@example.test>',
clientIp: '203.0.113.8',
message: '账号=姜先生 <user@example.test> · 邮箱=user@example.test · 用户ID=12 · 方式=api_key · IP=203.0.113.8',
level: 'info',
read: false,
relatedType: 'user',
relatedId: 12,
createdAt: '2026-06-10T08:00:00.000Z',
},
]);
let root!: WebTestRenderer;
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/events']}>
<ToastProvider>
<ProgramLogs />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const rows = root!.root.findAll((node) => node.type === 'tr');
const targetRow = rows.find((row) => collectText(row).includes('用户登录成功'));
expect(targetRow).toBeTruthy();
const tds = targetRow!.findAll((node) => node.type === 'td');
expect(collectText(tds[4]!).trim()).toBe('姜先生 <user@example.test>');
expect(collectText(tds[5]!).trim()).toBe('203.0.113.8');
expect(collectText(targetRow!)).toContain('IP=203.0.113.8');
});
it('opens related trace and route targets from desktop rows', async () => {
apiMock.getEvents.mockResolvedValue([
{
+18
View File
@@ -16,6 +16,8 @@ type ProgramEvent = {
type: string;
title: string;
message?: string | null;
account?: string | null;
clientIp?: string | null;
level: 'info' | 'warning' | 'error';
read: boolean;
relatedId?: number | null;
@@ -462,6 +464,12 @@ export default function ProgramLogs() {
<MobileField label="类型" value={<span className="badge badge-muted" style={{ fontSize: 11 }}>{typeLabel(row.type)}</span>} />
<MobileField label="级别" value={<span className={`badge ${level.cls}`} style={{ fontSize: 11 }}>{level.label}</span>} />
<MobileField label="状态" value={<span className={`badge ${eventStatus.cls}`} style={{ fontSize: 11 }}>{eventStatus.label}</span>} />
{row.type === 'user_login' && (
<>
<MobileField label="账号" value={row.account || '-'} />
<MobileField label="IPv4/IP" value={row.clientIp || '-'} />
</>
)}
<MobileField label="内容" value={row.message || '-'} stacked />
</MobileCard>
);
@@ -482,6 +490,8 @@ export default function ProgramLogs() {
<col style={{ width: 90 }} />
<col style={{ width: 90 }} />
<col style={{ width: 260 }} />
<col style={{ width: 220 }} />
<col style={{ width: 150 }} />
<col />
<col style={{ width: 110 }} />
<col style={{ width: 180 }} />
@@ -492,6 +502,8 @@ export default function ProgramLogs() {
<th>类型</th>
<th>级别</th>
<th>标题</th>
<th>账号</th>
<th>IPv4/IP</th>
<th>内容</th>
<th>状态</th>
<th style={{ textAlign: 'right' }}>操作</th>
@@ -519,6 +531,12 @@ export default function ProgramLogs() {
<td className="program-logs-title-cell">
{row.title || '-'}
</td>
<td className="program-logs-account-cell">
{row.account || '-'}
</td>
<td className="program-logs-ip-cell">
{row.clientIp || '-'}
</td>
<td className="program-logs-content-cell">
{row.message || '-'}
</td>