feat(frontend): add LogsPage with stats, filters and pagination
- New LogsPage.tsx under features/logs/ (was referenced by router but missing) - Uses existing useLogs / useLogStats / useLogLevels hooks - Stats cards (24h total, errors, failed logins, page count) - Search + level filter + pagination wired to /logs/api/logs - i18n strings, lucide icons, shadcn UI primitives Fix frontend/.gitignore: '/logs' was matching 'features/logs/' (rolldown-vite import resolution would have kept failing on CI otherwise). Scope the rule to the project root.
This commit is contained in:
4
frontend/.gitignore
vendored
4
frontend/.gitignore
vendored
@@ -1,5 +1,5 @@
|
|||||||
# Logs
|
# Logs (root-level runtime logs only; do NOT match src/features/logs/)
|
||||||
logs
|
/logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
|
|||||||
245
frontend/src/features/logs/LogsPage.tsx
Normal file
245
frontend/src/features/logs/LogsPage.tsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useLogs, useLogLevels, useLogStats } from '@/hooks/use-logs';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Search, RefreshCw, AlertCircle, ShieldAlert, Activity } from 'lucide-react';
|
||||||
|
import type { AuditLog } from '@/types';
|
||||||
|
|
||||||
|
function levelClass(level?: string) {
|
||||||
|
switch ((level || '').toUpperCase()) {
|
||||||
|
case 'ERROR':
|
||||||
|
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
|
||||||
|
case 'WARNING':
|
||||||
|
case 'WARN':
|
||||||
|
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
|
||||||
|
case 'SUCCESS':
|
||||||
|
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
|
||||||
|
case 'INFO':
|
||||||
|
default:
|
||||||
|
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LogsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [level, setLevel] = useState<string>('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const limit = 50;
|
||||||
|
|
||||||
|
const params = useMemo(
|
||||||
|
() => ({ search: search || undefined, level: level || undefined, page, limit }),
|
||||||
|
[search, level, page],
|
||||||
|
);
|
||||||
|
|
||||||
|
const logsQuery = useLogs(params);
|
||||||
|
const levelsQuery = useLogLevels();
|
||||||
|
const statsQuery = useLogStats(24);
|
||||||
|
|
||||||
|
const logs: AuditLog[] = logsQuery.data?.logs ?? [];
|
||||||
|
const totalPages = logsQuery.data?.pages ?? 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-semibold">{t('logs.title', 'System Logs')}</h1>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
logsQuery.refetch();
|
||||||
|
statsQuery.refetch();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
{t('common.refresh', 'Refresh')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
{t('logs.total_24h', 'Total (24h)')}
|
||||||
|
</CardTitle>
|
||||||
|
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{statsQuery.data?.total ?? '—'}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
{t('logs.errors_24h', 'Errors (24h)')}
|
||||||
|
</CardTitle>
|
||||||
|
<AlertCircle className="h-4 w-4 text-red-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-red-600">
|
||||||
|
{statsQuery.data?.errors_count ?? '—'}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
{t('logs.failed_logins', 'Failed Logins')}
|
||||||
|
</CardTitle>
|
||||||
|
<ShieldAlert className="h-4 w-4 text-yellow-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-yellow-600">
|
||||||
|
{statsQuery.data?.failed_logins ?? '—'}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
{t('logs.current_page', 'On this page')}
|
||||||
|
</CardTitle>
|
||||||
|
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{logs.length}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder={t('logs.search_placeholder', 'Search by message or user…')}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
|
value={level}
|
||||||
|
onChange={(e) => {
|
||||||
|
setLevel(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{t('logs.all_levels', 'All levels')}</option>
|
||||||
|
{(levelsQuery.data?.levels ?? []).map((l) => (
|
||||||
|
<option key={l.value} value={l.value}>
|
||||||
|
{l.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Logs table */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{logsQuery.isLoading ? (
|
||||||
|
<div className="p-8 text-center text-muted-foreground text-sm">
|
||||||
|
{t('common.loading', 'Loading…')}
|
||||||
|
</div>
|
||||||
|
) : logsQuery.error ? (
|
||||||
|
<div className="p-8 text-center text-red-500 text-sm">
|
||||||
|
{t('logs.load_error', 'Failed to load logs')}
|
||||||
|
</div>
|
||||||
|
) : logs.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-muted-foreground text-sm">
|
||||||
|
{t('logs.empty', 'No log entries')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-b bg-muted/50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">
|
||||||
|
{t('logs.time', 'Time')}
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">
|
||||||
|
{t('logs.level', 'Level')}
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">
|
||||||
|
{t('logs.category', 'Category')}
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">
|
||||||
|
{t('logs.user', 'User')}
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">
|
||||||
|
{t('logs.message', 'Message')}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{logs.map((log) => (
|
||||||
|
<tr key={log.id} className="border-b last:border-0 hover:bg-muted/30">
|
||||||
|
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">
|
||||||
|
{new Date(log.timestamp).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Badge className={levelClass(log.level)} variant="secondary">
|
||||||
|
{log.level || 'INFO'}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-muted-foreground">
|
||||||
|
{log.category || '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{log.username || (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 break-words">{log.message}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
{t('logs.page', 'Page')} {page} / {totalPages}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
{t('common.prev', 'Prev')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
{t('common.next', 'Next')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user