Files
lr-openwrt-tool/src/components/activity/ActivityLogs.tsx
T

331 lines
11 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import {
Download,
Search,
Trash2,
} from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Select } from '@/components/ui/Select';
import {
clearActivityLogs,
exportActivityLogs,
getActivityLogs,
} from '@/services/activityLogService';
import type {
ActivityLogEntry,
ActivityLogLevel,
ActivityLogSource,
} from '@/types/activity';
const levels: Array<'all' | ActivityLogLevel> = [
'all',
'info',
'success',
'warning',
'error',
];
const sources: Array<'all' | ActivityLogSource> = [
'all',
'desktop',
'router',
'backend',
'vps',
];
const levelLabels: Record<'all' | ActivityLogLevel, string> = {
all: 'Todos',
info: 'Info',
success: 'Sucesso',
warning: 'Aviso',
error: 'Erro',
};
const sourceLabels: Record<'all' | ActivityLogSource, string> = {
all: 'Todas',
desktop: 'Desktop',
router: 'Router',
backend: 'Backend',
vps: 'VPS',
};
function levelTone(level: ActivityLogLevel) {
if (level === 'success') return 'green';
if (level === 'warning') return 'purple';
if (level === 'error') return 'red';
return 'blue';
}
export function ActivityLogs() {
const [logs, setLogs] = useState<
ActivityLogEntry[]
>([]);
const [levelFilter, setLevelFilter] =
useState<'all' | ActivityLogLevel>('all');
const [sourceFilter, setSourceFilter] =
useState<'all' | ActivityLogSource>('all');
const [query, setQuery] = useState('');
function refreshLogs() {
setLogs(getActivityLogs());
}
useEffect(() => {
refreshLogs();
window.addEventListener(
'activity-log-added',
refreshLogs,
);
return () => {
window.removeEventListener(
'activity-log-added',
refreshLogs,
);
};
}, []);
const filteredLogs = useMemo(() => {
const normalizedQuery =
query.trim().toLowerCase();
return logs.filter((log) => {
const matchesLevel =
levelFilter === 'all' ||
log.level === levelFilter;
const matchesSource =
sourceFilter === 'all' ||
log.source === sourceFilter;
const matchesQuery =
!normalizedQuery ||
[
log.action,
log.message,
log.routerIp,
log.vpnIp,
log.source,
log.level,
]
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(normalizedQuery);
return (
matchesLevel &&
matchesSource &&
matchesQuery
);
});
}, [logs, levelFilter, sourceFilter, query]);
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="mb-5 flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight text-white">
Registos de Atividade
</h2>
<p className="mt-1 text-slate-400">
Histórico local de auditoria de
provisionamento para técnicos.
</p>
</div>
<div className="flex items-center gap-3">
<Button
type="button"
variant="secondary"
onClick={() => {
exportActivityLogs();
}}
>
<Download size={16} />
Exportar JSON
</Button>
<Button
type="button"
variant="danger"
onClick={() => {
clearActivityLogs();
refreshLogs();
}}
>
<Trash2 size={16} />
Limpar Registos
</Button>
</div>
</div>
<Card className="relative z-30 mb-4 overflow-visible">
<div className="grid grid-cols-12 gap-4">
<div className="col-span-6">
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-slate-500">
Pesquisa
</label>
<div className="flex items-center gap-2 rounded-xl border border-white/10 bg-ink-950 px-3">
<Search
size={16}
className="text-slate-500"
/>
<input
value={query}
onChange={(event) =>
setQuery(event.target.value)
}
placeholder="Pesquisar ação, IP VPN, IP router, mensagem..."
className="w-full bg-transparent py-3 text-sm text-white outline-none placeholder:text-slate-600"
/>
</div>
</div>
<div className="col-span-3">
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-slate-500">
Nível
</label>
<Select
value={levelFilter}
onChange={setLevelFilter}
options={levels.map((level) => ({
value: level,
label: levelLabels[level],
}))}
/>
</div>
<div className="col-span-3">
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-slate-500">
Origem
</label>
<Select
value={sourceFilter}
onChange={setSourceFilter}
options={sources.map((source) => ({
value: source,
label: sourceLabels[source],
}))}
/>
</div>
</div>
</Card>
<Card className="relative z-10 flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="mb-4 flex items-center justify-between">
<h3 className="font-semibold text-white">
Eventos de Auditoria
</h3>
<span className="text-sm text-slate-500">
{filteredLogs.length} apresentados /{' '}
{logs.length} total
</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto rounded-xl border border-white/10
[scrollbar-width:thin]
[scrollbar-color:rgba(59,130,246,0.45)_transparent]
[&::-webkit-scrollbar]:w-2
[&::-webkit-scrollbar-track]:bg-transparent
[&::-webkit-scrollbar-thumb]:rounded-full
[&::-webkit-scrollbar-thumb]:bg-blue-500/30
hover:[&::-webkit-scrollbar-thumb]:bg-blue-500/50">
<table className="w-full table-fixed text-sm">
<thead className="sticky top-0 z-10 bg-slate-950 text-left text-xs uppercase tracking-wide text-slate-500">
<tr>
<th className="w-[180px] px-4 py-3">
Hora
</th>
<th className="w-[110px] px-4 py-3">
Nível
</th>
<th className="w-[120px] px-4 py-3">
Origem
</th>
<th className="w-[190px] px-4 py-3">
Ação
</th>
<th className="px-4 py-3">
Mensagem
</th>
<th className="w-[160px] px-4 py-3">
IP VPN
</th>
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{filteredLogs.map((log) => (
<tr
key={log.id}
className="bg-white/[0.01] hover:bg-white/[0.04]"
>
<td className="px-4 py-3 font-mono text-xs text-slate-400">
{new Date(
log.timestamp,
).toLocaleString()}
</td>
<td className="px-4 py-3">
<Badge tone={levelTone(log.level)}>
{levelLabels[log.level]}
</Badge>
</td>
<td className="px-4 py-3 text-slate-300">
{sourceLabels[log.source]}
</td>
<td className="truncate px-4 py-3 font-medium text-white">
{log.action}
</td>
<td className="truncate px-4 py-3 text-slate-400">
{log.message}
</td>
<td className="px-4 py-3 font-mono text-slate-300">
{log.vpnIp ?? '—'}
</td>
</tr>
))}
{filteredLogs.length === 0 && (
<tr>
<td
colSpan={6}
className="px-4 py-10 text-center text-slate-500"
>
Nenhum registo de atividade encontrado.
</td>
</tr>
)}
</tbody>
</table>
</div>
</Card>
</div>
);
}