Audit fixes and improvements (#5835)

This commit is contained in:
Anthony Stirling
2026-03-05 22:00:44 +00:00
committed by GitHub
parent 879ffc066f
commit 6c83da6417
34 changed files with 3760 additions and 1030 deletions

View File

@@ -2,12 +2,12 @@
.modal-container {
display: flex;
gap: 0;
height: 37.5rem; /* 600px */
height: 45rem; /* 720px */
}
.modal-nav {
width: 15rem; /* 240px */
height: 37.5rem; /* 600px */
height: 45rem; /* 720px */
border-top-left-radius: 0.75rem; /* 12px */
border-bottom-left-radius: 0.75rem; /* 12px */
overflow: hidden;
@@ -108,7 +108,7 @@
.modal-content {
flex: 1;
height: 37.5rem; /* 600px */
height: 45rem; /* 720px */
display: flex;
flex-direction: column;
overflow: hidden;

View File

@@ -118,7 +118,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
opened={opened}
onClose={handleClose}
title={null}
size={isMobile ? "100%" : 980}
size={isMobile ? "100%" : 1100}
centered
radius="lg"
withCloseButton={false}

View File

@@ -5,6 +5,10 @@ export interface AuditSystemStatus {
level: string;
retentionDays: number;
totalEvents: number;
pdfMetadataEnabled: boolean;
captureFileHash: boolean;
capturePdfAuthor: boolean;
captureOperationResults: boolean;
}
export interface AuditEvent {
@@ -36,12 +40,32 @@ export interface AuditChartsData {
}
export interface AuditFilters {
eventType?: string;
username?: string;
eventType?: string | string[];
username?: string | string[];
startDate?: string;
endDate?: string;
outcome?: string;
page?: number;
pageSize?: number;
fields?: string;
}
export interface AuditStats {
totalEvents: number;
prevTotalEvents: number;
uniqueUsers: number;
prevUniqueUsers: number;
successRate: number; // 0100
prevSuccessRate: number;
avgLatencyMs: number;
prevAvgLatencyMs: number;
errorCount: number;
topEventType: string;
topUser: string;
eventsByType: Record<string, number>;
eventsByUser: Record<string, number>;
topTools: Record<string, number>;
hourlyDistribution: Record<string, number>; // "00""23" keys
}
const auditService = {
@@ -60,9 +84,23 @@ const auditService = {
level: data.auditLevel,
retentionDays: data.retentionDays,
totalEvents: 0, // Will be fetched separately
pdfMetadataEnabled: data.pdfMetadataEnabled ?? false,
captureFileHash: data.captureFileHash ?? false,
capturePdfAuthor: data.capturePdfAuthor ?? false,
captureOperationResults: data.captureOperationResults ?? false,
};
},
/**
* Get audit statistics and KPI data
*/
async getStats(timePeriod: 'day' | 'week' | 'month' = 'week'): Promise<AuditStats> {
const response = await apiClient.get<AuditStats>('/api/v1/proprietary/ui-data/audit-stats', {
params: { period: timePeriod },
});
return response.data;
},
/**
* Get audit events with pagination and filters
*/
@@ -84,7 +122,7 @@ const auditService = {
},
/**
* Export audit data
* Export audit data with custom field selection
*/
async exportData(
format: 'csv' | 'json',
@@ -112,6 +150,13 @@ const auditService = {
const response = await apiClient.get<string[]>('/api/v1/proprietary/ui-data/audit-users');
return response.data;
},
/**
* Clear all audit data from the database (irreversible)
*/
async clearAllAuditData(): Promise<void> {
await apiClient.post('/api/v1/proprietary/ui-data/audit-clear-all', {});
},
};
export default auditService;

View File

@@ -1,18 +1,23 @@
import React, { useState, useEffect } from 'react';
import { Tabs, Loader, Alert, Stack } from '@mantine/core';
import { Tabs, Loader, Alert, Stack, Text, Button, Accordion } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import auditService, { AuditSystemStatus as AuditStatus } from '@app/services/auditService';
import AuditSystemStatus from '@app/components/shared/config/configSections/audit/AuditSystemStatus';
import AuditStatsCards from '@app/components/shared/config/configSections/audit/AuditStatsCards';
import AuditChartsSection from '@app/components/shared/config/configSections/audit/AuditChartsSection';
import AuditEventsTable from '@app/components/shared/config/configSections/audit/AuditEventsTable';
import AuditExportSection from '@app/components/shared/config/configSections/audit/AuditExportSection';
import AuditClearDataSection from '@app/components/shared/config/configSections/audit/AuditClearDataSection';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import EnterpriseRequiredBanner from '@app/components/shared/config/EnterpriseRequiredBanner';
import LocalIcon from '@app/components/shared/LocalIcon';
const AdminAuditSection: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { loginEnabled } = useLoginRequired();
const { config } = useAppConfig();
const licenseType = config?.license ?? 'NORMAL';
@@ -21,6 +26,7 @@ const AdminAuditSection: React.FC = () => {
const [systemStatus, setSystemStatus] = useState<AuditStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [timePeriod, setTimePeriod] = useState<'day' | 'week' | 'month'>('week');
useEffect(() => {
const fetchSystemStatus = async () => {
@@ -52,6 +58,10 @@ const AdminAuditSection: React.FC = () => {
level: 'INFO',
retentionDays: 90,
totalEvents: 1234,
pdfMetadataEnabled: true,
captureFileHash: true,
capturePdfAuthor: true,
captureOperationResults: false,
});
setLoading(false);
}
@@ -94,6 +104,8 @@ const AdminAuditSection: React.FC = () => {
);
}
const isEnabled = loginEnabled && hasEnterpriseLicense;
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
@@ -101,32 +113,95 @@ const AdminAuditSection: React.FC = () => {
show={!hasEnterpriseLicense}
featureName={t('settings.licensingAnalytics.audit', 'Audit')}
/>
{/* Info banner about audit settings */}
{isEnabled && (
<Alert
icon={<LocalIcon icon="info" width="1.2rem" height="1.2rem" />}
title={t('audit.configureAudit', 'Configure Audit Logging')}
color="blue"
variant="light"
>
<Stack gap="xs">
<Text size="sm">
{t(
'audit.configureAuditMessage',
'Adjust audit logging level, retention period, and other settings in the Security & Authentication section.'
)}
</Text>
<Button
variant="light"
size="xs"
onClick={() => navigate('/settings/adminSecurity#auditLogging')}
rightSection={<LocalIcon icon="arrow-forward" width="0.9rem" height="0.9rem" />}
>
{t('audit.goToSettings', 'Go to Audit Settings')}
</Button>
</Stack>
</Alert>
)}
<AuditSystemStatus status={systemStatus} />
{systemStatus.enabled ? (
{systemStatus?.enabled ? (
<Tabs defaultValue="dashboard">
<Tabs.List>
<Tabs.Tab value="dashboard" disabled={!loginEnabled || !hasEnterpriseLicense}>
<Tabs.Tab value="dashboard" disabled={!isEnabled}>
{t('audit.tabs.dashboard', 'Dashboard')}
</Tabs.Tab>
<Tabs.Tab value="events" disabled={!loginEnabled || !hasEnterpriseLicense}>
<Tabs.Tab value="events" disabled={!isEnabled}>
{t('audit.tabs.events', 'Audit Events')}
</Tabs.Tab>
<Tabs.Tab value="export" disabled={!loginEnabled || !hasEnterpriseLicense}>
<Tabs.Tab value="export" disabled={!isEnabled}>
{t('audit.tabs.export', 'Export')}
</Tabs.Tab>
<Tabs.Tab value="clearData" disabled={!isEnabled}>
{t('audit.tabs.clearData', 'Clear Data')}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="dashboard" pt="md">
<AuditChartsSection loginEnabled={loginEnabled && hasEnterpriseLicense} />
<Stack gap="lg">
{/* Stats Cards - Always Visible */}
<AuditStatsCards loginEnabled={isEnabled} timePeriod={timePeriod} />
{/* Charts in Accordion - Collapsible */}
<Accordion defaultValue={["events-over-time"]} multiple>
<Accordion.Item value="events-over-time">
<Accordion.Control>
{t('audit.charts.overTime', 'Events Over Time')}
</Accordion.Control>
<Accordion.Panel>
<AuditChartsSection
loginEnabled={isEnabled}
timePeriod={timePeriod}
onTimePeriodChange={setTimePeriod}
/>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="events" pt="md">
<AuditEventsTable loginEnabled={loginEnabled && hasEnterpriseLicense} />
<AuditEventsTable
loginEnabled={isEnabled}
captureFileHash={systemStatus?.captureFileHash}
capturePdfAuthor={systemStatus?.capturePdfAuthor}
/>
</Tabs.Panel>
<Tabs.Panel value="export" pt="md">
<AuditExportSection loginEnabled={loginEnabled && hasEnterpriseLicense} />
<AuditExportSection
loginEnabled={isEnabled}
captureFileHash={systemStatus?.captureFileHash}
capturePdfAuthor={systemStatus?.capturePdfAuthor}
captureOperationResults={systemStatus?.captureOperationResults}
/>
</Tabs.Panel>
<Tabs.Panel value="clearData" pt="md">
<AuditClearDataSection loginEnabled={isEnabled} />
</Tabs.Panel>
</Tabs>
) : (

View File

@@ -33,6 +33,9 @@ interface SecuritySettingsData {
enabled?: boolean;
level?: number;
retentionDays?: number;
captureFileHash?: boolean;
capturePdfAuthor?: boolean;
captureOperationResults?: boolean;
};
html?: {
urlSecurity?: {
@@ -144,7 +147,10 @@ export default function AdminSecuritySection() {
// Premium audit settings
'premium.enterpriseFeatures.audit.enabled': audit?.enabled,
'premium.enterpriseFeatures.audit.level': audit?.level,
'premium.enterpriseFeatures.audit.retentionDays': audit?.retentionDays
'premium.enterpriseFeatures.audit.retentionDays': audit?.retentionDays,
'premium.enterpriseFeatures.audit.captureFileHash': audit?.captureFileHash,
'premium.enterpriseFeatures.audit.capturePdfAuthor': audit?.capturePdfAuthor,
'premium.enterpriseFeatures.audit.captureOperationResults': audit?.captureOperationResults
};
// System HTML settings
@@ -553,6 +559,64 @@ export default function AdminSecuritySection() {
disabled={!loginEnabled}
/>
</div>
<Alert color="yellow" icon={<LocalIcon icon="info" />} title={t('admin.settings.security.audit.advancedOptions.title', 'Advanced Options')}>
{t('admin.settings.security.audit.advancedOptions.description', 'The following options increase processing time and memory usage. Enable only if truly needed.')}
</Alert>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.audit.captureFileHash.label', 'Capture File Hash')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.audit.captureFileHash.description', 'Store MD5 hash of processed files for audit trail verification')}
</Text>
</div>
<Group gap="xs">
<Switch
name="audit_captureFileHash"
checked={settings?.audit?.captureFileHash || false}
onChange={(e) => setSettings({ ...settings, audit: { ...settings?.audit, captureFileHash: e.target.checked } })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('audit.captureFileHash')} />
</Group>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.audit.capturePdfAuthor.label', 'Capture PDF Author')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.audit.capturePdfAuthor.description', 'Extract author field from PDF documents during processing')}
</Text>
</div>
<Group gap="xs">
<Switch
name="audit_capturePdfAuthor"
checked={settings?.audit?.capturePdfAuthor || false}
onChange={(e) => setSettings({ ...settings, audit: { ...settings?.audit, capturePdfAuthor: e.target.checked } })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('audit.capturePdfAuthor')} />
</Group>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.audit.captureOperationResults.label', 'Capture Operation Results')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.audit.captureOperationResults.description', 'Store output file information and processing results in audit logs')}
</Text>
</div>
<Group gap="xs">
<Switch
name="audit_captureOperationResults"
checked={settings?.audit?.captureOperationResults || false}
onChange={(e) => setSettings({ ...settings, audit: { ...settings?.audit, captureOperationResults: e.target.checked } })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('audit.captureOperationResults')} />
</Group>
</div>
</Stack>
</Paper>

View File

@@ -1,15 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Stack,
Group,
Text,
Button,
SegmentedControl,
Loader,
Alert,
Card,
} from '@mantine/core';
import { Stack, Group, Text, Button, SegmentedControl, Loader, Alert, Card } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import usageAnalyticsService, { EndpointStatisticsResponse } from '@app/services/usageAnalyticsService';
import UsageAnalyticsChart from '@app/components/shared/config/configSections/usage/UsageAnalyticsChart';
import UsageAnalyticsTable from '@app/components/shared/config/configSections/usage/UsageAnalyticsTable';
@@ -21,6 +13,7 @@ import EnterpriseRequiredBanner from '@app/components/shared/config/EnterpriseRe
const AdminUsageSection: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { loginEnabled } = useLoginRequired();
const { config } = useAppConfig();
const licenseType = config?.license ?? 'NORMAL';
@@ -30,7 +23,7 @@ const AdminUsageSection: React.FC = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [displayMode, setDisplayMode] = useState<'top10' | 'top20' | 'all'>('top10');
const [dataType, setDataType] = useState<'all' | 'api' | 'ui'>('all');
const [dataType, setDataType] = useState<'all' | 'api' | 'ui'>('api');
const buildDemoUsageData = useCallback((): EndpointStatisticsResponse => {
const totalVisits = 15847;
@@ -180,6 +173,43 @@ const AdminUsageSection: React.FC = () => {
featureName={t('settings.licensingAnalytics.usageAnalytics', 'Usage Analytics')}
/>
{/* Info banner about usage analytics and audit relationship */}
{loginEnabled && hasEnterpriseLicense && (
<Alert
icon={<LocalIcon icon="info" width="1.2rem" height="1.2rem" />}
title={t('usage.aboutUsageAnalytics', 'About Usage Analytics')}
color="cyan"
variant="light"
>
<Stack gap="xs">
<Text size="sm">
{t(
'usage.usageAnalyticsExplanation',
'Usage analytics track endpoint requests and tool usage patterns. Combined with the Audit Logging dashboard, you get complete visibility into system activity, performance, and security events.'
)}
</Text>
<Group gap="sm">
<Button
variant="light"
size="xs"
onClick={() => navigate('/settings/adminSecurity')}
rightSection={<LocalIcon icon="arrow-forward" width="0.9rem" height="0.9rem" />}
>
{t('usage.configureSettings', 'Configure Analytics Settings')}
</Button>
<Button
variant="light"
size="xs"
onClick={() => navigate('/settings/adminSecurity#auditLogging')}
rightSection={<LocalIcon icon="arrow-forward" width="0.9rem" height="0.9rem" />}
>
{t('usage.viewAuditLogs', 'View Audit Logs')}
</Button>
</Group>
</Stack>
</Alert>
)}
{/* Controls */}
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">

View File

@@ -1,65 +1,59 @@
import React, { useState, useEffect } from 'react';
import { Card, Text, Group, Stack, SegmentedControl, Loader, Alert, Box, SimpleGrid } from '@mantine/core';
import {
Card,
Text,
Group,
Stack,
SegmentedControl,
Loader,
Alert,
Box,
SimpleGrid,
} from '@mantine/core';
import {
AreaChart,
Area,
BarChart,
Bar,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { useTranslation } from 'react-i18next';
import auditService, { AuditChartsData } from '@app/services/auditService';
interface SimpleBarChartProps {
data: { label: string; value: number }[];
title: string;
color?: string;
}
// Event type color mapping
const EVENT_TYPE_COLORS: Record<string, string> = {
USER_LOGIN: 'var(--mantine-color-green-6)',
USER_LOGOUT: 'var(--mantine-color-gray-5)',
USER_FAILED_LOGIN: 'var(--mantine-color-red-6)',
USER_PROFILE_UPDATE: 'var(--mantine-color-blue-6)',
SETTINGS_CHANGED: 'var(--mantine-color-orange-6)',
FILE_OPERATION: 'var(--mantine-color-cyan-6)',
PDF_PROCESS: 'var(--mantine-color-violet-6)',
UI_DATA: 'var(--mantine-color-teal-6)',
HTTP_REQUEST: 'var(--mantine-color-indigo-6)',
};
const SimpleBarChart: React.FC<SimpleBarChartProps> = ({ data, title, color = 'blue' }) => {
const maxValue = Math.max(...data.map((d) => d.value), 1);
return (
<Stack gap="sm">
<Text size="sm" fw={600}>
{title}
</Text>
<Stack gap="sm">
{data.map((item, index) => (
<Box key={index}>
<Group justify="space-between" mb={4}>
<Text size="xs" c="dimmed" maw={200} style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
{item.label}
</Text>
<Text size="xs" fw={600}>
{item.value}
</Text>
</Group>
<Box
style={{
width: '100%',
height: '0.5rem',
backgroundColor: 'var(--mantine-color-gray-2)',
borderRadius: 'var(--mantine-radius-sm)',
overflow: 'hidden',
}}
>
<Box
style={{
width: `${(item.value / maxValue) * 100}%`,
height: '100%',
backgroundColor: `var(--mantine-color-${color}-6)`,
transition: 'width 0.3s ease',
}}
/>
</Box>
</Box>
))}
</Stack>
</Stack>
);
const getEventTypeColor = (type: string): string => {
return EVENT_TYPE_COLORS[type] || 'var(--mantine-color-blue-6)';
};
interface AuditChartsSectionProps {
loginEnabled?: boolean;
timePeriod?: 'day' | 'week' | 'month';
onTimePeriodChange?: (period: 'day' | 'week' | 'month') => void;
}
const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({ loginEnabled = true }) => {
const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
loginEnabled = true,
timePeriod = 'week',
onTimePeriodChange,
}) => {
const { t } = useTranslation();
const [timePeriod, setTimePeriod] = useState<'day' | 'week' | 'month'>('week');
const [chartsData, setChartsData] = useState<AuditChartsData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -72,7 +66,7 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({ loginEnabled =
const data = await auditService.getChartsData(timePeriod);
setChartsData(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load charts');
setError(err instanceof Error ? err.message : t('audit.charts.error', 'Failed to load charts'));
} finally {
setLoading(false);
}
@@ -81,7 +75,7 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({ loginEnabled =
if (loginEnabled) {
fetchChartsData();
} else {
// Provide example charts data when login is disabled
// Demo data when login disabled
setChartsData({
eventsByType: {
labels: ['LOGIN', 'LOGOUT', 'SETTINGS_CHANGE', 'FILE_UPLOAD', 'FILE_DOWNLOAD'],
@@ -122,62 +116,161 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({ loginEnabled =
return null;
}
// Transform data for Recharts
const eventsOverTimeData = chartsData.eventsOverTime.labels.map((label, index) => ({
name: label,
value: chartsData.eventsOverTime.values[index],
}));
const eventsByTypeData = chartsData.eventsByType.labels.map((label, index) => ({
label,
type: label,
value: chartsData.eventsByType.values[index],
}));
const eventsByUserData = chartsData.eventsByUser.labels.map((label, index) => ({
label,
user: label,
value: chartsData.eventsByUser.values[index],
}));
const eventsOverTimeData = chartsData.eventsOverTime.labels.map((label, index) => ({
label,
value: chartsData.eventsOverTime.values[index],
}));
return (
<Card padding="lg" radius="md" withBorder>
<Stack gap="lg">
<Group justify="space-between" align="center">
<Text size="lg" fw={600}>
{t('audit.charts.title', 'Audit Dashboard')}
</Text>
<SegmentedControl
value={timePeriod}
onChange={(value) => {
if (!loginEnabled) return;
setTimePeriod(value as 'day' | 'week' | 'month');
}}
disabled={!loginEnabled}
data={[
{ label: t('audit.charts.day', 'Day'), value: 'day' },
{ label: t('audit.charts.week', 'Week'), value: 'week' },
{ label: t('audit.charts.month', 'Month'), value: 'month' },
]}
/>
</Group>
<Stack gap="lg">
{/* Header with time period selector */}
<Group justify="space-between" align="center">
<Text size="lg" fw={600}>
{t('audit.charts.title', 'Audit Dashboard')}
</Text>
<SegmentedControl
value={timePeriod}
onChange={(value) => {
onTimePeriodChange?.(value as 'day' | 'week' | 'month');
}}
disabled={!loginEnabled}
data={[
{ label: t('audit.charts.day', 'Day'), value: 'day' },
{ label: t('audit.charts.week', 'Week'), value: 'week' },
{ label: t('audit.charts.month', 'Month'), value: 'month' },
]}
/>
</Group>
<SimpleGrid cols={3} spacing="xl">
<SimpleBarChart
data={eventsByTypeData}
title={t('audit.charts.byType', 'Events by Type')}
color="blue"
/>
<SimpleBarChart
data={eventsByUserData}
title={t('audit.charts.byUser', 'Events by User')}
color="green"
/>
<SimpleBarChart
data={eventsOverTimeData}
title={t('audit.charts.overTime', 'Events Over Time')}
color="purple"
/>
</SimpleGrid>
</Stack>
</Card>
{/* Full-width Events Over Time Chart */}
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="md" fw={600}>
{t('audit.charts.overTime', 'Events Over Time')}
</Text>
<Box style={{ width: '100%', height: 280 }}>
{eventsOverTimeData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={eventsOverTimeData}>
<defs>
<linearGradient id="colorValue" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--mantine-color-blue-6)" stopOpacity={0.3} />
<stop offset="95%" stopColor="var(--mantine-color-blue-6)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="var(--mantine-color-gray-2)" />
<XAxis dataKey="name" stroke="var(--mantine-color-gray-6)" />
<YAxis stroke="var(--mantine-color-gray-6)" />
<Tooltip
contentStyle={{
backgroundColor: 'var(--mantine-color-gray-8)',
border: 'none',
borderRadius: 'var(--mantine-radius-md)',
color: 'var(--mantine-color-gray-0)',
}}
/>
<Area
type="monotone"
dataKey="value"
stroke="var(--mantine-color-blue-6)"
fillOpacity={1}
fill="url(#colorValue)"
/>
</AreaChart>
</ResponsiveContainer>
) : (
<Group justify="center">
<Text c="dimmed">{t('audit.charts.noData', 'No data for this period')}</Text>
</Group>
)}
</Box>
</Stack>
</Card>
{/* Two-column grid for remaining charts */}
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg">
{/* Events by Type Chart */}
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="md" fw={600}>
{t('audit.charts.byType', 'Events by Type')}
</Text>
<Box style={{ width: '100%', height: 280 }}>
{eventsByTypeData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={eventsByTypeData}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--mantine-color-gray-2)" />
<XAxis dataKey="type" stroke="var(--mantine-color-gray-6)" angle={-45} textAnchor="end" height={80} />
<YAxis stroke="var(--mantine-color-gray-6)" />
<Tooltip
contentStyle={{
backgroundColor: 'var(--mantine-color-gray-8)',
border: 'none',
borderRadius: 'var(--mantine-radius-md)',
color: 'var(--mantine-color-gray-0)',
}}
/>
<Bar dataKey="value" fill="var(--mantine-color-blue-6)">
{eventsByTypeData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={getEventTypeColor(entry.type)} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<Group justify="center">
<Text c="dimmed">{t('audit.charts.noData', 'No data')}</Text>
</Group>
)}
</Box>
</Stack>
</Card>
{/* Top Users Chart (Horizontal) */}
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="md" fw={600}>
{t('audit.charts.byUser', 'Top Users')}
</Text>
<Box style={{ width: '100%', height: 280 }}>
{eventsByUserData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={eventsByUserData} layout="vertical">
<CartesianGrid strokeDasharray="3 3" stroke="var(--mantine-color-gray-2)" />
<XAxis type="number" stroke="var(--mantine-color-gray-6)" />
<YAxis type="category" dataKey="user" stroke="var(--mantine-color-gray-6)" width={100} />
<Tooltip
contentStyle={{
backgroundColor: 'var(--mantine-color-gray-8)',
border: 'none',
borderRadius: 'var(--mantine-radius-md)',
color: 'var(--mantine-color-gray-0)',
}}
/>
<Bar dataKey="value" fill="var(--mantine-color-green-6)" />
</BarChart>
</ResponsiveContainer>
) : (
<Group justify="center">
<Text c="dimmed">{t('audit.charts.noData', 'No data')}</Text>
</Group>
)}
</Box>
</Stack>
</Card>
</SimpleGrid>
</Stack>
);
};

View File

@@ -0,0 +1,191 @@
import React, { useState } from 'react';
import { Card, Stack, Text, PasswordInput, Button, Group, Alert, Code, Badge } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import auditService from '@app/services/auditService';
import LocalIcon from '@app/components/shared/LocalIcon';
interface AuditClearDataSectionProps {
loginEnabled?: boolean;
}
const AuditClearDataSection: React.FC<AuditClearDataSectionProps> = ({ loginEnabled = true }) => {
const { t } = useTranslation();
const [confirmationCode, setConfirmationCode] = useState('');
const [generatedCode, setGeneratedCode] = useState('');
const [showConfirmation, setShowConfirmation] = useState(false);
const [clearing, setClearing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const handleInitiateDeletion = () => {
const code = Math.random().toString(36).substring(2, 10).toUpperCase();
setGeneratedCode(code);
setConfirmationCode('');
setShowConfirmation(true);
setError(null);
};
const resetForm = () => {
setConfirmationCode('');
setGeneratedCode('');
setShowConfirmation(false);
setError(null);
};
const handleClearData = async () => {
if (confirmationCode !== generatedCode) {
setError(t('audit.clearData.codeDoesNotMatch', 'Code does not match'));
return;
}
try {
setClearing(true);
setError(null);
await auditService.clearAllAuditData();
setSuccess(true);
resetForm();
// Auto-dismiss success message after 5 seconds
setTimeout(() => setSuccess(false), 5000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to clear audit data');
} finally {
setClearing(false);
}
};
if (success) {
return (
<Stack gap="lg">
<Alert
color="green"
icon={<LocalIcon icon="check-circle" width="1.2rem" height="1.2rem" />}
title={t('audit.clearData.success', 'Success')}
onClose={() => setSuccess(false)}
closeButtonLabel="Close alert"
withCloseButton
>
{t('audit.clearData.successMessage', 'All audit data has been cleared successfully')}
</Alert>
</Stack>
);
}
if (showConfirmation) {
return (
<Stack gap="lg">
<Alert
color="orange"
icon={<LocalIcon icon="warning" width="1.2rem" height="1.2rem" />}
title={t('audit.clearData.confirmTitle', 'Please confirm you want to delete')}
>
<Text size="sm">
{t('audit.clearData.confirmMessage', 'This will permanently remove all audit logs. Enter the confirmation code below to proceed.')}
</Text>
</Alert>
<Card padding="lg" radius="md" withBorder style={{ borderColor: 'var(--mantine-color-red-4)' }}>
<Stack gap="md">
<div
style={{
backgroundColor: 'var(--mantine-color-gray-0)',
padding: '1rem',
borderRadius: '0.375rem',
border: '1px solid var(--mantine-color-gray-2)',
}}
>
<Text size="xs" fw={600} c="dimmed" mb="xs">
{t('audit.clearData.confirmationCode', 'Confirmation Code')}
</Text>
<Code
style={{
fontSize: '1.5rem',
letterSpacing: '0.15em',
fontWeight: 600,
display: 'block',
textAlign: 'center',
padding: '0.75rem',
}}
>
{generatedCode}
</Code>
<Text size="xs" c="dimmed" mt="xs">
{t('audit.clearData.enterCodeBelow', 'Enter the code exactly as shown above (case-sensitive)')}
</Text>
</div>
<PasswordInput
label={t('audit.clearData.enterCode', 'Confirmation Code')}
placeholder={t('audit.clearData.codePlaceholder', 'Type the code here')}
value={confirmationCode}
onChange={(e) => setConfirmationCode(e.currentTarget.value)}
disabled={!loginEnabled}
error={
confirmationCode && confirmationCode !== generatedCode
? t('audit.clearData.codeDoesNotMatch', 'Code does not match')
: false
}
/>
{error && (
<Alert color="red" icon={<LocalIcon icon="error" width="1.2rem" height="1.2rem" />}>
{error}
</Alert>
)}
<Group justify="space-between">
<Button variant="default" onClick={resetForm} disabled={clearing}>
{t('audit.clearData.cancel', 'Cancel')}
</Button>
<Button
color="red"
onClick={handleClearData}
loading={clearing}
disabled={!loginEnabled || !generatedCode || confirmationCode !== generatedCode}
>
{t('audit.clearData.deleteButton', 'Delete')}
</Button>
</Group>
</Stack>
</Card>
</Stack>
);
}
return (
<Stack gap="lg">
<Alert
color="red"
icon={<LocalIcon icon="warning" width="1.2rem" height="1.2rem" />}
title={t('audit.clearData.warning1', 'This action cannot be undone')}
>
<Text size="sm">
{t('audit.clearData.warning2', 'Deleting audit data will permanently remove all historical audit logs, including security events, user activities, and file operations from the database.')}
</Text>
</Alert>
<Card padding="lg" radius="md" withBorder style={{ borderColor: 'var(--mantine-color-red-4)' }}>
<Stack gap="md">
<Group>
<Text fw={600}>
{t('audit.clearData.confirmationRequired', 'Delete All Audit Data')}
</Text>
<Badge color="red">
{t('audit.clearData.irreversible', 'IRREVERSIBLE')}
</Badge>
</Group>
<Button
color="red"
onClick={handleInitiateDeletion}
disabled={!loginEnabled}
fullWidth
>
{t('audit.clearData.initiateDelete', 'Delete All Data')}
</Button>
</Stack>
</Card>
</Stack>
);
};
export default AuditClearDataSection;

View File

@@ -11,18 +11,27 @@ import {
Loader,
Alert,
Table,
Badge,
UnstyledButton,
} from '@mantine/core';
import { useTranslation } from 'react-i18next';
import auditService, { AuditEvent } from '@app/services/auditService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useAuditFilters } from '@app/hooks/useAuditFilters';
import AuditFiltersForm from '@app/components/shared/config/configSections/audit/AuditFiltersForm';
import LocalIcon from '@app/components/shared/LocalIcon';
interface AuditEventsTableProps {
loginEnabled?: boolean;
captureFileHash?: boolean;
capturePdfAuthor?: boolean;
}
const AuditEventsTable: React.FC<AuditEventsTableProps> = ({ loginEnabled = true }) => {
const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
loginEnabled = true,
captureFileHash = false,
capturePdfAuthor = false,
}) => {
const { t } = useTranslation();
const [events, setEvents] = useState<AuditEvent[]>([]);
const [totalPages, setTotalPages] = useState(0);
@@ -30,6 +39,11 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({ loginEnabled = true
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedEvent, setSelectedEvent] = useState<AuditEvent | null>(null);
const [sortKey, setSortKey] = useState<'timestamp' | 'eventType' | 'username' | 'ipAddress' | null>('timestamp');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
const showAuthor = capturePdfAuthor;
const showFileHash = captureFileHash;
const totalColumns = 5 + (showAuthor ? 1 : 0) + (showFileHash ? 1 : 0);
// Use shared filters hook
const { filters, eventTypes, users, handleFilterChange, handleClearFilters } = useAuditFilters({
@@ -122,6 +136,69 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({ loginEnabled = true
return new Date(dateString).toLocaleString();
};
// Sort handling
const toggleSort = (key: 'timestamp' | 'eventType' | 'username' | 'ipAddress') => {
if (sortKey === key) {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
} else {
setSortKey(key);
setSortDir('asc');
}
};
const getSortIcon = (key: 'timestamp' | 'eventType' | 'username' | 'ipAddress') => {
if (sortKey !== key) return 'unfold-more';
return sortDir === 'asc' ? 'expand-less' : 'expand-more';
};
// Event type colors
const EVENT_TYPE_COLORS: Record<string, string> = {
USER_LOGIN: 'green',
USER_LOGOUT: 'gray',
USER_FAILED_LOGIN: 'red',
USER_PROFILE_UPDATE: 'blue',
SETTINGS_CHANGED: 'orange',
FILE_OPERATION: 'cyan',
PDF_PROCESS: 'violet',
UI_DATA: 'gray',
HTTP_REQUEST: 'indigo',
};
const getEventTypeColor = (type: string): string => {
return EVENT_TYPE_COLORS[type] || 'blue';
};
// Apply sorting to current events
const sortedEvents = [...events].sort((a, b) => {
let aVal: any;
let bVal: any;
switch (sortKey) {
case 'timestamp':
aVal = new Date(a.timestamp).getTime();
bVal = new Date(b.timestamp).getTime();
break;
case 'eventType':
aVal = a.eventType;
bVal = b.eventType;
break;
case 'username':
aVal = a.username;
bVal = b.username;
break;
case 'ipAddress':
aVal = a.ipAddress;
bVal = b.ipAddress;
break;
default:
return 0;
}
if (aVal < bVal) return sortDir === 'asc' ? -1 : 1;
if (aVal > bVal) return sortDir === 'asc' ? 1 : -1;
return 0;
});
return (
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
@@ -150,6 +227,7 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({ loginEnabled = true
</Alert>
) : (
<>
<div style={{ overflowX: 'auto', overflowY: 'hidden', marginBottom: '1rem' }}>
<Table
horizontalSpacing="md"
verticalSpacing="sm"
@@ -161,59 +239,118 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({ loginEnabled = true
>
<Table.Thead>
<Table.Tr style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
{t('audit.events.timestamp', 'Timestamp')}
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)', padding: '0.5rem' }} fz="sm">
<UnstyledButton onClick={() => toggleSort('timestamp')} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', userSelect: 'none' }}>
{t('audit.events.timestamp', 'Timestamp')}
<LocalIcon icon={getSortIcon('timestamp')} width="0.9rem" height="0.9rem" />
</UnstyledButton>
</Table.Th>
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)', padding: '0.5rem' }} fz="sm">
<UnstyledButton onClick={() => toggleSort('eventType')} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', userSelect: 'none' }}>
{t('audit.events.type', 'Type')}
<LocalIcon icon={getSortIcon('eventType')} width="0.9rem" height="0.9rem" />
</UnstyledButton>
</Table.Th>
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)', padding: '0.5rem' }} fz="sm">
<UnstyledButton onClick={() => toggleSort('username')} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', userSelect: 'none' }}>
{t('audit.events.user', 'User')}
<LocalIcon icon={getSortIcon('username')} width="0.9rem" height="0.9rem" />
</UnstyledButton>
</Table.Th>
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
{t('audit.events.type', 'Type')}
</Table.Th>
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
{t('audit.events.user', 'User')}
</Table.Th>
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
{t('audit.events.ipAddress', 'IP Address')}
{t('audit.events.documentName', 'Document Name')}
</Table.Th>
{showAuthor && (
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
{t('audit.events.author', 'Author')}
</Table.Th>
)}
{showFileHash && (
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
{t('audit.events.fileHash', 'File Hash')}
</Table.Th>
)}
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" ta="center">
{t('audit.events.actions', 'Actions')}
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{events.length === 0 ? (
{sortedEvents.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={5}>
<Text ta="center" c="dimmed" py="xl">
{t('audit.events.noEvents', 'No events found')}
</Text>
<Table.Td colSpan={totalColumns}>
<Group justify="center" py="xl">
<Stack align="center" gap={0}>
<LocalIcon icon="search" width="2rem" height="2rem" style={{ opacity: 0.4 }} />
<Text ta="center" c="dimmed" size="sm">
{t('audit.events.noEvents', 'No events found')}
</Text>
</Stack>
</Group>
</Table.Td>
</Table.Tr>
) : (
events.map((event) => (
<Table.Tr key={event.id}>
<Table.Td>
<Text size="sm">{formatDate(event.timestamp)}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{event.eventType}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{event.username}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{event.ipAddress}</Text>
</Table.Td>
<Table.Td ta="center">
<Button
variant="subtle"
size="xs"
onClick={() => setSelectedEvent(event)}
disabled={!loginEnabled}
>
{t('audit.events.viewDetails', 'View Details')}
</Button>
</Table.Td>
</Table.Tr>
))
sortedEvents.map((event) => {
// Extract document name, author, hash from details.files if available
let documentName = '';
let author = '';
let fileHash = '';
if (event.details && typeof event.details === 'object') {
const details = event.details as Record<string, any>;
const files = details.files;
if (Array.isArray(files) && files.length > 0) {
const firstFile = files[0] as Record<string, any>;
documentName = firstFile.name || '';
if (showAuthor || showFileHash) {
author = firstFile.pdfAuthor || '';
fileHash = firstFile.fileHash ? firstFile.fileHash.substring(0, 16) + '...' : '';
}
}
}
return (
<Table.Tr key={event.id}>
<Table.Td>
<Text size="sm">{formatDate(event.timestamp)}</Text>
</Table.Td>
<Table.Td>
<Badge variant="light" size="sm" color={getEventTypeColor(event.eventType)}>
{event.eventType}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">{event.username}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" title={documentName}>
{documentName || '—'}
</Text>
</Table.Td>
{showAuthor && (
<Table.Td>
<Text size="sm">{author}</Text>
</Table.Td>
)}
{showFileHash && (
<Table.Td>
<Text size="sm" title={fileHash} style={{ fontFamily: 'monospace', fontSize: '0.75rem' }}>
{fileHash}
</Text>
</Table.Td>
)}
<Table.Td ta="center">
<Button
variant="subtle"
size="xs"
onClick={() => setSelectedEvent(event)}
disabled={!loginEnabled}
>
{t('audit.events.viewDetails', 'View Details')}
</Button>
</Table.Td>
</Table.Tr>
);
})
)}
</Table.Tbody>
</Table>
@@ -228,6 +365,7 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({ loginEnabled = true
/>
</Group>
)}
</div>
</>
)}
</Stack>

View File

@@ -6,6 +6,7 @@ import {
Stack,
Button,
SegmentedControl,
Checkbox,
} from '@mantine/core';
import { useTranslation } from 'react-i18next';
import auditService from '@app/services/auditService';
@@ -15,12 +16,31 @@ import AuditFiltersForm from '@app/components/shared/config/configSections/audit
interface AuditExportSectionProps {
loginEnabled?: boolean;
captureFileHash?: boolean;
capturePdfAuthor?: boolean;
captureOperationResults?: boolean;
}
const AuditExportSection: React.FC<AuditExportSectionProps> = ({ loginEnabled = true }) => {
const AuditExportSection: React.FC<AuditExportSectionProps> = ({
loginEnabled = true,
captureFileHash = false,
capturePdfAuthor = false,
captureOperationResults = false,
}) => {
const { t } = useTranslation();
const [exportFormat, setExportFormat] = useState<'csv' | 'json'>('csv');
const [exporting, setExporting] = useState(false);
const [selectedFields, setSelectedFields] = useState<Record<string, boolean>>({
date: true,
username: true,
ipaddress: false,
tool: true,
documentName: true,
outcome: true,
author: capturePdfAuthor,
fileHash: captureFileHash,
operationResults: captureOperationResults,
});
// Use shared filters hook
const { filters, eventTypes, users, handleFilterChange, handleClearFilters } = useAuditFilters({}, loginEnabled);
@@ -31,7 +51,9 @@ const AuditExportSection: React.FC<AuditExportSectionProps> = ({ loginEnabled =
try {
setExporting(true);
const blob = await auditService.exportData(exportFormat, filters);
const fieldsParam = Object.keys(selectedFields).filter(k => selectedFields[k as keyof typeof selectedFields]).join(',');
const blob = await auditService.exportData(exportFormat, { ...filters, fields: fieldsParam });
// Create download link
const url = window.URL.createObjectURL(blob);
@@ -83,6 +105,75 @@ const AuditExportSection: React.FC<AuditExportSectionProps> = ({ loginEnabled =
/>
</div>
{/* Field Selection */}
<div>
<Text size="sm" fw={600} mb="xs">
{t('audit.export.selectFields', 'Select Fields to Include')}
</Text>
<Stack gap="xs">
<Checkbox
label={t('audit.export.fieldDate', 'Date')}
checked={selectedFields.date}
onChange={(e) => setSelectedFields({ ...selectedFields, date: e.currentTarget.checked })}
disabled={!loginEnabled}
/>
<Checkbox
label={t('audit.export.fieldUsername', 'Username')}
checked={selectedFields.username}
onChange={(e) => setSelectedFields({ ...selectedFields, username: e.currentTarget.checked })}
disabled={!loginEnabled}
/>
<Checkbox
label={t('audit.export.fieldIpAddress', 'IP Address')}
checked={selectedFields.ipaddress}
onChange={(e) => setSelectedFields({ ...selectedFields, ipaddress: e.currentTarget.checked })}
disabled={!loginEnabled}
/>
<Checkbox
label={t('audit.export.fieldTool', 'Tool')}
checked={selectedFields.tool}
onChange={(e) => setSelectedFields({ ...selectedFields, tool: e.currentTarget.checked })}
disabled={!loginEnabled}
/>
<Checkbox
label={t('audit.export.fieldDocumentName', 'Document Name')}
checked={selectedFields.documentName}
onChange={(e) => setSelectedFields({ ...selectedFields, documentName: e.currentTarget.checked })}
disabled={!loginEnabled}
/>
<Checkbox
label={t('audit.export.fieldOutcome', 'Outcome (Success/Failure)')}
checked={selectedFields.outcome}
onChange={(e) => setSelectedFields({ ...selectedFields, outcome: e.currentTarget.checked })}
disabled={!loginEnabled}
/>
{capturePdfAuthor && (
<Checkbox
label={t('audit.export.fieldAuthor', 'Author (from PDF)')}
checked={selectedFields.author}
onChange={(e) => setSelectedFields({ ...selectedFields, author: e.currentTarget.checked })}
disabled={!loginEnabled}
/>
)}
{captureFileHash && (
<Checkbox
label={t('audit.export.fieldFileHash', 'File Hash (SHA-256)')}
checked={selectedFields.fileHash}
onChange={(e) => setSelectedFields({ ...selectedFields, fileHash: e.currentTarget.checked })}
disabled={!loginEnabled}
/>
)}
{captureOperationResults && (
<Checkbox
label={t('audit.export.fieldOperationResults', 'Operation Results')}
checked={selectedFields.operationResults}
onChange={(e) => setSelectedFields({ ...selectedFields, operationResults: e.currentTarget.checked })}
disabled={!loginEnabled}
/>
)}
</Stack>
</div>
{/* Filters */}
<div>
<Text size="sm" fw={600} mb="xs">

View File

@@ -1,10 +1,44 @@
import React from 'react';
import { Group, Select, Button } from '@mantine/core';
import { Group, MultiSelect, Button, Stack, SimpleGrid, Text } from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useTranslation } from 'react-i18next';
import { AuditFilters } from '@app/services/auditService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
// Helper to format date as YYYY-MM-DD in local time (avoids DST/UTC issues)
const formatDateToYMD = (date: Date): string => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
// Helper to calculate date range for quick presets
const getDateRange = (preset: string): [Date, Date] | null => {
const end = new Date();
const start = new Date();
switch (preset) {
case 'today':
start.setHours(0, 0, 0, 0);
end.setHours(23, 59, 59, 999);
return [start, end];
case 'last7':
start.setDate(start.getDate() - 6);
return [start, end];
case 'last30':
start.setDate(start.getDate() - 29);
return [start, end];
case 'thisMonth':
start.setDate(1);
start.setHours(0, 0, 0, 0);
end.setHours(23, 59, 59, 999);
return [start, end];
default:
return null;
}
};
interface AuditFiltersFormProps {
filters: AuditFilters;
eventTypes: string[];
@@ -27,55 +61,118 @@ const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
}) => {
const { t } = useTranslation();
const handleQuickPreset = (preset: string) => {
const range = getDateRange(preset);
if (range) {
const [start, end] = range;
onFilterChange('startDate', formatDateToYMD(start));
onFilterChange('endDate', formatDateToYMD(end));
}
};
const isPresetActive = (preset: string): boolean => {
if (!filters.startDate || !filters.endDate) return false;
const range = getDateRange(preset);
if (!range) return false;
const [expectedStart, expectedEnd] = range;
const expectedStartStr = formatDateToYMD(expectedStart);
const expectedEndStr = formatDateToYMD(expectedEnd);
return filters.startDate === expectedStartStr && filters.endDate === expectedEndStr;
};
return (
<Group>
<Select
placeholder={t('audit.events.filterByType', 'Filter by type')}
data={eventTypes.map((type) => ({ value: type, label: type }))}
value={filters.eventType}
onChange={(value) => onFilterChange('eventType', value || undefined)}
clearable
disabled={disabled}
style={{ flex: 1, minWidth: 200 }}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Select
placeholder={t('audit.events.filterByUser', 'Filter by user')}
data={users.map((user) => ({ value: user, label: user }))}
value={filters.username}
onChange={(value) => onFilterChange('username', value || undefined)}
clearable
searchable
disabled={disabled}
style={{ flex: 1, minWidth: 200 }}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<DateInput
placeholder={t('audit.events.startDate', 'Start date')}
value={filters.startDate ? new Date(filters.startDate) : null}
onChange={(value: string | null) =>
onFilterChange('startDate', value ?? undefined)
}
clearable
disabled={disabled}
style={{ flex: 1, minWidth: 150 }}
popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<DateInput
placeholder={t('audit.events.endDate', 'End date')}
value={filters.endDate ? new Date(filters.endDate) : null}
onChange={(value: string | null) =>
onFilterChange('endDate', value ?? undefined)
}
clearable
disabled={disabled}
style={{ flex: 1, minWidth: 150 }}
popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Button variant="outline" onClick={onClearFilters} disabled={disabled}>
{t('audit.events.clearFilters', 'Clear')}
</Button>
</Group>
<Stack gap="md">
{/* Quick Preset Buttons */}
<div>
<Text size="xs" fw={600} mb="xs" c="dimmed">
{t('audit.filters.quickPresets', 'Quick filters')}
</Text>
<Group gap="xs">
<Button
variant={isPresetActive('today') ? 'filled' : 'light'}
size="xs"
onClick={() => handleQuickPreset('today')}
disabled={disabled}
>
{t('audit.filters.today', 'Today')}
</Button>
<Button
variant={isPresetActive('last7') ? 'filled' : 'light'}
size="xs"
onClick={() => handleQuickPreset('last7')}
disabled={disabled}
>
{t('audit.filters.last7Days', 'Last 7 days')}
</Button>
<Button
variant={isPresetActive('last30') ? 'filled' : 'light'}
size="xs"
onClick={() => handleQuickPreset('last30')}
disabled={disabled}
>
{t('audit.filters.last30Days', 'Last 30 days')}
</Button>
<Button
variant={isPresetActive('thisMonth') ? 'filled' : 'light'}
size="xs"
onClick={() => handleQuickPreset('thisMonth')}
disabled={disabled}
>
{t('audit.filters.thisMonth', 'This month')}
</Button>
</Group>
</div>
{/* Filter Inputs */}
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="sm">
<MultiSelect
placeholder={t('audit.events.filterByType', 'Filter by type')}
data={eventTypes.map((type) => ({ value: type, label: type }))}
value={Array.isArray(filters.eventType) ? filters.eventType : (filters.eventType ? [filters.eventType] : [])}
onChange={(value) => onFilterChange('eventType', value.length > 0 ? value : undefined)}
clearable
disabled={disabled}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<MultiSelect
placeholder={t('audit.events.filterByUser', 'Filter by user')}
data={users.map((user) => ({ value: user, label: user }))}
value={Array.isArray(filters.username) ? filters.username : (filters.username ? [filters.username] : [])}
onChange={(value) => onFilterChange('username', value.length > 0 ? value : undefined)}
clearable
searchable
disabled={disabled}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<DateInput
placeholder={t('audit.events.startDate', 'Start date')}
value={filters.startDate ? new Date(filters.startDate) : null}
onChange={(value: any) => {
onFilterChange('startDate', value ? formatDateToYMD(value as Date) : undefined);
}}
clearable
disabled={disabled}
popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<DateInput
placeholder={t('audit.events.endDate', 'End date')}
value={filters.endDate ? new Date(filters.endDate) : null}
onChange={(value: any) => {
onFilterChange('endDate', value ? formatDateToYMD(value as Date) : undefined);
}}
clearable
disabled={disabled}
popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
</SimpleGrid>
{/* Clear Button */}
<Group justify="flex-end">
<Button variant="outline" size="sm" onClick={onClearFilters} disabled={disabled}>
{t('audit.events.clearFilters', 'Clear')}
</Button>
</Group>
</Stack>
);
};

View File

@@ -0,0 +1,232 @@
import React, { useState, useEffect } from 'react';
import { Card, Group, Stack, Text, Badge, SimpleGrid, Loader, Alert } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import auditService, { AuditStats } from '@app/services/auditService';
import LocalIcon from '@app/components/shared/LocalIcon';
interface AuditStatsCardsProps {
loginEnabled?: boolean;
timePeriod: 'day' | 'week' | 'month';
}
const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true, timePeriod = 'week' }) => {
const { t } = useTranslation();
const [stats, setStats] = useState<AuditStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchStats = async () => {
try {
setLoading(true);
setError(null);
const data = await auditService.getStats(timePeriod);
setStats(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load statistics');
} finally {
setLoading(false);
}
};
if (loginEnabled) {
fetchStats();
} else {
// Demo data when login disabled
setStats({
totalEvents: 4256,
prevTotalEvents: 3891,
uniqueUsers: 12,
prevUniqueUsers: 10,
successRate: 96.5,
prevSuccessRate: 94.2,
avgLatencyMs: 342,
prevAvgLatencyMs: 385,
errorCount: 148,
topEventType: 'PDF_PROCESS',
topUser: 'admin',
eventsByType: {},
eventsByUser: {},
topTools: {},
hourlyDistribution: {},
});
setLoading(false);
}
}, [timePeriod, loginEnabled]);
if (loading) {
return (
<Card padding="lg" radius="md" withBorder>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '2rem 0' }}>
<Loader size="lg" />
</div>
</Card>
);
}
if (error) {
return (
<Alert color="red" title={t('audit.stats.error', 'Error loading statistics')}>
{error}
</Alert>
);
}
if (!stats) {
return null;
}
const trendPercent = stats.prevTotalEvents > 0 ? ((stats.totalEvents - stats.prevTotalEvents) / stats.prevTotalEvents) * 100 : 0;
const userTrend = stats.prevUniqueUsers > 0 ? ((stats.uniqueUsers - stats.prevUniqueUsers) / stats.prevUniqueUsers) * 100 : 0;
const latencyTrend = stats.prevAvgLatencyMs > 0 ? ((stats.avgLatencyMs - stats.prevAvgLatencyMs) / stats.prevAvgLatencyMs) * 100 : 0;
const successTrend = stats.prevSuccessRate > 0 ? stats.successRate - stats.prevSuccessRate : 0;
const getSuccessRateColor = (rate: number) => {
if (rate >= 95) return 'green';
if (rate >= 80) return 'yellow';
return 'red';
};
const getTrendColor = (trend: number, lowerIsBetter: boolean = false) => {
if (lowerIsBetter) {
return trend <= 0 ? 'green' : 'red';
}
return trend >= 0 ? 'green' : 'red';
};
const getTrendIcon = (trend: number, lowerIsBetter: boolean = false) => {
const isPositive = lowerIsBetter ? trend <= 0 : trend >= 0;
return isPositive ? 'trending-up' : 'trending-down';
};
return (
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg">
{/* Total Events Card */}
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t('audit.stats.totalEvents', 'Total Events')}
</Text>
<LocalIcon icon="analytics" width="1.2rem" height="1.2rem" />
</Group>
<Text size="xl" fw={700}>
{stats.totalEvents.toLocaleString()}
</Text>
{trendPercent !== 0 && (
<Badge
color={getTrendColor(trendPercent)}
variant="light"
size="sm"
leftSection={
<LocalIcon
icon={getTrendIcon(trendPercent)}
width="0.8rem"
height="0.8rem"
style={{ marginRight: '0.25rem' }}
/>
}
>
{Math.abs(trendPercent).toFixed(1)}% {t('audit.stats.vsLastPeriod', 'vs last period')}
</Badge>
)}
</Stack>
</Card>
{/* Success Rate Card */}
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t('audit.stats.successRate', 'Success Rate')}
</Text>
<LocalIcon icon="check-circle-rounded" width="1.2rem" height="1.2rem" />
</Group>
<Text size="xl" fw={700}>
{stats.successRate.toFixed(1)}%
</Text>
<Group gap="xs">
<Badge color={getSuccessRateColor(stats.successRate)} variant="light" size="sm">
{stats.successRate >= 95
? t('audit.stats.excellent', 'Excellent')
: stats.successRate >= 80
? t('audit.stats.good', 'Good')
: t('audit.stats.attention', 'Attention needed')}
</Badge>
{successTrend !== 0 && (
<Badge color={getTrendColor(successTrend)} variant="light" size="xs">
{successTrend > 0 ? '+' : ''}{successTrend.toFixed(1)}%
</Badge>
)}
</Group>
</Stack>
</Card>
{/* Active Users Card */}
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t('audit.stats.activeUsers', 'Active Users')}
</Text>
<LocalIcon icon="group" width="1.2rem" height="1.2rem" />
</Group>
<Text size="xl" fw={700}>
{stats.uniqueUsers}
</Text>
{userTrend !== 0 && (
<Badge
color={getTrendColor(userTrend)}
variant="light"
size="sm"
leftSection={
<LocalIcon
icon={getTrendIcon(userTrend)}
width="0.8rem"
height="0.8rem"
style={{ marginRight: '0.25rem' }}
/>
}
>
{Math.abs(userTrend).toFixed(1)}%
</Badge>
)}
</Stack>
</Card>
{/* Avg Latency Card */}
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t('audit.stats.avgLatency', 'Avg Latency')}
</Text>
<LocalIcon icon="speed" width="1.2rem" height="1.2rem" />
</Group>
<Text size="xl" fw={700}>
{stats.avgLatencyMs > 0 ? `${stats.avgLatencyMs.toFixed(0)}ms` : t('audit.stats.noData', 'N/A')}
</Text>
{latencyTrend !== 0 && stats.avgLatencyMs > 0 && (
<Badge
color={getTrendColor(latencyTrend, true)}
variant="light"
size="sm"
leftSection={
<LocalIcon
icon={getTrendIcon(latencyTrend, true)}
width="0.8rem"
height="0.8rem"
style={{ marginRight: '0.25rem' }}
/>
}
>
{Math.abs(latencyTrend).toFixed(1)}%
</Badge>
)}
</Stack>
</Card>
</SimpleGrid>
);
};
export default AuditStatsCards;

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Card, Group, Stack, Badge, Text } from '@mantine/core';
import { Card, Group, Stack, Badge, Text, Divider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { AuditSystemStatus as AuditStatus } from '@app/services/auditService';
@@ -56,6 +56,44 @@ const AuditSystemStatus: React.FC<AuditSystemStatusProps> = ({ status }) => {
</Text>
</div>
</Group>
<Divider />
<div>
<Text size="sm" fw={600} mb="xs">
{t('audit.systemStatus.capturedFields', 'Captured Fields')}
</Text>
<Group gap="xs">
<Badge color="green" variant="light" size="sm">
{t('audit.systemStatus.username', 'Username')}
</Badge>
<Badge color="green" variant="light" size="sm">
{t('audit.systemStatus.documentName', 'Document Name')}
</Badge>
<Badge color="green" variant="light" size="sm">
{t('audit.systemStatus.tool', 'Tool')}
</Badge>
<Badge color="green" variant="light" size="sm">
{t('audit.systemStatus.date', 'Date')}
</Badge>
<Badge color={status.capturePdfAuthor ? 'green' : 'gray'} variant="light" size="sm">
{t('audit.systemStatus.pdfAuthor', 'PDF Author')}
{!status.capturePdfAuthor && (
<span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', opacity: 0.7 }}>
({t('audit.systemStatus.captureBySettings', 'Enable in settings')})
</span>
)}
</Badge>
<Badge color={status.captureFileHash ? 'green' : 'gray'} variant="light" size="sm">
{t('audit.systemStatus.fileHash', 'File Hash')}
{!status.captureFileHash && (
<span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', opacity: 0.7 }}>
({t('audit.systemStatus.captureBySettings', 'Enable in settings')})
</span>
)}
</Badge>
</Group>
</div>
</Stack>
</Card>
);