frontend; backend: adiciona sistema novo de gravacao de provas

Signed-off-by: Rodrigo B. de Sousa Martins <rodrigo.sousa.577@gmail.com>
This commit is contained in:
rodrigo committed 2025-07-27 18:47:48 -03:00
1 parent 0750b26208
commit 24b5d04fbc
13 files changed
+1507 -12

No files matched your search

+91 -2
View File
@@ -1,6 +1,95 @@
import React from 'react';
import { DashboardLayout } from './dashboard/DashboardLayout';
import { Link } from 'react-router-dom';
export const Dashboard: React.FC = () => {
return <DashboardLayout />;
return (
<div className="p-6">
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-600">Visão geral do seu escritório</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{/* Widget para sessões de prova */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg
className="h-8 w-8 text-purple-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
Sessões de Gravação
</dt>
<dd className="text-lg font-medium text-gray-900">
Disponível
</dd>
</dl>
</div>
</div>
<div className="mt-3">
<Link
to="/sessions/new"
className="text-sm text-purple-600 hover:text-purple-500"
>
Nova sessão de coleta →
</Link>
</div>
</div>
{/* Widget para provas digitais */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg
className="h-8 w-8 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
Provas Digitais
</dt>
<dd className="text-lg font-medium text-gray-900">
Ativo
</dd>
</dl>
</div>
</div>
<div className="mt-3">
<Link
to="/provas/upload"
className="text-sm text-blue-600 hover:text-blue-500"
>
Validar documento →
</Link>
</div>
</div>
</div>
</div>
);
};
export default Dashboard;
+9
View File
@@ -116,6 +116,15 @@ export const MainLayout: React.FC = () => {
icon: MoneyIcon
}
]
},
{
label: 'Provas Digitais',
icon: ShieldCheckIcon,
subitems: [
{ label: 'Upload de Documentos', path: '/provas/upload' },
{ label: 'Gravação de Sessão', path: '/sessions/new' },
{ label: 'Administrar Provas', path: '/provas/admin', roles: ['ADVOGADO_ADMIN'] },
]
}
];
+387
View File
@@ -0,0 +1,387 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify';
export const SessionRecording: React.FC = () => {
const [recording, setRecording] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(null);
const [recordingTime, setRecordingTime] = useState(0);
const [screenshots, setScreenshots] = useState<string[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const recordedChunksRef = useRef<Blob[]>([]);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const navigate = useNavigate();
useEffect(() => {
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
}
};
}, []);
const startRecording = useCallback(async () => {
try {
// Coletar informações de contexto
const metadata = await collectContextMetadata();
// Criar sessão no backend
const response = await fetch('/api/sessions/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(metadata)
});
if (!response.ok) {
throw new Error('Erro ao criar sessão');
}
const { sessionId: newSessionId } = await response.json();
setSessionId(newSessionId);
// Solicitar captura de tela
const stream = await navigator.mediaDevices.getDisplayMedia({
video: { mediaSource: 'screen' },
audio: true
});
streamRef.current = stream;
// Configurar MediaRecorder
const mediaRecorder = new MediaRecorder(stream, {
mimeType: 'video/webm;codecs=vp9'
});
mediaRecorderRef.current = mediaRecorder;
recordedChunksRef.current = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
recordedChunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = () => {
handleRecordingStop(newSessionId);
};
// Iniciar gravação
mediaRecorder.start(1000); // Gravar em chunks de 1 segundo
setRecording(true);
setRecordingTime(0);
// Iniciar timer
timerRef.current = setInterval(() => {
setRecordingTime(prev => prev + 1);
}, 1000);
// Overlay de informações na tela
createScreenOverlay(metadata);
toast.success('Gravação de sessão iniciada!');
} catch (error) {
console.error('Erro ao iniciar gravação:', error);
toast.error('Erro ao iniciar gravação: ' + error.message);
}
}, []);
const stopRecording = useCallback(() => {
if (mediaRecorderRef.current && recording) {
mediaRecorderRef.current.stop();
setRecording(false);
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
}
removeScreenOverlay();
}
}, [recording]);
const takeScreenshot = useCallback(async () => {
if (!sessionId || !streamRef.current) {
toast.error('Sessão não ativa');
return;
}
try {
// Capturar screenshot usando Canvas
const canvas = document.createElement('canvas');
const video = document.createElement('video');
video.srcObject = streamRef.current;
video.play();
video.onloadedmetadata = async () => {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
ctx?.drawImage(video, 0, 0);
// Converter para base64
const screenshotData = canvas.toDataURL('image/png');
// Enviar para o backend
const response = await fetch(`/api/sessions/${sessionId}/screenshot`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ screenshot: screenshotData })
});
if (response.ok) {
const { filename } = await response.json();
setScreenshots(prev => [...prev, filename]);
toast.success('Screenshot capturado!');
}
};
} catch (error) {
console.error('Erro ao capturar screenshot:', error);
toast.error('Erro ao capturar screenshot');
}
}, [sessionId]);
const handleRecordingStop = async (sessionId: string) => {
try {
setIsProcessing(true);
// Combinar chunks do vídeo
const blob = new Blob(recordedChunksRef.current, { type: 'video/webm' });
// Enviar vídeo para o backend
const formData = new FormData();
formData.append('video', blob, `session_${sessionId}.webm`);
const uploadResponse = await fetch(`/api/sessions/${sessionId}/video`, {
method: 'POST',
body: formData
});
if (!uploadResponse.ok) {
throw new Error('Erro ao enviar vídeo');
}
// Finalizar sessão e gerar relatório
const finalizeResponse = await fetch(`/api/sessions/${sessionId}/finalize`, {
method: 'POST'
});
if (!finalizeResponse.ok) {
throw new Error('Erro ao finalizar sessão');
}
const { reportPath } = await finalizeResponse.json();
toast.success('Sessão finalizada e relatório gerado!');
// Navegar para página de resultados
navigate(`/sessions/report/${sessionId}`);
} catch (error) {
console.error('Erro ao processar gravação:', error);
toast.error('Erro ao processar gravação: ' + error.message);
} finally {
setIsProcessing(false);
}
};
const formatTime = (seconds: number): string => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
return (
<div className="max-w-4xl mx-auto p-6">
<div className="bg-white rounded-lg shadow-lg p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-6">
Gravação de Sessão de Coleta de Prova
</h2>
<div className="space-y-6">
{/* Status da gravação */}
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div className="flex items-center space-x-3">
<div className={`w-3 h-3 rounded-full ${recording ? 'bg-red-500 animate-pulse' : 'bg-gray-300'}`} />
<span className="font-medium">
{recording ? 'Gravando' : isProcessing ? 'Processando...' : 'Pronto para gravar'}
</span>
{recording && (
<span className="font-mono text-lg text-red-600">
{formatTime(recordingTime)}
</span>
)}
</div>
{sessionId && (
<span className="text-sm text-gray-500">
Sessão: {sessionId}
</span>
)}
</div>
{/* Controles */}
<div className="flex justify-center space-x-4">
{!recording && !isProcessing && (
<button
onClick={startRecording}
className="px-6 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium"
>
Iniciar Gravação
</button>
)}
{recording && (
<>
<button
onClick={takeScreenshot}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"
>
Capturar Tela
</button>
<button
onClick={stopRecording}
className="px-6 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 font-medium"
>
Parar Gravação
</button>
</>
)}
</div>
{/* Informações da sessão */}
{(recording || screenshots.length > 0) && (
<div className="border-t pt-4">
<h3 className="font-medium text-gray-900 mb-2">Informações da Sessão</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-500">Screenshots capturados:</span>
<span className="ml-2 font-medium">{screenshots.length}</span>
</div>
<div>
<span className="text-gray-500">Tempo de gravação:</span>
<span className="ml-2 font-medium">{formatTime(recordingTime)}</span>
</div>
</div>
</div>
)}
{/* Lista de screenshots */}
{screenshots.length > 0 && (
<div className="border-t pt-4">
<h3 className="font-medium text-gray-900 mb-2">Screenshots Capturados</h3>
<div className="space-y-1">
{screenshots.map((screenshot, index) => (
<div key={index} className="text-sm text-gray-600">
{index + 1}. {screenshot}
</div>
))}
</div>
</div>
)}
{isProcessing && (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4" />
<p className="text-gray-600">Processando gravação e gerando relatório...</p>
</div>
)}
</div>
</div>
</div>
);
};
// Função para coletar metadados de contexto
async function collectContextMetadata() {
const getLocation = (): Promise<GeolocationPosition | null> => {
return new Promise((resolve) => {
if (!navigator.geolocation) {
resolve(null);
return;
}
navigator.geolocation.getCurrentPosition(
(position) => resolve(position),
() => resolve(null),
{ timeout: 5000 }
);
});
};
const location = await getLocation();
return {
userAgent: navigator.userAgent,
language: navigator.language,
platform: navigator.platform,
cookieEnabled: navigator.cookieEnabled,
onlineStatus: navigator.onLine,
screenResolution: `${screen.width}x${screen.height}`,
viewportSize: `${window.innerWidth}x${window.innerHeight}`,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
geolocation: location ? {
latitude: location.coords.latitude,
longitude: location.coords.longitude,
accuracy: location.coords.accuracy
} : null,
timestamp: new Date().toISOString(),
url: window.location.href,
referrer: document.referrer
};
}
// Função para criar overlay na tela
function createScreenOverlay(metadata: any) {
const overlay = document.createElement('div');
overlay.id = 'recording-overlay';
overlay.style.cssText = `
position: fixed;
top: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 10px;
border-radius: 5px;
font-family: monospace;
font-size: 12px;
z-index: 10000;
max-width: 300px;
`;
overlay.innerHTML = `
<div>🔴 GRAVAÇÃO ATIVA</div>
<div>IP: ${metadata.ipAddress || 'Coletando...'}</div>
<div>Timestamp: ${new Date().toLocaleString()}</div>
<div>User-Agent: ${metadata.userAgent.substring(0, 50)}...</div>
`;
document.body.appendChild(overlay);
}
// Função para remover overlay
function removeScreenOverlay() {
const overlay = document.getElementById('recording-overlay');
if (overlay) {
overlay.remove();
}
}
export default SessionRecording;
+227
View File
@@ -0,0 +1,227 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
interface SessionData {
id: string;
startTime: string;
endTime?: string;
ipAddress: string;
dnsInfo: string;
userAgent: string;
geolocation?: {
latitude: number;
longitude: number;
};
screenshots: string[];
videoPath?: string;
metadata: any;
}
export const SessionReport: React.FC = () => {
const { sessionId } = useParams<{ sessionId: string }>();
const [session, setSession] = useState<SessionData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchSession = async () => {
try {
const response = await fetch(`/api/sessions/${sessionId}`);
if (!response.ok) {
throw new Error('Sessão não encontrada');
}
const data = await response.json();
setSession(data.session);
} catch (error) {
console.error('Erro ao buscar sessão:', error);
setError(error.message);
} finally {
setLoading(false);
}
};
if (sessionId) {
fetchSession();
}
}, [sessionId]);
const formatDuration = (start: string, end?: string) => {
if (!end) return 'Em andamento';
const startTime = new Date(start);
const endTime = new Date(end);
const diffMs = endTime.getTime() - startTime.getTime();
const hours = Math.floor(diffMs / (1000 * 60 * 60));
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto">
<div className="bg-white rounded-lg shadow p-6">
<div className="animate-pulse">
<div className="h-6 bg-gray-300 rounded w-3/4 mb-4"></div>
<div className="h-4 bg-gray-300 rounded w-1/2 mb-2"></div>
<div className="h-4 bg-gray-300 rounded w-2/3"></div>
</div>
</div>
</div>
</div>
);
}
if (error || !session) {
return (
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto">
<div className="bg-white rounded-lg shadow p-6 text-center">
<div className="text-red-600 text-xl font-bold mb-4">
Erro ao carregar sessão
</div>
<p className="text-gray-600 mb-6">{error || 'Sessão não encontrada'}</p>
<Link
to="/sessions/new"
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Nova Sessão
</Link>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto">
<div className="bg-white rounded-lg shadow overflow-hidden">
{/* Header */}
<div className="bg-green-600 text-white px-6 py-4">
<h1 className="text-2xl font-bold">Relatório de Sessão de Coleta de Prova</h1>
<p className="text-green-100">ID da Sessão: {session.id}</p>
</div>
{/* Resumo da sessão */}
<div className="p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Resumo da Sessão</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 className="text-sm font-medium text-gray-500">Data/Hora de Início</h3>
<p className="text-lg text-gray-900">
{format(new Date(session.startTime), 'dd/MM/yyyy HH:mm:ss', { locale: ptBR })}
</p>
</div>
{session.endTime && (
<div>
<h3 className="text-sm font-medium text-gray-500">Data/Hora de Fim</h3>
<p className="text-lg text-gray-900">
{format(new Date(session.endTime), 'dd/MM/yyyy HH:mm:ss', { locale: ptBR })}
</p>
</div>
)}
<div>
<h3 className="text-sm font-medium text-gray-500">Duração</h3>
<p className="text-lg text-gray-900">
{formatDuration(session.startTime, session.endTime)}
</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500">Screenshots Capturados</h3>
<p className="text-lg text-gray-900">{session.screenshots.length}</p>
</div>
</div>
</div>
{/* Informações de rede */}
<div className="p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Informações de Rede</h2>
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium text-gray-500">Endereço IP</h3>
<p className="text-gray-900 font-mono">{session.ipAddress}</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500">DNS Reverso</h3>
<p className="text-gray-900 font-mono">{session.dnsInfo}</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500">User-Agent</h3>
<p className="text-gray-900 font-mono text-sm break-all">{session.userAgent}</p>
</div>
</div>
</div>
{/* Geolocalização */}
{session.geolocation && (
<div className="p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Geolocalização</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h3 className="text-sm font-medium text-gray-500">Latitude</h3>
<p className="text-gray-900 font-mono">{session.geolocation.latitude}</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500">Longitude</h3>
<p className="text-gray-900 font-mono">{session.geolocation.longitude}</p>
</div>
</div>
</div>
)}
{/* Metadados adicionais */}
<div className="p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Metadados do Sistema</h2>
<div className="bg-gray-50 rounded-lg p-4">
<pre className="text-sm text-gray-700 overflow-x-auto">
{JSON.stringify(session.metadata, null, 2)}
</pre>
</div>
</div>
{/* Ações */}
<div className="p-6 flex flex-col sm:flex-row gap-4">
<a
href={`/api/sessions/${session.id}/report`}
className="inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
Baixar Relatório PDF
</a>
{session.videoPath && (
<button className="inline-flex items-center justify-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
Visualizar Vídeo
</button>
)}
<Link
to="/sessions/new"
className="inline-flex items-center justify-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
Nova Sessão
</Link>
</div>
</div>
<div className="mt-4 text-center text-sm text-gray-500">
Relatório gerado pelo sistema AdvX Provas - Coleta de Evidências Digitais
</div>
</div>
</div>
);
};
export default SessionReport;