1
0

Refactor App structure and improve styling.

Reorganized component structure for modularity, introducing `AppHeader`, `Sidebar`, and page-specific components. Simplified state management with `LogProvider` context. Added custom Tailwind CSS themes for consistent app styling.
This commit is contained in:
Márcio Eric 2026-02-09 22:20:04 -03:00
parent 74b7569dd1
commit c32ac5fd3c
20 changed files with 1457 additions and 651 deletions

View File

@ -24,7 +24,7 @@
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
<title>Voyager</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

View File

@ -1,656 +1,38 @@
import React, { useState, useEffect, useRef } from 'react';
import { Database, Globe, Download, Play, Pause, Trash2, Plus, RefreshCw, Activity, Clock, CircleX, CircleCheck, Grid, Calendar, Terminal } from 'lucide-react';
import * as signalR from '@microsoft/signalr';
import { authFetch } from './auth/authFetch';
import { getAccessToken } from './auth/userManager';
import React, { useCallback, useState } from 'react';
import { AppHeader } from './components/AppHeader';
import { Sidebar } from './components/Sidebar';
import { DashboardPage } from './pages/DashboardPage';
import { ScraperPage } from './pages/ScraperPage';
import { ExtractorsPage } from './pages/ExtractorsPage';
import { SchedulesPage } from './pages/SchedulesPage';
import { LogsPage } from './pages/LogsPage';
import { useLogs } from './context/LogContext';
const API_BASE = 'http://localhost:5123';
const WebScraper = () => {
const [sessions, setSessions] = useState([]);
const [selectedSessionId, setSelectedSessionId] = useState(null);
const selectedSession = sessions.find(s => s.sessionId === selectedSessionId) ?? null;
const [sessionStatus, setSessionStatus] = useState(null);
const [newUrl, setNewUrl] = useState('');
const [logs, setLogs] = useState([]);
export default function App() {
const { addLog } = useLogs();
const [activePage, setActivePage] = useState('scraper');
const [isConnected, setIsConnected] = useState(false);
// Estado de navegação simples para futuras páginas
const [activePage, setActivePage] = useState('scraper'); // 'dashboard' | 'scraper' | 'schedules' | 'logs'
const connectionRef = useRef(null);
const addLog = (message, type = 'info') => {
setLogs(prev => {
const newLogs = [...prev, {
message,
type,
time: new Date().toLocaleTimeString()
}];
return newLogs.slice(-100);
});
};
useEffect(() => {
loadSessions();
}, []);
// Conectar ao SignalR
useEffect(() => {
const connection = new signalR.HubConnectionBuilder()
.withUrl(`${API_BASE}/ws/scrape`, {
accessTokenFactory: async () => (await getAccessToken()) ?? ''
})
.withAutomaticReconnect()
.build();
connection.on('scrapeEvent', (event) => {
handleScrapeEvent(normalizeEventType(event));
});
connection.onreconnecting(() => {
setIsConnected(false);
addLog('Reconectando ao servidor...', 'warning');
});
connection.onreconnected(async () => {
setIsConnected(true);
addLog('Reconectado ao servidor', 'success');
try {
await connection.invoke('SubscribeOverview');
await loadSessions(); // ressincroniza snapshot
if (selectedSession) {
await connection.invoke('Subscribe', selectedSession.sessionId);
await loadSessionStatus(selectedSession.sessionId); // snapshot também
}
} catch (e) { }
});
connection.onclose(() => {
setIsConnected(false);
addLog('Desconectado do servidor', 'error');
});
connection.start()
.then(async () => {
setIsConnected(true);
addLog('Conectado ao servidor', 'success');
connectionRef.current = connection;
await connection.invoke('SubscribeOverview');
await loadSessions();
})
.catch(err => {
addLog(`Erro ao conectar: ${err.message}`, 'error');
});
return () => {
if (connection) {
connection.stop();
}
};
}, []);
// Inscrever na sessão selecionada
useEffect(() => {
if (connectionRef.current && selectedSession && isConnected) {
connectionRef.current.invoke('Subscribe', selectedSession.sessionId)
.then(() => {
addLog(`Inscrito na sessão: ${selectedSession.name}`, 'info');
})
.catch(err => {
addLog(`Erro ao inscrever: ${err.message}`, 'error');
});
return () => {
if (connectionRef.current) {
connectionRef.current.invoke('Unsubscribe', selectedSession.sessionId)
.catch(() => { });
}
};
}
}, [selectedSession, isConnected]);
const upsertSessionFromEvent = (sid, patch) => {
setSessions(prev => {
const idx = prev.findIndex(s => s.sessionId === sid);
if (idx === -1) return prev;
const copy = [...prev];
copy[idx] = { ...copy[idx], ...patch };
return copy;
});
};
const ScrapeEventType = {
0: 'SessionStarted',
1: 'SessionStopRequested',
2: 'SessionStopped',
3: 'ItemStarted',
4: 'ItemSucceeded',
5: 'ItemFailed',
6: 'Progress',
};
const normalizeEventType = (ev) => {
if (typeof ev.type === 'number') return { ...ev, type: ScrapeEventType[ev.type] ?? ev.type };
return ev;
};
const handleScrapeEvent = (event) => {
const sid = event.sessionId;
switch (event.type) {
case 'SessionStarted':
upsertSessionFromEvent(event.sessionId, { isRunning: true });
addLog(`🚀 Sessão iniciada`, 'success');
break;
case 'SessionStopped':
upsertSessionFromEvent(event.sessionId, { isRunning: false });
addLog(`⏹️ Sessão parada`, 'info');
break;
case 'ItemStarted':
addLog(`📄 Processando: ${event.url}`, 'info');
setSessionStatus(prev => {
if (!prev || prev.runtime?.sessionId !== sid) return prev;
return {
...prev,
runtime: {
...prev.runtime,
isRunning: true,
currentUrl: event.url,
currentQueueId: event.queueId ?? prev.runtime?.currentQueueId,
currentStartedAt: event.at
}
};
});
break;
case 'ItemSucceeded':
addLog(`✓ Sucesso: ${event.url}`, 'success');
break;
case 'ItemFailed':
addLog(`✗ Falha: ${event.url} - ${event.error}`, 'error');
break;
case 'Progress': {
upsertSessionFromEvent(event.sessionId, {
queue: {
total: event.total,
pending: event.pending,
processing: event.processing,
done: event.done,
failed: event.failed,
}
});
if (selectedSession?.sessionId === event.sessionId) {
setSessionStatus(prev => ({
...(prev ?? {}),
counts: {
total: event.total,
pending: event.pending,
processing: event.processing,
done: event.done,
failed: event.failed
},
percent: event.percent
}));
}
break;
}
default:
break;
}
};
const loadSessions = async () => {
try {
const response = await authFetch(`${API_BASE}/sessions`);
const data = await response.json();
setSessions(data);
} catch (err) {
addLog(`Erro ao carregar sessões: ${err.message}`, 'error');
}
};
const loadSessionStatus = async (sessionId) => {
try {
const response = await authFetch(`${API_BASE}/sessions/${sessionId}/scrap/status`);
const data = await response.json();
setSessionStatus(data);
} catch (err) {
addLog(`Erro ao carregar status: ${err.message}`, 'error');
}
};
const startScrapingById = async (sessionId, sessionName) => {
try {
await authFetch(`${API_BASE}/sessions/${sessionId}/scrap/start`, {
method: 'POST'
});
addLog(`Iniciando scraping da sessão: ${sessionName}`, 'info');
} catch (err) {
addLog(`Erro ao iniciar: ${err.message}`, 'error');
}
};
const stopScrapingById = async (sessionId, sessionName) => {
try {
await authFetch(`${API_BASE}/sessions/${sessionId}/scrap/stop`, {
method: 'POST'
});
addLog(`Parando sessão: ${sessionName}`, 'warning');
} catch (err) {
addLog(`Erro ao parar: ${err.message}`, 'error');
}
};
const startScraping = async () => {
if (!selectedSession) return;
await startScrapingById(selectedSession.sessionId, selectedSession.name);
};
const stopScraping = async () => {
if (!selectedSession) return;
await stopScrapingById(selectedSession.sessionId, selectedSession.name);
};
const addUrlToQueue = async () => {
if (!selectedSession || !newUrl) return;
try {
const response = await authFetch(`${API_BASE}/sessions/${selectedSession.sessionId}/queue`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: newUrl })
});
if (response.ok) {
const data = await response.json();
addLog(`✓ URL adicionada: ${newUrl}`, 'success');
setNewUrl('');
loadSessionStatus(selectedSession.sessionId);
} else {
const error = await response.text();
addLog(`Erro ao adicionar URL: ${error}`, 'error');
}
} catch (err) {
addLog(`Erro ao adicionar URL: ${err.message}`, 'error');
}
};
const updateSessionFromProgress = (sessionId, ev) => {
setSessions(prev =>
prev.map(s =>
s.sessionId !== sessionId
? s
: {
...s,
queue: {
total: ev.total ?? s.queue?.total ?? 0,
pending: ev.pending ?? s.queue?.pending ?? 0,
processing: ev.processing ?? s.queue?.processing ?? 0,
done: ev.done ?? s.queue?.done ?? 0,
failed: ev.failed ?? s.queue?.failed ?? 0
}
}
)
);
};
const selectSession = (session) => {
setSelectedSessionId(session.sessionId);
setLogs([]);
addLog(`Sessão selecionada: ${session.name}`, 'info');
loadSessionStatus(session.sessionId);
};
const formatNumber = (num) => {
return num?.toLocaleString('pt-BR') || '0';
};
const patchSessionCounts = (sid, ev) => {
setSessions(prev =>
prev.map(s => s.sessionId !== sid ? s : ({
...s,
queue: {
total: ev.total ?? s.queue?.total ?? 0,
pending: ev.pending ?? s.queue?.pending ?? 0,
processing: ev.processing ?? s.queue?.processing ?? 0,
done: ev.done ?? s.queue?.done ?? 0,
failed: ev.failed ?? s.queue?.failed ?? 0
}
}))
);
};
const patchSessionRunning = (sid, isRunning) => {
setSessions(prev =>
prev.map(s => s.sessionId !== sid ? s : ({ ...s, isRunning }))
);
};
const getProgressPercent = () => {
if (!sessionStatus?.counts) return 0;
const { total, done } = sessionStatus.counts;
return total > 0 ? ((done / total) * 100).toFixed(1) : 0;
};
const handleSessionAction = (e, session) => {
e.stopPropagation();
if (session.isRunning) {
stopScrapingById(session.sessionId, session.name);
} else {
startScrapingById(session.sessionId, session.name);
}
};
// Componente Header
const Header = () => (
<header className="h-16 shrink-0 bg-slate-900/80 backdrop-blur border-b border-slate-700 flex items-center justify-between px-6 fixed top-0 left-0 right-0 z-20">
<div className="flex items-center gap-3">
<Globe className="text-blue-400" />
<h1 className="text-lg font-bold tracking-tight text-white">Web Scraper Pro</h1>
</div>
<div className="flex items-center gap-3">
<div className={`flex items-center gap-2 px-3.5 py-1.5 rounded border text-sm font-medium ${isConnected ? 'bg-green-900/20 border-green-800 text-green-400' : 'bg-red-900/20 border-red-800 text-red-400'}`}>
<Activity size={16} />
{isConnected ? 'Conectado' : 'Desconectado'}
</div>
<button
onClick={loadSessions}
className="flex items-center gap-2 px-3.5 py-1.5 bg-slate-800 border border-slate-700 hover:border-blue-500/50 text-slate-300 hover:text-white rounded transition-colors text-sm"
>
<RefreshCw size={16} />
Atualizar
</button>
</div>
</header>
);
// Componente Sidebar
const Sidebar = () => (
<aside className="w-64 bg-slate-900/60 backdrop-blur border-r border-slate-700 flex flex-col shrink-0 fixed top-16 bottom-0 left-0 z-10">
<div className="p-4">
<p className="px-2 text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Gerenciamento</p>
<nav className="space-y-1">
<button
onClick={() => setActivePage('dashboard')}
className={`w-full text-left flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${activePage === 'dashboard' ? 'bg-blue-500/10 text-blue-400 border border-blue-500/20' : 'text-slate-400 hover:bg-slate-800 hover:text-white'}`}
>
<Grid size={18} />
<span className="text-sm font-medium">Dashboard</span>
</button>
<button
onClick={() => setActivePage('scraper')}
className={`w-full text-left flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${activePage === 'scraper' ? 'bg-blue-500/10 text-blue-400 border border-blue-500/20' : 'text-slate-400 hover:bg-slate-800 hover:text-white'}`}
>
<Database size={18} />
<span className="text-sm font-bold">Scraper</span>
</button>
<button
onClick={() => setActivePage('schedules')}
className={`w-full text-left flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${activePage === 'schedules' ? 'bg-blue-500/10 text-blue-400 border border-blue-500/20' : 'text-slate-400 hover:bg-slate-800 hover:text-white'}`}
>
<Calendar size={18} />
<span className="text-sm font-medium">Agendamentos</span>
</button>
<button
onClick={() => setActivePage('logs')}
className={`w-full text-left flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${activePage === 'logs' ? 'bg-blue-500/10 text-blue-400 border border-blue-500/20' : 'text-slate-400 hover:bg-slate-800 hover:text-white'}`}
>
<Terminal size={18} />
<span className="text-sm font-medium">Logs de Execução</span>
</button>
</nav>
</div>
</aside>
);
const onRefresh = useCallback(() => {
addLog('Atualizar: recarregue dados nas telas (botão global).', 'info');
// Each page has its own refresh buttons. This is mainly visual.
}, [addLog]);
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 text-white">
<Header />
<Sidebar />
<div className="min-h-screen bg-app-bg text-text-main flex flex-col overflow-hidden">
<AppHeader isConnected={isConnected} onRefresh={onRefresh} />
{/* Conteúdo principal */}
<main className="pt-16 pl-0 md:pl-64">
<div className="p-6 lg:p-8">
{/* Breadcrumb e título da página ativa */}
<div className="mb-6">
<div className="flex items-center text-xs text-slate-400 mb-2">
<span className="hover:text-blue-400 transition-colors cursor-default">Extratores</span>
<span className="mx-2 text-slate-600">/</span>
<span className="text-white">{activePage === 'scraper' ? 'Scraper' : activePage.charAt(0).toUpperCase() + activePage.slice(1)}</span>
</div>
<div className="flex items-end justify-between gap-4">
<div>
<h2 className="text-2xl md:text-3xl font-bold text-white tracking-tight">
{activePage === 'scraper' ? 'Gerenciador de Sessões' : 'Em breve'}
</h2>
<p className="text-slate-400 text-sm mt-1">
{activePage === 'scraper' ? 'Gerencie e monitore suas sessões de scraping.' : 'Conteúdo a ser implementado.'}
</p>
</div>
</div>
</div>
<div className="flex flex-1 overflow-hidden">
<Sidebar activePage={activePage} setActivePage={setActivePage} />
{/* Render da página ativa */}
{activePage === 'scraper' ? (
<div className="max-w-7xl mx-auto">
<div className="grid grid-cols-3 gap-6">
{/* Lista de Sessões */}
<div className="bg-slate-800 rounded-lg p-6 shadow-xl">
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<Database size={20} />
Sessões
</h2>
<div className="space-y-2">
{sessions.map((session) => (
<div
key={session.sessionId}
onClick={() => selectSession(session)}
className={`p-4 rounded-lg cursor-pointer transition ${selectedSession?.sessionId === session.sessionId
? 'bg-blue-600'
: 'bg-slate-700 hover:bg-slate-600'
}`}
>
<div className="flex justify-between items-center mb-2">
<span className="font-bold">{session.name}</span>
<div className="flex items-center gap-2">
{session.isRunning && (
<span className="px-2 py-1 bg-green-500 text-xs rounded">
Rodando
</span>
)}
<button
onClick={(e) => handleSessionAction(e, session)}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition ${
session.isRunning
? 'bg-orange-600 hover:bg-orange-700'
: 'bg-blue-600 hover:bg-blue-700'
}`}
title={session.isRunning ? 'Parar (Graceful)' : 'Iniciar Scraping'}
>
{session.isRunning ? <Pause size={14} /> : <Play size={14} />}
{session.isRunning ? 'Parar' : 'Iniciar'}
</button>
</div>
</div>
<div className="text-sm text-slate-300">
<div>Total: {formatNumber(session.queue?.total || 0)}</div>
<div className="flex gap-4 mt-1">
<span className="text-green-400 flex items-center"><CircleCheck size={16} />&nbsp;{formatNumber(session.queue?.done || 0)}</span>
<span className="text-yellow-400 flex items-center"><Clock size={16} />&nbsp;{formatNumber(session.queue?.pending || 0)}</span>
<span className="text-red-400 flex items-center"><CircleX size={16} />&nbsp;{formatNumber(session.queue?.failed || 0)}</span>
</div>
</div>
</div>
))}
{sessions.length === 0 && (
<div className="text-slate-500 text-center py-8">
Nenhuma sessão disponível
</div>
)}
</div>
</div>
{/* Detalhes e Controles */}
<div className="col-span-2 space-y-6">
{selectedSession ? (
<>
{/* Controles */}
<div className="bg-slate-800 rounded-lg p-6 shadow-xl">
<h3 className="text-lg font-bold mb-4">Controles - {selectedSession.name}</h3>
<div className="flex gap-3 mb-4">
<input
type="text"
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
placeholder="https://site.com/page"
className="flex-1 px-4 py-2 bg-slate-700 rounded-lg border border-slate-600 focus:border-blue-500 focus:outline-none"
onKeyPress={(e) => e.key === 'Enter' && addUrlToQueue()}
/>
<button
onClick={addUrlToQueue}
className="flex items-center gap-2 px-6 py-2 bg-green-600 hover:bg-green-700 rounded-lg font-medium transition"
disabled={!newUrl}
>
<Plus size={18} />
Adicionar
</button>
</div>
<div className="flex gap-3">
{!selectedSession.isRunning ? (
<button
onClick={startScraping}
className="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg font-medium transition"
>
<Play size={18} />
Iniciar Scraping
</button>
) : (
<button
onClick={stopScraping}
className="flex items-center gap-2 px-6 py-2 bg-orange-600 hover:bg-orange-700 rounded-lg font-medium transition"
>
<Pause size={18} />
Parar (Graceful)
</button>
)}
</div>
</div>
{/* Estatísticas */}
{sessionStatus && (
<>
<div className="bg-slate-800 rounded-lg p-6 shadow-xl">
<h3 className="text-lg font-bold mb-4">Progresso</h3>
{/* Barra de Progresso */}
<div className="mb-4">
<div className="flex justify-between text-sm mb-2">
<span>Processado</span>
<span>{getProgressPercent()}%</span>
</div>
<div className="w-full bg-slate-700 rounded-full h-4 overflow-hidden">
<div
className="bg-gradient-to-r from-blue-500 to-green-500 h-full transition-all duration-500"
style={{ width: `${getProgressPercent()}%` }}
/>
</div>
</div>
<div className="grid grid-cols-5 gap-3">
<div className="bg-slate-700 rounded-lg p-3">
<div className="text-slate-400 text-xs mb-1">Total</div>
<div className="text-2xl font-bold">{formatNumber(sessionStatus.counts?.total)}</div>
</div>
<div className="bg-slate-700 rounded-lg p-3">
<div className="text-slate-400 text-xs mb-1">Concluído</div>
<div className="text-2xl font-bold text-green-400">{formatNumber(sessionStatus.counts?.done)}</div>
</div>
<div className="bg-slate-700 rounded-lg p-3">
<div className="text-slate-400 text-xs mb-1">Pendente</div>
<div className="text-2xl font-bold text-yellow-400">{formatNumber(sessionStatus.counts?.pending)}</div>
</div>
<div className="bg-slate-700 rounded-lg p-3">
<div className="text-slate-400 text-xs mb-1">Processando</div>
<div className="text-2xl font-bold text-blue-400">{formatNumber(sessionStatus.counts?.processing)}</div>
</div>
<div className="bg-slate-700 rounded-lg p-3">
<div className="text-slate-400 text-xs mb-1">Falhas</div>
<div className="text-2xl font-bold text-red-400">{formatNumber(sessionStatus.counts?.failed)}</div>
</div>
</div>
{/* URL Atual */}
{sessionStatus.runtime?.isRunning && sessionStatus.runtime?.currentUrl && (
<div className="mt-4 p-3 bg-slate-700 rounded-lg">
<div className="text-xs text-slate-400 mb-1">Processando agora:</div>
<div className="text-sm text-blue-300 truncate" title={sessionStatus.runtime.currentUrl}>
{sessionStatus.runtime.currentUrl}
</div>
<div className="text-xs text-slate-400 mt-1">
Iniciado: {new Date(sessionStatus.runtime.currentStartedAt).toLocaleTimeString('pt-BR')}
</div>
</div>
)}
</div>
</>
)}
{/* Logs */}
<div className="bg-slate-800 rounded-lg p-6 shadow-xl">
<h2 className="text-xl font-bold mb-4">Logs em Tempo Real</h2>
<div className="space-y-1 max-h-96 overflow-y-auto font-mono text-xs">
{logs.map((log, idx) => (
<div key={idx} className={`
${log.type === 'error' ? 'text-red-400' : ''}
${log.type === 'success' ? 'text-green-400' : ''}
${log.type === 'warning' ? 'text-yellow-400' : ''}
${log.type === 'info' ? 'text-slate-300' : ''}
`}>
[{log.time}] {log.message}
</div>
))}
{logs.length === 0 && (
<div className="text-slate-500 text-center py-8">
Nenhum evento ainda. Selecione uma sessão e inicie o scraping.
</div>
)}
</div>
</div>
</>
) : (
<div className="bg-slate-800 rounded-lg p-12 shadow-xl text-center">
<Globe size={64} className="mx-auto mb-4 text-slate-600" />
<h3 className="text-xl font-bold mb-2">Selecione uma Sessão</h3>
<p className="text-slate-400">
Escolha uma sessão à esquerda para começar
</p>
</div>
)}
</div>
</div>
</div>
) : (
<div className="bg-slate-800/50 border border-slate-700 rounded-xl p-8 text-slate-300">
Conteúdo da página "{activePage}" em breve.
</div>
)}
</div>
<main className="flex-1 overflow-y-auto bg-app-bg p-6 lg:p-8">
{activePage === 'dashboard' ? <DashboardPage /> : null}
{activePage === 'scraper' ? <ScraperPage onConnectionChanged={setIsConnected} /> : null}
{activePage === 'extractors' ? <ExtractorsPage /> : null}
{activePage === 'schedules' ? <SchedulesPage /> : null}
{activePage === 'logs' ? <LogsPage /> : null}
</main>
</div>
</div>
);
};
export default WebScraper;
}

84
src/api/extractionApi.js Normal file
View File

@ -0,0 +1,84 @@
import { authFetch } from '../auth/authFetch';
async function safeText(res) {
try {
return await res.text();
} catch {
return 'Request failed';
}
}
/**
* Extraction API client.
* All endpoints are relative to API base.
*/
export function createExtractionApi(baseUrl) {
return {
// 1) Models
async listModels() {
const res = await authFetch(`${baseUrl}/extraction-models`);
if (!res.ok) throw new Error(await safeText(res));
return res.json();
},
async getModel(id) {
const res = await authFetch(`${baseUrl}/extraction-models/${id}`);
if (!res.ok) throw new Error(await safeText(res));
return res.json();
},
async createModel(payload) {
const res = await authFetch(`${baseUrl}/extraction-models`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(await safeText(res));
return res.json();
},
// 2) Runs
async startRun({ sessionId, modelId, onlyDone = true }) {
const res = await authFetch(`${baseUrl}/extraction-runs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, modelId, onlyDone }),
});
if (!res.ok) throw new Error(await safeText(res));
return res.json(); // { runId }
},
async bulkRun({ modelId, onlyDone = true, sessionIds }) {
const body = Array.isArray(sessionIds) && sessionIds.length
? { modelId, onlyDone, sessionIds }
: { modelId, onlyDone };
const res = await authFetch(`${baseUrl}/extraction-runs/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(await safeText(res));
return res.json(); // { runIds, count }
},
async getRun(runId) {
const res = await authFetch(`${baseUrl}/extraction-runs/${runId}`);
if (!res.ok) throw new Error(await safeText(res));
return res.json();
},
// 3) Results
async listResultsBySessionModel({ sessionId, modelId }) {
const res = await authFetch(`${baseUrl}/extraction-runs/session/${sessionId}/model/${modelId}`);
if (!res.ok) throw new Error(await safeText(res));
return res.json();
},
async getResultByQueueModel({ queueId, modelId }) {
const res = await authFetch(`${baseUrl}/extraction-runs/queue/${queueId}/model/${modelId}`);
if (!res.ok) throw new Error(await safeText(res));
return res.json();
},
};
}

View File

@ -0,0 +1,34 @@
import React from 'react';
import { RefreshCw } from 'lucide-react';
export function AppHeader({ isConnected, onRefresh }) {
return (
<header className="h-16 shrink-0 bg-app-bg border-b border-app-border flex items-center justify-between px-6 z-20">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-primary/10 border border-primary/20 flex items-center justify-center">
<span className="text-primary font-black">V</span>
</div>
<h1 className="text-xl font-bold tracking-tight text-white">Voyager Scrapper</h1>
</div>
<div className="flex items-center gap-4">
<div className={`flex items-center gap-2 px-3 py-1.5 border rounded text-xs font-medium ${
isConnected
? 'bg-green-900/20 border-green-800 text-green-400'
: 'bg-red-900/20 border-red-800 text-red-400'
}`}>
<span className="text-[16px]"></span>
{isConnected ? 'Conectado' : 'Desconectado'}
</div>
<button
onClick={onRefresh}
className="flex items-center gap-2 px-3 py-1.5 bg-app-surface border border-app-border hover:border-primary/50 text-text-muted hover:text-white rounded transition-colors text-sm"
>
<RefreshCw size={16} />
Atualizar
</button>
</div>
</header>
);
}

View File

@ -0,0 +1,26 @@
import React from 'react';
export function JsonModal({ title = 'JSON', value, onClose }) {
if (!value) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="w-full max-w-4xl max-h-[85vh] bg-app-card border border-app-border rounded-2xl shadow-xl overflow-hidden flex flex-col">
<div className="px-4 py-3 border-b border-app-border flex items-center justify-between">
<h3 className="font-bold text-white">{title}</h3>
<button
onClick={onClose}
className="text-text-muted hover:text-white px-2 py-1 rounded hover:bg-app-surface"
>
Fechar
</button>
</div>
<div className="p-4 overflow-auto">
<pre className="text-xs bg-app-bg border border-app-border rounded-lg p-3 overflow-auto">
{JSON.stringify(value, null, 2)}
</pre>
</div>
</div>
</div>
);
}

60
src/components/Sidebar.js Normal file
View File

@ -0,0 +1,60 @@
import React from 'react';
import { Grid, Database, Terminal, Calendar, Globe } from 'lucide-react';
function NavItem({ active, icon: Icon, label, onClick }) {
return (
<button
onClick={onClick}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors group ${
active
? 'bg-primary/10 text-primary border border-primary/20'
: 'text-text-muted hover:bg-app-card hover:text-white'
}`}
>
<Icon size={18} className={active ? 'text-primary' : 'text-text-muted group-hover:text-white'} />
<span className={`text-sm ${active ? 'font-bold' : 'font-medium'}`}>{label}</span>
</button>
);
}
export function Sidebar({ activePage, setActivePage }) {
return (
<aside className="w-64 bg-app-surface border-r border-app-border flex flex-col shrink-0 hidden md:flex">
<div className="p-4">
<p className="px-2 text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">Gerenciamento</p>
<nav className="space-y-1">
<NavItem
active={activePage === 'dashboard'}
icon={Grid}
label="Dashboard"
onClick={() => setActivePage('dashboard')}
/>
<NavItem
active={activePage === 'scraper'}
icon={Globe}
label="Scraper"
onClick={() => setActivePage('scraper')}
/>
<NavItem
active={activePage === 'extractors'}
icon={Database}
label="Extratores"
onClick={() => setActivePage('extractors')}
/>
<NavItem
active={activePage === 'schedules'}
icon={Calendar}
label="Agendamentos"
onClick={() => setActivePage('schedules')}
/>
<NavItem
active={activePage === 'logs'}
icon={Terminal}
label="Logs"
onClick={() => setActivePage('logs')}
/>
</nav>
</div>
</aside>
);
}

1
src/config.js Normal file
View File

@ -0,0 +1 @@
export const API_BASE = process.env.REACT_APP_API_BASE ?? 'http://localhost:5000';

25
src/context/LogContext.js Normal file
View File

@ -0,0 +1,25 @@
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
const LogContext = createContext(null);
export function LogProvider({ children }) {
const [logs, setLogs] = useState([]);
const addLog = useCallback((message, type = 'info') => {
setLogs(prev => {
const next = [...prev, { message, type, time: new Date().toLocaleTimeString() }];
return next.slice(-200);
});
}, []);
const clearLogs = useCallback(() => setLogs([]), []);
const value = useMemo(() => ({ logs, addLog, clearLogs }), [logs, addLog, clearLogs]);
return <LogContext.Provider value={value}>{children}</LogContext.Provider>;
}
export function useLogs() {
const ctx = useContext(LogContext);
if (!ctx) throw new Error('useLogs must be used within LogProvider');
return ctx;
}

View File

@ -0,0 +1,27 @@
import { useCallback, useEffect, useState } from 'react';
export function useExtractionModels(extractionApi) {
const [models, setModels] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const refresh = useCallback(async () => {
if (!extractionApi) return;
setLoading(true);
setError(null);
try {
const data = await extractionApi.listModels();
setModels(Array.isArray(data) ? data : []);
} catch (e) {
setError(e?.message ?? 'Erro ao carregar modelos');
} finally {
setLoading(false);
}
}, [extractionApi]);
useEffect(() => {
refresh();
}, [refresh]);
return { models, loading, error, refresh };
}

View File

@ -0,0 +1,23 @@
import { useCallback, useState } from 'react';
export function useExtractionResults(extractionApi) {
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const load = useCallback(async ({ sessionId, modelId }) => {
if (!extractionApi) return;
setLoading(true);
setError(null);
try {
const data = await extractionApi.listResultsBySessionModel({ sessionId, modelId });
setRows(Array.isArray(data) ? data : []);
} catch (e) {
setError(e?.message ?? 'Erro ao carregar resultados');
} finally {
setLoading(false);
}
}, [extractionApi]);
return { rows, loading, error, load };
}

View File

@ -0,0 +1,54 @@
import { useEffect, useRef, useState } from 'react';
export function useExtractionRunPolling(extractionApi, runId, { intervalMs = 1500 } = {}) {
const [run, setRun] = useState(null);
const [error, setError] = useState(null);
const [isPolling, setIsPolling] = useState(false);
const timerRef = useRef(null);
useEffect(() => {
if (!extractionApi || !runId) {
setRun(null);
setError(null);
setIsPolling(false);
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
return;
}
let cancelled = false;
async function tick() {
try {
const data = await extractionApi.getRun(runId);
if (cancelled) return;
setRun(data);
setError(null);
const status = data?.status;
const shouldPoll = status === 'queued' || status === 'running';
setIsPolling(shouldPoll);
if (!shouldPoll && timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
} catch (e) {
if (!cancelled) setError(e?.message ?? 'Erro ao consultar run');
}
}
tick();
timerRef.current = setInterval(tick, intervalMs);
return () => {
cancelled = true;
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = null;
};
}, [extractionApi, runId, intervalMs]);
return { run, error, isPolling };
}

28
src/hooks/useSessions.js Normal file
View File

@ -0,0 +1,28 @@
import { useCallback, useEffect, useState } from 'react';
import { authFetch } from '../auth/authFetch';
import { API_BASE } from '../config';
export function useSessions() {
const [sessions, setSessions] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await authFetch(`${API_BASE}/sessions`);
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
setSessions(Array.isArray(data) ? data : []);
} catch (e) {
setError(e?.message ?? 'Erro ao carregar sessões');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { refresh(); }, [refresh]);
return { sessions, loading, error, refresh, setSessions };
}

View File

@ -1,3 +1,12 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}
body {
@apply bg-app-bg text-text-main;
}
}

View File

@ -3,12 +3,15 @@ import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import AuthBootstrap from './AuthBootstrap';
import { LogProvider } from './context/LogContext';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
// <React.StrictMode>
<AuthBootstrap>
<LogProvider>
<App />
</LogProvider>
</AuthBootstrap>
// </React.StrictMode>
);

View File

@ -0,0 +1,30 @@
import React from 'react';
export function DashboardPage() {
return (
<div className="max-w-6xl mx-auto space-y-4">
<div>
<h2 className="text-2xl md:text-3xl font-bold text-white tracking-tight">Dashboard</h2>
<p className="text-text-muted text-sm mt-1">Visão geral do scraping e extrações.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-app-card border border-app-border rounded-2xl p-4">
<p className="text-xs text-text-muted uppercase tracking-wider font-semibold">Atalhos</p>
<ul className="mt-3 space-y-2 text-sm text-text-muted">
<li> Gerencie filas e sessões em <span className="text-white">Scraper</span></li>
<li> Crie modelos e rode extrações em <span className="text-white">Extratores</span></li>
</ul>
</div>
<div className="bg-app-card border border-app-border rounded-2xl p-4">
<p className="text-xs text-text-muted uppercase tracking-wider font-semibold">Status</p>
<p className="mt-3 text-sm text-text-muted">Em breve: métricas agregadas (runs, sucesso, falhas).</p>
</div>
<div className="bg-app-card border border-app-border rounded-2xl p-4">
<p className="text-xs text-text-muted uppercase tracking-wider font-semibold">Dica</p>
<p className="mt-3 text-sm text-text-muted">Configure <code className="text-white">REACT_APP_API_BASE</code> para apontar para sua API.</p>
</div>
</div>
</div>
);
}

515
src/pages/ExtractorsPage.js Normal file
View File

@ -0,0 +1,515 @@
import React, { useMemo, useState } from 'react';
import { Database, Play, RefreshCw, Plus, History } from 'lucide-react';
import { createExtractionApi } from '../api/extractionApi';
import { useExtractionModels } from '../hooks/useExtractionModels';
import { useExtractionRunPolling } from '../hooks/useExtractionRunPolling';
import { useExtractionResults } from '../hooks/useExtractionResults';
import { JsonModal } from '../components/JsonModal';
import { API_BASE } from '../config';
import { useSessions } from '../hooks/useSessions';
import { useLogs } from '../context/LogContext';
function TabButton({ active, onClick, children }) {
return (
<button
onClick={onClick}
className={`px-3 py-2 text-sm rounded-lg border transition-colors ${
active
? 'bg-primary/10 text-primary border-primary/20'
: 'bg-app-surface text-text-muted border-app-border hover:text-white hover:border-primary/40'
}`}
>
{children}
</button>
);
}
function StatusPill({ status }) {
const map = {
queued: 'bg-yellow-900/20 border-yellow-800 text-yellow-300',
running: 'bg-blue-900/20 border-blue-800 text-blue-300',
done: 'bg-green-900/20 border-green-800 text-green-300',
failed: 'bg-red-900/20 border-red-800 text-red-300',
};
const cls = map[status] ?? 'bg-app-bg border-app-border text-text-muted';
return (
<span className={`inline-flex items-center px-2 py-0.5 text-xs border rounded ${cls}`}> {status ?? '—'} </span>
);
}
export function ExtractorsPage() {
const { addLog } = useLogs();
const { sessions, loading: sessionsLoading, error: sessionsError, refresh: refreshSessions } = useSessions();
const extractionApi = useMemo(() => createExtractionApi(API_BASE), []);
const { models, loading: modelsLoading, error: modelsError, refresh: refreshModels } = useExtractionModels(extractionApi);
const [tab, setTab] = useState('models'); // models | run | results
const [selectedModelId, setSelectedModelId] = useState(null);
const [jsonModal, setJsonModal] = useState(null);
const [createdRunIds, setCreatedRunIds] = useState([]);
const [activeRunId, setActiveRunId] = useState(null);
const [onlyDone, setOnlyDone] = useState(true);
const [modelDraft, setModelDraft] = useState(defaultModelDraft());
const { run: activeRun, error: runError, isPolling } = useExtractionRunPolling(extractionApi, activeRunId);
const { rows: results, loading: resultsLoading, error: resultsError, load: loadResults } = useExtractionResults(extractionApi);
const [selectedSessionId, setSelectedSessionId] = useState(null);
const selectedSession = sessions.find(s => s.sessionId === selectedSessionId) ?? null;
async function openModel(id) {
try {
const data = await extractionApi.getModel(id);
setJsonModal({ title: `Model ${id} — definition`, value: data.definition });
} catch (e) {
addLog(`Erro ao abrir model ${id}: ${e?.message ?? e}`, 'error');
}
}
async function createModel() {
try {
const res = await extractionApi.createModel(modelDraft);
addLog(`Modelo criado: ${res?.id ?? '(sem id)'}`, 'success');
setModelDraft(defaultModelDraft());
refreshModels();
setTab('models');
} catch (e) {
addLog(`Erro ao criar modelo: ${e?.message ?? e}`, 'error');
}
}
async function startBulk(sessionIds) {
if (!selectedModelId) {
addLog('Selecione um modelId primeiro.', 'warning');
return;
}
try {
const res = await extractionApi.bulkRun({ modelId: Number(selectedModelId), onlyDone, sessionIds });
setCreatedRunIds(res?.runIds ?? []);
const first = res?.runIds?.[0] ?? null;
if (first) setActiveRunId(first);
setTab('run');
addLog(`Bulk iniciado. Runs: ${res?.count ?? (res?.runIds?.length ?? 0)}`, 'success');
} catch (e) {
addLog(`Erro ao iniciar bulk: ${e?.message ?? e}`, 'error');
}
}
async function startSingle() {
if (!selectedModelId || !selectedSessionId) {
addLog('Selecione session e model antes.', 'warning');
return;
}
try {
const res = await extractionApi.startRun({ sessionId: Number(selectedSessionId), modelId: Number(selectedModelId), onlyDone });
setActiveRunId(res?.runId ?? null);
setCreatedRunIds(res?.runId ? [res.runId] : []);
setTab('run');
addLog(`Run iniciado: ${res?.runId ?? '(sem id)'}`, 'success');
} catch (e) {
addLog(`Erro ao iniciar run: ${e?.message ?? e}`, 'error');
}
}
async function loadResultsForSelected() {
if (!selectedModelId || !selectedSessionId) {
addLog('Selecione session e model antes.', 'warning');
return;
}
await loadResults({ sessionId: Number(selectedSessionId), modelId: Number(selectedModelId) });
}
return (
<div className="max-w-7xl mx-auto space-y-6 h-full flex flex-col pb-8">
<JsonModal title={jsonModal?.title} value={jsonModal?.value} onClose={() => setJsonModal(null)} />
<div className="flex flex-col gap-1 shrink-0">
<div className="flex items-end justify-between gap-4">
<div>
<h2 className="text-2xl md:text-3xl font-bold text-white tracking-tight flex items-center gap-2">
<Database className="text-primary" /> Extratores
</h2>
<p className="text-text-muted text-sm mt-1">Modelos, runs e resultados de extração.</p>
</div>
<div className="flex gap-3">
<button
onClick={() => refreshModels()}
className="flex items-center gap-2 px-4 py-2 bg-app-card border border-app-border rounded-lg text-sm font-medium text-text-muted hover:text-white hover:border-primary/50 transition-all"
>
<RefreshCw size={16} /> Atualizar
</button>
<button
onClick={() => setTab('create')}
className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-semibold shadow-lg shadow-blue-500/20 transition-all active:scale-95"
>
<Plus size={16} /> Novo Modelo
</button>
</div>
</div>
<div className="mt-4 flex items-center gap-2">
<TabButton active={tab === 'models'} onClick={() => setTab('models')}>Modelos</TabButton>
<TabButton active={tab === 'run'} onClick={() => setTab('run')}>Executar</TabButton>
<TabButton active={tab === 'results'} onClick={() => setTab('results')}>Resultados</TabButton>
{tab === 'create' && (
<span className="text-xs text-text-muted ml-2">Criar modelo</span>
)}
</div>
</div>
<div className="bg-app-card border border-app-border rounded-2xl shadow-xl overflow-hidden flex-1 flex flex-col">
<div className="px-6 py-4 border-b border-app-border flex flex-col md:flex-row md:items-center md:justify-between gap-4 bg-app-surface/50">
<div>
<h3 className="font-bold text-white text-lg">Configuração</h3>
<p className="text-xs text-text-muted mt-0.5">Escolha session/model e rode a extração.</p>
</div>
<div className="flex flex-col md:flex-row gap-3">
<select
value={selectedSessionId ?? ''}
onChange={e => setSelectedSessionId(e.target.value ? Number(e.target.value) : null)}
className="rounded bg-app-bg border border-app-border text-white text-sm px-3 py-2"
>
<option value="">(Selecione uma sessão)</option>
{sessions.map(s => (
<option key={s.sessionId} value={s.sessionId}>{s.name ?? `Session ${s.sessionId}`}</option>
))}
</select>
<select
value={selectedModelId ?? ''}
onChange={e => setSelectedModelId(e.target.value ? Number(e.target.value) : null)}
className="rounded bg-app-bg border border-app-border text-white text-sm px-3 py-2"
>
<option value="">(Selecione um modelo)</option>
{models.map(m => (
<option key={m.id} value={m.id}>{m.name} v{m.version}</option>
))}
</select>
<label className="flex items-center gap-2 text-sm text-text-muted px-3 py-2 bg-app-bg border border-app-border rounded">
<input
type="checkbox"
checked={onlyDone}
onChange={e => setOnlyDone(e.target.checked)}
/>
onlyDone
</label>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto">
{tab === 'create' ? (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h4 className="text-white font-semibold">Novo modelo</h4>
<p className="text-xs text-text-muted">Cole o JSON do seu definition e ajuste metadados.</p>
</div>
<button
onClick={createModel}
className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-semibold shadow-lg shadow-blue-500/20 transition-all active:scale-95"
>
<Plus size={16} /> Criar
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input label="Name" value={modelDraft.name} onChange={v => setModelDraft(d => ({ ...d, name: v }))} />
<Input label="Version" type="number" value={modelDraft.version} onChange={v => setModelDraft(d => ({ ...d, version: Number(v) }))} />
</div>
<Input label="Description" value={modelDraft.description} onChange={v => setModelDraft(d => ({ ...d, description: v }))} />
<div>
<label className="text-xs font-semibold text-text-muted uppercase tracking-wider">Definition (JSON)</label>
<textarea
className="mt-2 w-full min-h-[280px] rounded bg-app-bg border border-app-border text-white text-xs font-mono p-3"
value={JSON.stringify(modelDraft.definition, null, 2)}
onChange={e => {
try {
const parsed = JSON.parse(e.target.value);
setModelDraft(d => ({ ...d, definition: parsed }));
} catch {
// keep typing - ignore parse errors
}
}}
/>
<p className="text-[11px] text-text-muted mt-2">Dica: você pode colar um JSON completo aqui.</p>
</div>
</div>
) : null}
{tab === 'models' ? (
<div className="p-6">
{modelsLoading ? <div className="text-sm text-text-muted">Carregando modelos</div> : null}
{modelsError ? <div className="text-sm text-red-400">{modelsError}</div> : null}
<div className="overflow-x-auto border border-app-border rounded-2xl">
<table className="w-full text-sm">
<thead className="bg-app-surface/60 text-text-muted">
<tr>
<th className="text-left px-4 py-3">ID</th>
<th className="text-left px-4 py-3">Name</th>
<th className="text-left px-4 py-3">Version</th>
<th className="text-left px-4 py-3">Updated</th>
<th className="text-left px-4 py-3">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-app-border">
{models.length === 0 ? (
<tr><td colSpan={5} className="px-4 py-6 text-text-muted">Nenhum modelo encontrado.</td></tr>
) : models.map(m => (
<tr key={m.id} className="hover:bg-app-surface/30">
<td className="px-4 py-3 text-white">{m.id}</td>
<td className="px-4 py-3 text-white">{m.name}</td>
<td className="px-4 py-3 text-text-muted">{m.version}</td>
<td className="px-4 py-3 text-text-muted">{m.updatedAt ? new Date(m.updatedAt).toLocaleString() : '—'}</td>
<td className="px-4 py-3">
<div className="flex gap-2">
<button
onClick={() => { setSelectedModelId(m.id); setTab('run'); }}
className="px-3 py-1.5 text-xs rounded bg-primary/10 text-primary border border-primary/20 hover:bg-primary/20"
>
Usar
</button>
<button
onClick={() => openModel(m.id)}
className="px-3 py-1.5 text-xs rounded bg-app-bg text-text-muted border border-app-border hover:text-white hover:border-primary/40"
>
Ver JSON
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null}
{tab === 'run' ? (
<div className="p-6 space-y-6">
<div className="flex flex-col md:flex-row gap-3">
<button
onClick={startSingle}
className="flex items-center justify-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-semibold shadow-lg shadow-blue-500/20 transition-all active:scale-95"
>
<Play size={16} /> Run (sessão selecionada)
</button>
<button
onClick={() => startBulk(sessions.map(s => s.sessionId))}
className="flex items-center justify-center gap-2 px-4 py-2 bg-app-card border border-app-border rounded-lg text-sm font-medium text-text-muted hover:text-white hover:border-primary/50 transition-all"
>
<Play size={16} /> Bulk (todas)
</button>
<button
onClick={() => {
const ids = selectedSessionId ? [Number(selectedSessionId)] : [];
startBulk(ids);
}}
className="flex items-center justify-center gap-2 px-4 py-2 bg-app-card border border-app-border rounded-lg text-sm font-medium text-text-muted hover:text-white hover:border-primary/50 transition-all"
>
<Play size={16} /> Bulk (selecionada)
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="bg-app-bg border border-app-border rounded-2xl p-4">
<div className="flex items-center gap-2 text-white font-semibold">
<History size={16} className="text-primary" /> Runs criados
</div>
<div className="mt-3 space-y-2">
{createdRunIds.length === 0 ? (
<div className="text-sm text-text-muted">Nenhum run ainda.</div>
) : createdRunIds.map(id => (
<button
key={id}
onClick={() => setActiveRunId(id)}
className={`w-full text-left px-3 py-2 rounded-lg border text-sm transition-colors ${
activeRunId === id
? 'bg-primary/10 text-primary border-primary/20'
: 'bg-app-surface text-text-muted border-app-border hover:text-white hover:border-primary/40'
}`}
>
Run #{id}
</button>
))}
</div>
</div>
<div className="lg:col-span-2 bg-app-bg border border-app-border rounded-2xl p-4">
<div className="flex items-center justify-between">
<div>
<div className="text-white font-semibold">Acompanhamento</div>
<div className="text-xs text-text-muted mt-1">
{activeRunId ? (
<span>Run #{activeRunId} {isPolling ? '(polling)' : ''}</span>
) : 'Selecione um run para acompanhar.'}
</div>
</div>
<StatusPill status={activeRun?.status} />
</div>
{runError ? <div className="mt-3 text-sm text-red-400">{runError}</div> : null}
{activeRun ? (
<div className="mt-4 space-y-3">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Stat label="Total" value={activeRun.total} />
<Stat label="Succeeded" value={activeRun.succeeded} />
<Stat label="Failed" value={activeRun.failed} />
<Stat label="Session" value={activeRun.sessionId} />
</div>
<div>
<div className="flex justify-between text-xs text-text-muted mb-1">
<span>Progresso</span>
<span>{activeRun.succeeded + activeRun.failed}/{activeRun.total}</span>
</div>
<div className="w-full bg-app-surface rounded-full h-2 overflow-hidden border border-app-border">
<div
className="h-2 bg-primary"
style={{
width: activeRun.total ? `${Math.min(100, ((activeRun.succeeded + activeRun.failed) / activeRun.total) * 100)}%` : '0%'
}}
/>
</div>
</div>
{activeRun.error ? (
<div className="text-sm text-red-400">{String(activeRun.error)}</div>
) : null}
</div>
) : (
<div className="mt-4 text-sm text-text-muted">Nenhum run selecionado.</div>
)}
</div>
</div>
</div>
) : null}
{tab === 'results' ? (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="text-white font-semibold">Resultados</h4>
<p className="text-xs text-text-muted">Carrega resultados por session + model.</p>
</div>
<button
onClick={loadResultsForSelected}
className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-semibold shadow-lg shadow-blue-500/20 transition-all active:scale-95"
>
<RefreshCw size={16} /> Carregar
</button>
</div>
{resultsLoading ? <div className="text-sm text-text-muted">Carregando</div> : null}
{resultsError ? <div className="text-sm text-red-400">{resultsError}</div> : null}
<div className="overflow-x-auto border border-app-border rounded-2xl">
<table className="w-full text-sm">
<thead className="bg-app-surface/60 text-text-muted">
<tr>
<th className="text-left px-4 py-3">Queue</th>
<th className="text-left px-4 py-3">Success</th>
<th className="text-left px-4 py-3">ExtractedAt</th>
<th className="text-left px-4 py-3">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-app-border">
{results.length === 0 ? (
<tr><td colSpan={4} className="px-4 py-6 text-text-muted">Nenhum resultado carregado.</td></tr>
) : results.map(r => (
<tr key={`${r.queueId}-${r.modelId}`} className="hover:bg-app-surface/30">
<td className="px-4 py-3 text-white">{r.queueId}</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded border ${r.success ? 'bg-green-900/20 border-green-800 text-green-300' : 'bg-red-900/20 border-red-800 text-red-300'}`}>
{r.success ? 'true' : 'false'}
</span>
</td>
<td className="px-4 py-3 text-text-muted">{r.extractedAt ? new Date(r.extractedAt).toLocaleString() : '—'}</td>
<td className="px-4 py-3">
<button
onClick={() => setJsonModal({ title: `Queue ${r.queueId} — extractedJson`, value: r.extractedJson })}
className="px-3 py-1.5 text-xs rounded bg-app-bg text-text-muted border border-app-border hover:text-white hover:border-primary/40"
>
Ver JSON
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null}
</div>
</div>
</div>
);
}
function Stat({ label, value }) {
return (
<div className="bg-app-surface border border-app-border rounded-xl p-3">
<div className="text-xs text-text-muted">{label}</div>
<div className="text-lg text-white font-bold mt-1">{value ?? '—'}</div>
</div>
);
}
function Input({ label, value, onChange, type = 'text' }) {
return (
<div className="space-y-1.5">
<label className="text-xs font-semibold text-text-muted uppercase tracking-wider">{label}</label>
<input
type={type}
value={value}
onChange={e => onChange(e.target.value)}
className="w-full rounded bg-app-bg border border-app-border text-white text-sm px-3 py-2 focus:ring-1 focus:ring-primary focus:border-primary"
/>
</div>
);
}
function defaultModelDraft() {
return {
name: 'ProductPageV1',
version: 1,
description: 'Extrai dados de produto',
definition: {
rootSelector: 'main',
fields: [
{
key: 'title',
type: 'string',
selector: 'h1.product-title',
source: { kind: 'text' },
transforms: ['trim'],
},
{
key: 'price',
type: 'number',
selector: '.price',
source: { kind: 'text' },
transforms: ['trim', 'number:pt-BR'],
},
{
key: 'items',
type: 'array',
selector: 'div.titulo',
items: {
type: 'object',
fields: [
{ key: 'href', type: 'string', selector: 'a', source: { kind: 'attr', name: 'href' } },
{ key: 'text', type: 'string', selector: 'a', source: { kind: 'text' }, transforms: ['trim'] },
],
},
},
],
},
};
}

54
src/pages/LogsPage.js Normal file
View File

@ -0,0 +1,54 @@
import React from 'react';
import { CircleCheck, CircleX, Activity, Clock, Trash2 } from 'lucide-react';
import { useLogs } from '../context/LogContext';
function iconFor(type) {
switch (type) {
case 'success': return <CircleCheck size={16} className="text-green-400" />;
case 'error': return <CircleX size={16} className="text-red-400" />;
case 'warning': return <Activity size={16} className="text-yellow-400" />;
default: return <Clock size={16} className="text-text-muted" />;
}
}
export function LogsPage() {
const { logs, clearLogs } = useLogs();
return (
<div className="max-w-6xl mx-auto space-y-4">
<div className="flex items-end justify-between gap-4">
<div>
<h2 className="text-2xl md:text-3xl font-bold text-white tracking-tight">Logs</h2>
<p className="text-text-muted text-sm mt-1">Eventos recebidos via SignalR e ações do usuário.</p>
</div>
<button
onClick={clearLogs}
className="flex items-center gap-2 px-3 py-2 bg-app-surface border border-app-border hover:border-red-400/40 text-text-muted hover:text-white rounded-lg transition-colors text-sm"
>
<Trash2 size={16} />
Limpar
</button>
</div>
<div className="bg-app-card border border-app-border rounded-2xl overflow-hidden">
<div className="max-h-[70vh] overflow-y-auto">
{logs.length === 0 ? (
<div className="p-6 text-sm text-text-muted">Nenhum log ainda.</div>
) : (
<ul className="divide-y divide-app-border">
{logs.slice().reverse().map((l, idx) => (
<li key={idx} className="px-4 py-3 flex items-start gap-3">
<div className="mt-0.5">{iconFor(l.type)}</div>
<div className="flex-1">
<div className="text-sm text-white">{l.message}</div>
<div className="text-xs text-text-muted mt-1">{l.time}</div>
</div>
</li>
))}
</ul>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,16 @@
import React from 'react';
export function SchedulesPage() {
return (
<div className="max-w-6xl mx-auto space-y-4">
<div>
<h2 className="text-2xl md:text-3xl font-bold text-white tracking-tight">Agendamentos</h2>
<p className="text-text-muted text-sm mt-1">(placeholder) Em breve: agendar sessões/extrações.</p>
</div>
<div className="bg-app-card border border-app-border rounded-2xl p-6 text-sm text-text-muted">
Quando você tiver endpoints de agendamento, essa tela vira uma UI de cronjobs e regras.
</div>
</div>
);
}

416
src/pages/ScraperPage.js Normal file
View File

@ -0,0 +1,416 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Database, Play, Pause, CircleX, CircleCheck, Clock, Download } from 'lucide-react';
import * as signalR from '@microsoft/signalr';
import { authFetch } from '../auth/authFetch';
import { getAccessToken } from '../auth/userManager';
import { API_BASE } from '../config';
import { useLogs } from '../context/LogContext';
// NOTE: This page keeps the original Session/Queue UI and SignalR integration.
// It is intentionally isolated from App.js to avoid a monolithic root component.
export function ScraperPage({ onConnectionChanged }) {
const { addLog } = useLogs();
const [sessions, setSessions] = useState([]);
const [selectedSessionId, setSelectedSessionId] = useState(null);
const selectedSession = useMemo(() => sessions.find(s => s.sessionId === selectedSessionId) ?? null, [sessions, selectedSessionId]);
const [sessionStatus, setSessionStatus] = useState(null);
const [newUrl, setNewUrl] = useState('');
const [isConnected, setIsConnected] = useState(false);
const connectionRef = useRef(null);
useEffect(() => {
onConnectionChanged?.(isConnected);
}, [isConnected, onConnectionChanged]);
const ScrapeEventType = {
0: 'SessionStarted',
1: 'SessionStopRequested',
2: 'SessionStopped',
3: 'ItemStarted',
4: 'ItemSucceeded',
5: 'ItemFailed',
6: 'Progress',
};
const normalizeEventType = (ev) => {
if (typeof ev.type === 'number') return { ...ev, type: ScrapeEventType[ev.type] ?? ev.type };
return ev;
};
const upsertSessionFromEvent = (sid, patch) => {
setSessions(prev => {
const idx = prev.findIndex(s => s.sessionId === sid);
if (idx === -1) return prev;
const copy = [...prev];
copy[idx] = { ...copy[idx], ...patch };
return copy;
});
};
const handleScrapeEvent = (event) => {
const sid = event.sessionId;
switch (event.type) {
case 'SessionStarted':
upsertSessionFromEvent(sid, { isRunning: true });
addLog('🚀 Sessão iniciada', 'success');
break;
case 'SessionStopped':
upsertSessionFromEvent(sid, { isRunning: false });
addLog('⏹️ Sessão parada', 'info');
break;
case 'ItemStarted':
addLog(`📄 Processando: ${event.url}`, 'info');
setSessionStatus(prev => {
if (!prev || prev.runtime?.sessionId !== sid) return prev;
return {
...prev,
runtime: {
...prev.runtime,
isRunning: true,
currentUrl: event.url,
currentQueueId: event.queueId ?? prev.runtime?.currentQueueId,
currentStartedAt: event.at,
},
};
});
break;
case 'ItemSucceeded':
addLog(`✓ Sucesso: ${event.url}`, 'success');
break;
case 'ItemFailed':
addLog(`✗ Falha: ${event.url} - ${event.error}`, 'error');
break;
case 'Progress':
upsertSessionFromEvent(sid, {
queue: {
total: event.total,
pending: event.pending,
processing: event.processing,
done: event.done,
failed: event.failed,
},
});
if (selectedSession?.sessionId === sid) {
setSessionStatus(prev => ({
...(prev ?? {}),
counts: {
total: event.total,
pending: event.pending,
processing: event.processing,
done: event.done,
failed: event.failed,
},
}));
}
break;
default:
break;
}
};
const loadSessions = async () => {
try {
const response = await authFetch(`${API_BASE}/sessions`);
if (!response.ok) throw new Error(await response.text());
const data = await response.json();
setSessions(Array.isArray(data) ? data : []);
} catch (e) {
addLog(`Erro ao carregar sessões: ${e?.message ?? e}`, 'error');
}
};
const loadSessionStatus = async (sessionId) => {
try {
const response = await authFetch(`${API_BASE}/sessions/${sessionId}/scrap/status`);
if (!response.ok) throw new Error(await response.text());
const data = await response.json();
setSessionStatus(data);
} catch (e) {
addLog(`Erro ao carregar status: ${e?.message ?? e}`, 'error');
}
};
// initial load
useEffect(() => {
loadSessions();
}, []);
// SignalR connection
useEffect(() => {
const connection = new signalR.HubConnectionBuilder()
.withUrl(`${API_BASE}/ws/scrape`, {
accessTokenFactory: async () => (await getAccessToken()) ?? '',
})
.withAutomaticReconnect()
.build();
connection.on('scrapeEvent', (event) => {
handleScrapeEvent(normalizeEventType(event));
});
connection.onreconnecting(() => {
setIsConnected(false);
addLog('Reconectando ao servidor...', 'warning');
});
connection.onreconnected(async () => {
setIsConnected(true);
addLog('Reconectado ao servidor', 'success');
try {
await connection.invoke('SubscribeOverview');
await loadSessions();
if (selectedSession) {
await connection.invoke('Subscribe', selectedSession.sessionId);
await loadSessionStatus(selectedSession.sessionId);
}
} catch {
// ignore
}
});
connection.onclose(() => {
setIsConnected(false);
addLog('Desconectado do servidor', 'error');
});
connection.start()
.then(async () => {
setIsConnected(true);
addLog('Conectado ao servidor', 'success');
connectionRef.current = connection;
await connection.invoke('SubscribeOverview');
await loadSessions();
})
.catch(err => {
addLog(`Erro ao conectar: ${err.message}`, 'error');
});
return () => {
connection.stop();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// subscribe selected session
useEffect(() => {
if (connectionRef.current && selectedSession && isConnected) {
connectionRef.current.invoke('Subscribe', selectedSession.sessionId)
.then(() => {
addLog(`Inscrito na sessão: ${selectedSession.name}`, 'info');
})
.catch(err => {
addLog(`Erro ao inscrever: ${err.message}`, 'error');
});
return () => {
connectionRef.current?.invoke('Unsubscribe', selectedSession.sessionId).catch(() => {});
};
}
}, [selectedSession, isConnected, addLog]);
const selectSession = async (session) => {
setSelectedSessionId(session.sessionId);
await loadSessionStatus(session.sessionId);
};
const startScraping = async (sessionId) => {
try {
await authFetch(`${API_BASE}/sessions/${sessionId}/scrap/start`, { method: 'POST' });
addLog('Início solicitado', 'success');
await loadSessions();
await loadSessionStatus(sessionId);
} catch (e) {
addLog(`Erro ao iniciar: ${e?.message ?? e}`, 'error');
}
};
const stopScraping = async (sessionId) => {
try {
await authFetch(`${API_BASE}/sessions/${sessionId}/scrap/stop`, { method: 'POST' });
addLog('Parada solicitada', 'warning');
await loadSessions();
await loadSessionStatus(sessionId);
} catch (e) {
addLog(`Erro ao parar: ${e?.message ?? e}`, 'error');
}
};
const addUrlToQueue = async () => {
if (!selectedSession) return;
if (!newUrl.trim()) return;
try {
const response = await authFetch(`${API_BASE}/sessions/${selectedSession.sessionId}/queue`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: newUrl.trim() }),
});
if (!response.ok) throw new Error(await response.text());
setNewUrl('');
addLog('URL adicionada na fila', 'success');
await loadSessions();
await loadSessionStatus(selectedSession.sessionId);
} catch (e) {
addLog(`Erro ao enfileirar URL: ${e?.message ?? e}`, 'error');
}
};
const exportSessionData = async () => {
if (!selectedSession) return;
// If your API has an export endpoint, wire here.
// Current UI keeps the button but logs the action.
addLog('Export: endpoint não implementado no UI (placeholder).', 'info');
};
const formatNumber = (num) => {
try {
return new Intl.NumberFormat('pt-BR').format(Number(num ?? 0));
} catch {
return String(num ?? 0);
}
};
return (
<div className="max-w-7xl mx-auto">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Sessions */}
<div className="bg-app-card border border-app-border rounded-2xl p-6 shadow-xl">
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<Database size={20} className="text-primary" />
Sessões
</h2>
<div className="space-y-2">
{sessions.map((session) => (
<div
key={session.sessionId}
onClick={() => selectSession(session)}
className={`p-4 rounded-xl cursor-pointer transition border ${selectedSession?.sessionId === session.sessionId
? 'bg-primary/10 border-primary/30'
: 'bg-app-surface border-app-border hover:border-primary/40'
}`}
>
<div className="flex justify-between items-center mb-2">
<span className="font-bold text-white">{session.name ?? `Session ${session.sessionId}`}</span>
<div className="flex items-center gap-2">
{session.isRunning && (
<span className="px-2 py-1 bg-green-900/20 border border-green-800 text-green-300 text-xs rounded">
Rodando
</span>
)}
<button
onClick={(e) => {
e.stopPropagation();
session.isRunning ? stopScraping(session.sessionId) : startScraping(session.sessionId);
}}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition border ${
session.isRunning
? 'bg-orange-900/20 border-orange-800 text-orange-300 hover:bg-orange-900/30'
: 'bg-primary/10 border-primary/30 text-primary hover:bg-primary/20'
}`}
title={session.isRunning ? 'Parar (Graceful)' : 'Iniciar Scraping'}
>
{session.isRunning ? <Pause size={14} /> : <Play size={14} />}
{session.isRunning ? 'Parar' : 'Iniciar'}
</button>
</div>
</div>
<div className="text-sm text-text-muted">
<div>Total: {formatNumber(session.queue?.total || 0)}</div>
<div className="flex gap-4 mt-1">
<span className="text-green-400 flex items-center"><CircleCheck size={16} />&nbsp;{formatNumber(session.queue?.done || 0)}</span>
<span className="text-yellow-400 flex items-center"><Clock size={16} />&nbsp;{formatNumber(session.queue?.pending || 0)}</span>
<span className="text-red-400 flex items-center"><CircleX size={16} />&nbsp;{formatNumber(session.queue?.failed || 0)}</span>
</div>
</div>
</div>
))}
{sessions.length === 0 && (
<div className="text-text-muted text-center py-8">
Nenhuma sessão disponível
</div>
)}
</div>
</div>
{/* Details */}
<div className="lg:col-span-2 space-y-6">
{selectedSession ? (
<>
<div className="bg-app-card border border-app-border rounded-2xl p-6 shadow-xl">
<h3 className="text-lg font-bold mb-4 text-white">Controles - {selectedSession.name}</h3>
<div className="flex gap-3 mb-4">
<input
type="text"
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
placeholder="https://site.com/page"
className="flex-1 px-4 py-2 bg-app-bg rounded-lg border border-app-border focus:border-primary focus:outline-none text-white"
onKeyDown={(e) => e.key === 'Enter' && addUrlToQueue()}
/>
<button
onClick={addUrlToQueue}
className="px-4 py-2 bg-primary hover:bg-primary-hover rounded-lg font-semibold text-white"
>
Adicionar
</button>
</div>
<div className="flex gap-3">
<button
onClick={() => startScraping(selectedSession.sessionId)}
className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover rounded-lg font-semibold text-white"
>
<Play size={16} /> Iniciar
</button>
<button
onClick={() => stopScraping(selectedSession.sessionId)}
className="flex items-center gap-2 px-4 py-2 bg-orange-900/20 border border-orange-800 text-orange-300 rounded-lg font-semibold hover:bg-orange-900/30"
>
<Pause size={16} /> Parar
</button>
<button
onClick={exportSessionData}
className="flex items-center gap-2 px-4 py-2 bg-app-surface border border-app-border text-text-muted rounded-lg hover:text-white hover:border-primary/40"
>
<Download size={16} /> Exportar
</button>
</div>
</div>
<div className="bg-app-card border border-app-border rounded-2xl p-6 shadow-xl">
<h3 className="text-lg font-bold mb-4 text-white">Status</h3>
{!sessionStatus ? (
<div className="text-text-muted text-sm">Carregando status</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<Metric title="Total" value={formatNumber(sessionStatus.counts?.total ?? 0)} />
<Metric title="Done" value={formatNumber(sessionStatus.counts?.done ?? 0)} valueClass="text-green-400" />
<Metric title="Pending" value={formatNumber(sessionStatus.counts?.pending ?? 0)} valueClass="text-yellow-400" />
<Metric title="Processing" value={formatNumber(sessionStatus.counts?.processing ?? 0)} valueClass="text-blue-400" />
<Metric title="Failed" value={formatNumber(sessionStatus.counts?.failed ?? 0)} valueClass="text-red-400" />
</div>
)}
</div>
</>
) : (
<div className="bg-app-card border border-app-border rounded-2xl p-6 text-sm text-text-muted">
Selecione uma sessão para ver controles e status.
</div>
)}
</div>
</div>
</div>
);
}
function Metric({ title, value, valueClass = 'text-white' }) {
return (
<div className="bg-app-bg border border-app-border rounded-xl p-4">
<div className="text-xs text-text-muted uppercase tracking-wider font-semibold">{title}</div>
<div className={`text-2xl font-bold mt-2 ${valueClass}`}>{value}</div>
</div>
);
}

View File

@ -3,7 +3,26 @@ module.exports = {
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {},
extend: {
colors: {
'app-bg': '#0B1120',
'app-surface': '#151e32',
'app-card': '#1E293B',
'app-border': '#2D3B55',
'primary': '#3B82F6',
'primary-hover': '#2563EB',
'text-main': '#F1F5F9',
'text-muted': '#94A3B8',
},
fontFamily: {
sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
},
borderRadius: {
lg: '0.5rem',
xl: '0.75rem',
'2xl': '1rem',
},
},
},
plugins: [],
}