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:
parent
74b7569dd1
commit
c32ac5fd3c
@ -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>
|
||||
|
||||
676
src/App.js
676
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 = () => (
|
||||
<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} /> {formatNumber(session.queue?.done || 0)}</span>
|
||||
<span className="text-yellow-400 flex items-center"><Clock size={16} /> {formatNumber(session.queue?.pending || 0)}</span>
|
||||
<span className="text-red-400 flex items-center"><CircleX size={16} /> {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>
|
||||
<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
84
src/api/extractionApi.js
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
34
src/components/AppHeader.js
Normal file
34
src/components/AppHeader.js
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/components/JsonModal.js
Normal file
26
src/components/JsonModal.js
Normal 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
60
src/components/Sidebar.js
Normal 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
1
src/config.js
Normal file
@ -0,0 +1 @@
|
||||
export const API_BASE = process.env.REACT_APP_API_BASE ?? 'http://localhost:5000';
|
||||
25
src/context/LogContext.js
Normal file
25
src/context/LogContext.js
Normal 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;
|
||||
}
|
||||
27
src/hooks/useExtractionModels.js
Normal file
27
src/hooks/useExtractionModels.js
Normal 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 };
|
||||
}
|
||||
23
src/hooks/useExtractionResults.js
Normal file
23
src/hooks/useExtractionResults.js
Normal 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 };
|
||||
}
|
||||
54
src/hooks/useExtractionRunPolling.js
Normal file
54
src/hooks/useExtractionRunPolling.js
Normal 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
28
src/hooks/useSessions.js
Normal 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 };
|
||||
}
|
||||
@ -1,3 +1,12 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
body {
|
||||
@apply bg-app-bg text-text-main;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
<App />
|
||||
<LogProvider>
|
||||
<App />
|
||||
</LogProvider>
|
||||
</AuthBootstrap>
|
||||
// </React.StrictMode>
|
||||
);
|
||||
30
src/pages/DashboardPage.js
Normal file
30
src/pages/DashboardPage.js
Normal 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
515
src/pages/ExtractorsPage.js
Normal 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
54
src/pages/LogsPage.js
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/pages/SchedulesPage.js
Normal file
16
src/pages/SchedulesPage.js
Normal 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
416
src/pages/ScraperPage.js
Normal 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} /> {formatNumber(session.queue?.done || 0)}</span>
|
||||
<span className="text-yellow-400 flex items-center"><Clock size={16} /> {formatNumber(session.queue?.pending || 0)}</span>
|
||||
<span className="text-red-400 flex items-center"><CircleX size={16} /> {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>
|
||||
);
|
||||
}
|
||||
@ -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: [],
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user