diff --git a/public/index.html b/public/index.html index aa069f2..1886fb7 100644 --- a/public/index.html +++ b/public/index.html @@ -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`. --> - React App + Voyager diff --git a/src/App.js b/src/App.js index 11fb018..a1121ab 100644 --- a/src/App.js +++ b/src/App.js @@ -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 = () => ( -
-
- -

Web Scraper Pro

-
-
-
- - {isConnected ? 'Conectado' : 'Desconectado'} -
- -
-
- ); - - // Componente Sidebar - const Sidebar = () => ( - - ); + 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 ( -
-
- +
+ - {/* Conteúdo principal */} -
-
- {/* Breadcrumb e título da página ativa */} -
-
- Extratores - / - {activePage === 'scraper' ? 'Scraper' : activePage.charAt(0).toUpperCase() + activePage.slice(1)} -
-
-
-

- {activePage === 'scraper' ? 'Gerenciador de Sessões' : 'Em breve'} -

-

- {activePage === 'scraper' ? 'Gerencie e monitore suas sessões de scraping.' : 'Conteúdo a ser implementado.'} -

-
-
-
+
+ - {/* Render da página ativa */} - {activePage === 'scraper' ? ( -
-
- {/* Lista de Sessões */} -
-

- - Sessões -

-
- {sessions.map((session) => ( -
selectSession(session)} - className={`p-4 rounded-lg cursor-pointer transition ${selectedSession?.sessionId === session.sessionId - ? 'bg-blue-600' - : 'bg-slate-700 hover:bg-slate-600' - }`} - > -
- {session.name} -
- {session.isRunning && ( - - Rodando - - )} - -
-
-
-
Total: {formatNumber(session.queue?.total || 0)}
-
-  {formatNumber(session.queue?.done || 0)} -  {formatNumber(session.queue?.pending || 0)} -  {formatNumber(session.queue?.failed || 0)} -
-
-
- ))} - {sessions.length === 0 && ( -
- Nenhuma sessão disponível -
- )} -
-
- - {/* Detalhes e Controles */} -
- {selectedSession ? ( - <> - {/* Controles */} -
-

Controles - {selectedSession.name}

- -
- 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()} - /> - -
- -
- {!selectedSession.isRunning ? ( - - ) : ( - - )} -
-
- - {/* Estatísticas */} - {sessionStatus && ( - <> -
-

Progresso

- - {/* Barra de Progresso */} -
-
- Processado - {getProgressPercent()}% -
-
-
-
-
- -
-
-
Total
-
{formatNumber(sessionStatus.counts?.total)}
-
-
-
Concluído
-
{formatNumber(sessionStatus.counts?.done)}
-
-
-
Pendente
-
{formatNumber(sessionStatus.counts?.pending)}
-
-
-
Processando
-
{formatNumber(sessionStatus.counts?.processing)}
-
-
-
Falhas
-
{formatNumber(sessionStatus.counts?.failed)}
-
-
- - {/* URL Atual */} - {sessionStatus.runtime?.isRunning && sessionStatus.runtime?.currentUrl && ( -
-
Processando agora:
-
- {sessionStatus.runtime.currentUrl} -
-
- Iniciado: {new Date(sessionStatus.runtime.currentStartedAt).toLocaleTimeString('pt-BR')} -
-
- )} -
- - )} - - {/* Logs */} -
-

Logs em Tempo Real

-
- {logs.map((log, idx) => ( -
- [{log.time}] {log.message} -
- ))} - {logs.length === 0 && ( -
- Nenhum evento ainda. Selecione uma sessão e inicie o scraping. -
- )} -
-
- - ) : ( -
- -

Selecione uma Sessão

-

- Escolha uma sessão à esquerda para começar -

-
- )} -
-
-
- ) : ( -
- Conteúdo da página "{activePage}" em breve. -
- )} -
-
+
+ {activePage === 'dashboard' ? : null} + {activePage === 'scraper' ? : null} + {activePage === 'extractors' ? : null} + {activePage === 'schedules' ? : null} + {activePage === 'logs' ? : null} +
+
); -}; - -export default WebScraper; \ No newline at end of file +} diff --git a/src/api/extractionApi.js b/src/api/extractionApi.js new file mode 100644 index 0000000..f3a0c15 --- /dev/null +++ b/src/api/extractionApi.js @@ -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(); + }, + }; +} diff --git a/src/components/AppHeader.js b/src/components/AppHeader.js new file mode 100644 index 0000000..d9dfedd --- /dev/null +++ b/src/components/AppHeader.js @@ -0,0 +1,34 @@ +import React from 'react'; +import { RefreshCw } from 'lucide-react'; + +export function AppHeader({ isConnected, onRefresh }) { + return ( +
+
+
+ V +
+

Voyager Scrapper

+
+ +
+
+ + {isConnected ? 'Conectado' : 'Desconectado'} +
+ + +
+
+ ); +} diff --git a/src/components/JsonModal.js b/src/components/JsonModal.js new file mode 100644 index 0000000..b14e6d6 --- /dev/null +++ b/src/components/JsonModal.js @@ -0,0 +1,26 @@ +import React from 'react'; + +export function JsonModal({ title = 'JSON', value, onClose }) { + if (!value) return null; + + return ( +
+
+
+

{title}

+ +
+
+
+            {JSON.stringify(value, null, 2)}
+          
+
+
+
+ ); +} diff --git a/src/components/Sidebar.js b/src/components/Sidebar.js new file mode 100644 index 0000000..ed92c45 --- /dev/null +++ b/src/components/Sidebar.js @@ -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 ( + + ); +} + +export function Sidebar({ activePage, setActivePage }) { + return ( + + ); +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..a5ac1f8 --- /dev/null +++ b/src/config.js @@ -0,0 +1 @@ +export const API_BASE = process.env.REACT_APP_API_BASE ?? 'http://localhost:5000'; diff --git a/src/context/LogContext.js b/src/context/LogContext.js new file mode 100644 index 0000000..c78e65d --- /dev/null +++ b/src/context/LogContext.js @@ -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 {children}; +} + +export function useLogs() { + const ctx = useContext(LogContext); + if (!ctx) throw new Error('useLogs must be used within LogProvider'); + return ctx; +} diff --git a/src/hooks/useExtractionModels.js b/src/hooks/useExtractionModels.js new file mode 100644 index 0000000..3e00139 --- /dev/null +++ b/src/hooks/useExtractionModels.js @@ -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 }; +} diff --git a/src/hooks/useExtractionResults.js b/src/hooks/useExtractionResults.js new file mode 100644 index 0000000..d45a0d6 --- /dev/null +++ b/src/hooks/useExtractionResults.js @@ -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 }; +} diff --git a/src/hooks/useExtractionRunPolling.js b/src/hooks/useExtractionRunPolling.js new file mode 100644 index 0000000..5ecb3f3 --- /dev/null +++ b/src/hooks/useExtractionRunPolling.js @@ -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 }; +} diff --git a/src/hooks/useSessions.js b/src/hooks/useSessions.js new file mode 100644 index 0000000..12e77b6 --- /dev/null +++ b/src/hooks/useSessions.js @@ -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 }; +} diff --git a/src/index.css b/src/index.css index bd6213e..eb2a956 100644 --- a/src/index.css +++ b/src/index.css @@ -1,3 +1,12 @@ @tailwind base; @tailwind components; -@tailwind utilities; \ No newline at end of file +@tailwind utilities; + +@layer base { + html { + font-family: Inter, ui-sans-serif, system-ui, sans-serif; + } + body { + @apply bg-app-bg text-text-main; + } +} \ No newline at end of file diff --git a/src/index.js b/src/index.js index e591d76..6c56167 100644 --- a/src/index.js +++ b/src/index.js @@ -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( // - + + + // ); \ No newline at end of file diff --git a/src/pages/DashboardPage.js b/src/pages/DashboardPage.js new file mode 100644 index 0000000..58393f2 --- /dev/null +++ b/src/pages/DashboardPage.js @@ -0,0 +1,30 @@ +import React from 'react'; + +export function DashboardPage() { + return ( +
+
+

Dashboard

+

Visão geral do scraping e extrações.

+
+ +
+
+

Atalhos

+
    +
  • • Gerencie filas e sessões em Scraper
  • +
  • • Crie modelos e rode extrações em Extratores
  • +
+
+
+

Status

+

Em breve: métricas agregadas (runs, sucesso, falhas).

+
+
+

Dica

+

Configure REACT_APP_API_BASE para apontar para sua API.

+
+
+
+ ); +} diff --git a/src/pages/ExtractorsPage.js b/src/pages/ExtractorsPage.js new file mode 100644 index 0000000..4febd78 --- /dev/null +++ b/src/pages/ExtractorsPage.js @@ -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 ( + + ); +} + +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 ( + {status ?? '—'} + ); +} + +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 ( +
+ setJsonModal(null)} /> + +
+
+
+

+ Extratores +

+

Modelos, runs e resultados de extração.

+
+ +
+ + +
+
+ +
+ setTab('models')}>Modelos + setTab('run')}>Executar + setTab('results')}>Resultados + {tab === 'create' && ( + Criar modelo + )} +
+
+ +
+
+
+

Configuração

+

Escolha session/model e rode a extração.

+
+
+ + + + + +
+
+ + {/* Content */} +
+ {tab === 'create' ? ( +
+
+
+

Novo modelo

+

Cole o JSON do seu definition e ajuste metadados.

+
+ +
+ +
+ setModelDraft(d => ({ ...d, name: v }))} /> + setModelDraft(d => ({ ...d, version: Number(v) }))} /> +
+ setModelDraft(d => ({ ...d, description: v }))} /> + +
+ +