331 lines
11 KiB
TypeScript
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>
|
|
);
|
|
} |