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`.
-->
-
-
-
+
+
- {/* 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
-
- )}
-
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 ? : }
- {session.isRunning ? 'Parar' : 'Iniciar'}
-
-
-
-
-
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()}
- />
-
-
- Adicionar
-
-
-
-
- {!selectedSession.isRunning ? (
-
-
- Iniciar Scraping
-
- ) : (
-
-
- Parar (Graceful)
-
- )}
-
-
-
- {/* 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 (
+
+ );
+}
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}
+
+ Fechar
+
+
+
+
+ {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 (
+
+
+ {label}
+
+ );
+}
+
+export function Sidebar({ activePage, setActivePage }) {
+ return (
+
+
+
Gerenciamento
+
+ setActivePage('dashboard')}
+ />
+ setActivePage('scraper')}
+ />
+ setActivePage('extractors')}
+ />
+ setActivePage('schedules')}
+ />
+ setActivePage('logs')}
+ />
+
+
+
+ );
+}
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 (
+
+ {children}
+
+ );
+}
+
+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.
+
+
+
+
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"
+ >
+ Atualizar
+
+
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"
+ >
+ Novo Modelo
+
+
+
+
+
+ setTab('models')}>Modelos
+ setTab('run')}>Executar
+ setTab('results')}>Resultados
+ {tab === 'create' && (
+ Criar modelo
+ )}
+
+
+
+
+
+
+
Configuração
+
Escolha session/model e rode a extração.
+
+
+ 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"
+ >
+ (Selecione uma sessão)
+ {sessions.map(s => (
+ {s.name ?? `Session ${s.sessionId}`}
+ ))}
+
+
+ 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"
+ >
+ (Selecione um modelo)
+ {models.map(m => (
+ {m.name} v{m.version}
+ ))}
+
+
+
+ setOnlyDone(e.target.checked)}
+ />
+ onlyDone
+
+
+
+
+ {/* Content */}
+
+ {tab === 'create' ? (
+
+
+
+
Novo modelo
+
Cole o JSON do seu definition e ajuste metadados.
+
+
+ Criar
+
+
+
+
+ setModelDraft(d => ({ ...d, name: v }))} />
+ setModelDraft(d => ({ ...d, version: Number(v) }))} />
+
+
setModelDraft(d => ({ ...d, description: v }))} />
+
+
+
+ ) : null}
+
+ {tab === 'models' ? (
+
+ {modelsLoading ?
Carregando modelos…
: null}
+ {modelsError ?
{modelsError}
: null}
+
+
+
+
+
+ ID
+ Name
+ Version
+ Updated
+ Ações
+
+
+
+ {models.length === 0 ? (
+ Nenhum modelo encontrado.
+ ) : models.map(m => (
+
+ {m.id}
+ {m.name}
+ {m.version}
+ {m.updatedAt ? new Date(m.updatedAt).toLocaleString() : '—'}
+
+
+ { 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
+
+ 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
+
+
+
+
+ ))}
+
+
+
+
+ ) : null}
+
+ {tab === 'run' ? (
+
+
+
+ Run (sessão selecionada)
+
+
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"
+ >
+ Bulk (todas)
+
+
{
+ 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"
+ >
+ Bulk (selecionada)
+
+
+
+
+
+
+ Runs criados
+
+
+ {createdRunIds.length === 0 ? (
+
Nenhum run ainda.
+ ) : createdRunIds.map(id => (
+
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}
+
+ ))}
+
+
+
+
+
+
+
Acompanhamento
+
+ {activeRunId ? (
+ Run #{activeRunId} {isPolling ? '(polling)' : ''}
+ ) : 'Selecione um run para acompanhar.'}
+
+
+
+
+
+ {runError ?
{runError}
: null}
+ {activeRun ? (
+
+
+
+
+
+
+
+
+
+
+ Progresso
+ {activeRun.succeeded + activeRun.failed}/{activeRun.total}
+
+
+
+
+ {activeRun.error ? (
+
{String(activeRun.error)}
+ ) : null}
+
+ ) : (
+
Nenhum run selecionado.
+ )}
+
+
+
+ ) : null}
+
+ {tab === 'results' ? (
+
+
+
+
Resultados
+
Carrega resultados por session + model.
+
+
+ Carregar
+
+
+
+ {resultsLoading ?
Carregando…
: null}
+ {resultsError ?
{resultsError}
: null}
+
+
+
+
+
+ Queue
+ Success
+ ExtractedAt
+ Ações
+
+
+
+ {results.length === 0 ? (
+ Nenhum resultado carregado.
+ ) : results.map(r => (
+
+ {r.queueId}
+
+
+ {r.success ? 'true' : 'false'}
+
+
+ {r.extractedAt ? new Date(r.extractedAt).toLocaleString() : '—'}
+
+ 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
+
+
+
+ ))}
+
+
+
+
+ ) : null}
+
+
+
+ );
+}
+
+function Stat({ label, value }) {
+ return (
+
+
{label}
+
{value ?? '—'}
+
+ );
+}
+
+function Input({ label, value, onChange, type = 'text' }) {
+ return (
+
+ {label}
+ 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"
+ />
+
+ );
+}
+
+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'] },
+ ],
+ },
+ },
+ ],
+ },
+ };
+}
diff --git a/src/pages/LogsPage.js b/src/pages/LogsPage.js
new file mode 100644
index 0000000..21ab343
--- /dev/null
+++ b/src/pages/LogsPage.js
@@ -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
;
+ case 'error': return
;
+ case 'warning': return
;
+ default: return
;
+ }
+}
+
+export function LogsPage() {
+ const { logs, clearLogs } = useLogs();
+
+ return (
+
+
+
+
Logs
+
Eventos recebidos via SignalR e ações do usuário.
+
+
+
+ Limpar
+
+
+
+
+
+ {logs.length === 0 ? (
+
Nenhum log ainda.
+ ) : (
+
+ {logs.slice().reverse().map((l, idx) => (
+
+ {iconFor(l.type)}
+
+
{l.message}
+
{l.time}
+
+
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/pages/SchedulesPage.js b/src/pages/SchedulesPage.js
new file mode 100644
index 0000000..c2464ea
--- /dev/null
+++ b/src/pages/SchedulesPage.js
@@ -0,0 +1,16 @@
+import React from 'react';
+
+export function SchedulesPage() {
+ return (
+
+
+
Agendamentos
+
(placeholder) Em breve: agendar sessões/extrações.
+
+
+
+ Quando você tiver endpoints de agendamento, essa tela vira uma UI de cronjobs e regras.
+
+
+ );
+}
diff --git a/src/pages/ScraperPage.js b/src/pages/ScraperPage.js
new file mode 100644
index 0000000..975b792
--- /dev/null
+++ b/src/pages/ScraperPage.js
@@ -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 (
+
+
+ {/* Sessions */}
+
+
+
+ Sessões
+
+
+ {sessions.map((session) => (
+
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'
+ }`}
+ >
+
+
{session.name ?? `Session ${session.sessionId}`}
+
+ {session.isRunning && (
+
+ Rodando
+
+ )}
+
{
+ 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 ? : }
+ {session.isRunning ? 'Parar' : 'Iniciar'}
+
+
+
+
+
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
+
+ )}
+
+
+
+ {/* Details */}
+
+ {selectedSession ? (
+ <>
+
+
Controles - {selectedSession.name}
+
+
+ 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()}
+ />
+
+ Adicionar
+
+
+
+
+
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"
+ >
+ Iniciar
+
+
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"
+ >
+ Parar
+
+
+ Exportar
+
+
+
+
+
+
Status
+ {!sessionStatus ? (
+
Carregando status…
+ ) : (
+
+
+
+
+
+
+
+ )}
+
+ >
+ ) : (
+
+ Selecione uma sessão para ver controles e status.
+
+ )}
+
+
+
+ );
+}
+
+function Metric({ title, value, valueClass = 'text-white' }) {
+ return (
+
+ );
+}
diff --git a/tailwind.config.js b/tailwind.config.js
index b7c41d1..64eab52 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -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: [],
}
\ No newline at end of file