You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
820 lines
42 KiB
820 lines
42 KiB
import React, { useState, useEffect } from 'react';
|
|
import axios from 'axios';
|
|
import {
|
|
ShieldAlert,
|
|
Layers,
|
|
Newspaper,
|
|
History,
|
|
Plus,
|
|
Search,
|
|
Trash2,
|
|
Edit2,
|
|
Play,
|
|
X,
|
|
AlertTriangle,
|
|
ExternalLink
|
|
} from 'lucide-react';
|
|
|
|
const HOURS_LIST = Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, '0'));
|
|
const MINUTES_LIST = Array.from({ length: 60 }, (_, i) => i.toString().padStart(2, '0'));
|
|
|
|
function App() {
|
|
const [activeTab, setActiveTab] = useState('crawlers');
|
|
const [crawlers, setCrawlers] = useState([]);
|
|
const [ads, setAds] = useState([]);
|
|
const [logs, setLogs] = useState([]);
|
|
const [health, setHealth] = useState({ database: 'loading', redis: 'loading' });
|
|
|
|
// Loading states
|
|
const [loadingCrawlers, setLoadingCrawlers] = useState(true);
|
|
const [loadingAds, setLoadingAds] = useState(true);
|
|
const [loadingLogs, setLoadingLogs] = useState(true);
|
|
|
|
// Filters
|
|
const [filterTaskId, setFilterTaskId] = useState('');
|
|
const [filterOnlyFlagged, setFilterOnlyFlagged] = useState(false);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
|
|
// Modal states
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [editingCrawler, setEditingCrawler] = useState(null);
|
|
|
|
// Form state
|
|
const [formTitle, setFormTitle] = useState('');
|
|
const [formUrl, setFormUrl] = useState('');
|
|
const [formPrompt, setFormPrompt] = useState('');
|
|
const [formInterval, setFormInterval] = useState('15');
|
|
const [formTelegram, setFormTelegram] = useState('');
|
|
const [formStartHour, setFormStartHour] = useState('08:00');
|
|
const [formEndHour, setFormEndHour] = useState('23:00');
|
|
const [formIsActive, setFormIsActive] = useState(true);
|
|
const [formError, setFormError] = useState('');
|
|
|
|
// Parse hour and minute from form time states
|
|
const [startH, startM] = (formStartHour || '08:00').split(':');
|
|
const [endH, endM] = (formEndHour || '23:00').split(':');
|
|
|
|
const updateStartHour = (h) => {
|
|
const parts = (formStartHour || '08:00').split(':');
|
|
const m = parts.length > 1 ? parts[1] : '00';
|
|
setFormStartHour(`${h}:${m}`);
|
|
};
|
|
|
|
const updateStartMinute = (m) => {
|
|
const parts = (formStartHour || '08:00').split(':');
|
|
const h = parts.length > 0 ? parts[0] : '08';
|
|
setFormStartHour(`${h}:${m}`);
|
|
};
|
|
|
|
const updateEndHour = (h) => {
|
|
const parts = (formEndHour || '23:00').split(':');
|
|
const m = parts.length > 1 ? parts[1] : '00';
|
|
setFormEndHour(`${h}:${m}`);
|
|
};
|
|
|
|
const updateEndMinute = (m) => {
|
|
const parts = (formEndHour || '23:00').split(':');
|
|
const h = parts.length > 0 ? parts[0] : '23';
|
|
setFormEndHour(`${h}:${m}`);
|
|
};
|
|
|
|
// Toast alert
|
|
const [toast, setToast] = useState(null);
|
|
|
|
const showToast = (message, type = 'info') => {
|
|
setToast({ message, type });
|
|
setTimeout(() => {
|
|
setToast(null);
|
|
}, 4000);
|
|
};
|
|
|
|
// Initial Fetch & Health Check Polling
|
|
useEffect(() => {
|
|
fetchHealth();
|
|
fetchCrawlers();
|
|
|
|
const healthInterval = setInterval(fetchHealth, 15000);
|
|
return () => clearInterval(healthInterval);
|
|
}, []);
|
|
|
|
// Fetch ads and logs based on active tab and filtering criteria
|
|
useEffect(() => {
|
|
if (activeTab === 'ads') {
|
|
fetchAds();
|
|
} else if (activeTab === 'logs') {
|
|
fetchLogs();
|
|
}
|
|
}, [activeTab, filterTaskId, filterOnlyFlagged]);
|
|
|
|
// Dynamic status polling for active logs
|
|
useEffect(() => {
|
|
let logsInterval = null;
|
|
if (activeTab === 'logs' && logs.some(l => l.status === 'RUNNING')) {
|
|
logsInterval = setInterval(fetchLogs, 5000);
|
|
}
|
|
return () => {
|
|
if (logsInterval) clearInterval(logsInterval);
|
|
};
|
|
}, [activeTab, logs]);
|
|
|
|
const fetchHealth = async () => {
|
|
try {
|
|
const res = await axios.get('/api/health/');
|
|
setHealth({
|
|
database: res.data.database === 'healthy' ? 'healthy' : 'unhealthy',
|
|
redis: res.data.redis === 'healthy' ? 'healthy' : 'unhealthy'
|
|
});
|
|
} catch (err) {
|
|
setHealth({
|
|
database: err.response?.data?.database === 'healthy' ? 'healthy' : 'unhealthy',
|
|
redis: err.response?.data?.redis === 'healthy' ? 'healthy' : 'unhealthy'
|
|
});
|
|
}
|
|
};
|
|
|
|
const fetchCrawlers = async () => {
|
|
setLoadingCrawlers(true);
|
|
try {
|
|
const res = await axios.get('/api/crawlers/');
|
|
setCrawlers(res.data);
|
|
} catch (err) {
|
|
showToast('خطا در دریافت لیست کرالرها', 'danger');
|
|
console.error(err);
|
|
} finally {
|
|
setLoadingCrawlers(false);
|
|
}
|
|
};
|
|
|
|
const fetchAds = async () => {
|
|
setLoadingAds(true);
|
|
try {
|
|
let url = '/api/ads/';
|
|
const params = {};
|
|
if (filterTaskId) params.crawl_task = filterTaskId;
|
|
if (filterOnlyFlagged) params.is_flagged = 'true';
|
|
|
|
const res = await axios.get(url, { params });
|
|
setAds(res.data);
|
|
} catch (err) {
|
|
showToast('خطا در دریافت لیست آگهیها', 'danger');
|
|
console.error(err);
|
|
} finally {
|
|
setLoadingAds(false);
|
|
}
|
|
};
|
|
|
|
const fetchLogs = async () => {
|
|
setLoadingLogs(true);
|
|
try {
|
|
const crawlersList = crawlers.length > 0 ? crawlers : (await axios.get('/api/crawlers/')).data;
|
|
const promises = crawlersList.map(async (c) => {
|
|
try {
|
|
const res = await axios.get(`/api/crawlers/${c.id}/runs/`);
|
|
return res.data.map(run => ({ ...run, crawler_title: c.title }));
|
|
} catch (e) {
|
|
return [];
|
|
}
|
|
});
|
|
const results = await Promise.all(promises);
|
|
const merged = results.flat().sort((a, b) => new Date(b.started_at) - new Date(a.started_at));
|
|
setLogs(merged);
|
|
} catch (err) {
|
|
showToast('خطا در دریافت تاریخچه اجرا', 'danger');
|
|
console.error(err);
|
|
} finally {
|
|
setLoadingLogs(false);
|
|
}
|
|
};
|
|
|
|
// Switch tabs
|
|
const switchTab = (tab) => {
|
|
setActiveTab(tab);
|
|
};
|
|
|
|
// Open Edit/Create Modal
|
|
const openCrawlerModal = (crawler = null) => {
|
|
setEditingCrawler(crawler);
|
|
setFormError('');
|
|
if (crawler) {
|
|
setFormTitle(crawler.title);
|
|
setFormUrl(crawler.divar_url);
|
|
setFormPrompt(crawler.detection_prompt);
|
|
setFormInterval(String(crawler.interval_minutes));
|
|
setFormTelegram(crawler.telegram_channel_id || '');
|
|
setFormStartHour(crawler.start_hour.substring(0, 5));
|
|
setFormEndHour(crawler.end_hour.substring(0, 5));
|
|
setFormIsActive(crawler.is_active);
|
|
} else {
|
|
setFormTitle('');
|
|
setFormUrl('');
|
|
setFormPrompt('');
|
|
setFormInterval('15');
|
|
setFormTelegram('');
|
|
setFormStartHour('08:00');
|
|
setFormEndHour('23:00');
|
|
setFormIsActive(true);
|
|
}
|
|
setModalOpen(true);
|
|
};
|
|
|
|
const closeCrawlerModal = () => {
|
|
setModalOpen(false);
|
|
setEditingCrawler(null);
|
|
};
|
|
|
|
const handleFormSubmit = async (e) => {
|
|
e.preventDefault();
|
|
setFormError('');
|
|
|
|
if (!formUrl.startsWith('https://divar.ir/s/')) {
|
|
setFormError("لینک دیوار باید با 'https://divar.ir/s/' شروع شود");
|
|
return;
|
|
}
|
|
|
|
const payload = {
|
|
title: formTitle,
|
|
divar_url: formUrl,
|
|
detection_prompt: formPrompt,
|
|
interval_minutes: parseInt(formInterval),
|
|
start_hour: `${formStartHour}:00`,
|
|
end_hour: `${formEndHour}:00`,
|
|
telegram_channel_id: formTelegram || null,
|
|
is_active: formIsActive
|
|
};
|
|
|
|
try {
|
|
if (editingCrawler) {
|
|
await axios.put(`/api/crawlers/${editingCrawler.id}/`, payload);
|
|
showToast('کرالر با موفقیت ویرایش شد', 'success');
|
|
} else {
|
|
await axios.post('/api/crawlers/', payload);
|
|
showToast('کرالر جدید با موفقیت ایجاد شد', 'success');
|
|
}
|
|
closeCrawlerModal();
|
|
fetchCrawlers();
|
|
} catch (err) {
|
|
const errorDetail = err.response?.data ? JSON.stringify(err.response.data) : 'خطا در ثبت اطلاعات';
|
|
setFormError(errorDetail);
|
|
}
|
|
};
|
|
|
|
const toggleCrawlerActive = async (crawler) => {
|
|
try {
|
|
await axios.patch(`/api/crawlers/${crawler.id}/`, {
|
|
is_active: !crawler.is_active
|
|
});
|
|
showToast(`کرالر ${!crawler.is_active ? 'فعال' : 'غیرفعال'} شد`, 'info');
|
|
fetchCrawlers();
|
|
} catch (err) {
|
|
showToast('خطا در تغییر وضعیت کرالر', 'danger');
|
|
}
|
|
};
|
|
|
|
const deleteCrawler = async (id) => {
|
|
if (!confirm('آیا از حذف این کرالر اطمینان دارید؟')) return;
|
|
try {
|
|
await axios.delete(`/api/crawlers/${id}/`);
|
|
showToast('کرالر با موفقیت حذف شد', 'success');
|
|
fetchCrawlers();
|
|
} catch (err) {
|
|
showToast('خطا در حذف کرالر', 'danger');
|
|
}
|
|
};
|
|
|
|
const triggerCrawler = async (id) => {
|
|
try {
|
|
await axios.post(`/api/crawlers/${id}/trigger/`);
|
|
showToast('درخواست اجرای کرالر با موفقیت ارسال شد', 'success');
|
|
if (activeTab === 'logs') {
|
|
fetchLogs();
|
|
}
|
|
} catch (err) {
|
|
showToast('خطا در اجرای کرالر', 'danger');
|
|
}
|
|
};
|
|
|
|
// Format date helper
|
|
const formatDate = (isoStr) => {
|
|
if (!isoStr) return '-';
|
|
const date = new Date(isoStr);
|
|
return date.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }) + ' ' +
|
|
date.toLocaleDateString('fa-IR', { month: '2-digit', day: '2-digit' });
|
|
};
|
|
|
|
// Filter ads on client side based on Search Term
|
|
const getFilteredAds = () => {
|
|
if (!searchTerm) return ads;
|
|
const term = searchTerm.toLowerCase();
|
|
return ads.filter(adEval => {
|
|
const ad = adEval.ad;
|
|
return (
|
|
ad.title?.toLowerCase().includes(term) ||
|
|
ad.description?.toLowerCase().includes(term) ||
|
|
adEval.reason?.toLowerCase().includes(term)
|
|
);
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="app-container flex flex-col md:flex-row min-h-screen relative font-fa">
|
|
|
|
{/* Toast Notification */}
|
|
{toast && (
|
|
<div
|
|
className="fixed top-5 left-5 px-5 py-3 rounded-lg z-50 shadow-2xl text-white direction-rtl animate-fade-in"
|
|
style={{
|
|
backgroundColor: toast.type === 'success' ? '#10b981' : toast.type === 'danger' ? '#ef4444' : '#a855f7',
|
|
}}
|
|
>
|
|
{toast.message}
|
|
</div>
|
|
)}
|
|
|
|
{/* Sidebar */}
|
|
<aside className="w-full md:w-72 bg-slate-900 border-l border-white/5 flex flex-col p-6 sticky top-0 md:h-screen z-10 shrink-0">
|
|
<div className="flex items-center gap-3 pb-4 mb-10 border-b border-white/5">
|
|
<div className="bg-gradient-to-br from-purple-500 to-blue-500 w-11 h-11 rounded-xl flex items-center justify-center shadow-[0_0_15px_rgba(168,85,247,0.3)] text-white">
|
|
<ShieldAlert className="w-6 h-6" />
|
|
</div>
|
|
<div className="flex flex-col">
|
|
<h1 className="text-lg font-bold text-white leading-tight">دیدبان دیوار</h1>
|
|
<span className="text-[10px] text-gray-500">کرال هوشمند با AI</span>
|
|
</div>
|
|
</div>
|
|
|
|
<nav className="flex flex-row md:flex-col overflow-x-auto md:overflow-x-visible gap-2 flex-grow pb-2 md:pb-0">
|
|
<button
|
|
className={`w-auto md:w-full bg-transparent border-none text-gray-400 flex items-center gap-3 px-4 py-3 rounded-xl cursor-pointer text-sm font-medium transition-all duration-200 text-right hover:bg-white/5 hover:text-white shrink-0 ${activeTab === 'crawlers' ? 'bg-gradient-to-r from-purple-500/15 to-transparent border-r-3 border-purple-500 text-purple-400 font-bold' : ''}`}
|
|
onClick={() => switchTab('crawlers')}
|
|
>
|
|
<Layers className="w-5 h-5" />
|
|
<span>مدیریت کرالرها</span>
|
|
</button>
|
|
<button
|
|
className={`w-auto md:w-full bg-transparent border-none text-gray-400 flex items-center gap-3 px-4 py-3 rounded-xl cursor-pointer text-sm font-medium transition-all duration-200 text-right hover:bg-white/5 hover:text-white shrink-0 ${activeTab === 'ads' ? 'bg-gradient-to-r from-purple-500/15 to-transparent border-r-3 border-purple-500 text-purple-400 font-bold' : ''}`}
|
|
onClick={() => switchTab('ads')}
|
|
>
|
|
<Newspaper className="w-5 h-5" />
|
|
<span>فید آگهیها</span>
|
|
</button>
|
|
<button
|
|
className={`w-auto md:w-full bg-transparent border-none text-gray-400 flex items-center gap-3 px-4 py-3 rounded-xl cursor-pointer text-sm font-medium transition-all duration-200 text-right hover:bg-white/5 hover:text-white shrink-0 ${activeTab === 'logs' ? 'bg-gradient-to-r from-purple-500/15 to-transparent border-r-3 border-purple-500 text-purple-400 font-bold' : ''}`}
|
|
onClick={() => switchTab('logs')}
|
|
>
|
|
<History className="w-5 h-5" />
|
|
<span>تاریخچه اجرا</span>
|
|
</button>
|
|
</nav>
|
|
|
|
<div className="pt-4 border-t border-white/5 text-center hidden md:block">
|
|
<span className="text-[11px] text-gray-500 font-en">نسخه ۱.۰.۰ (MVP)</span>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Main Content */}
|
|
<div className="flex-grow flex flex-col min-w-0">
|
|
|
|
{/* Header */}
|
|
<header className="bg-slate-950/70 backdrop-blur-md border-b border-white/5 flex items-center justify-between px-6 md:px-8 py-5 sticky top-0 z-9">
|
|
<div className="header-title">
|
|
<h2 className="text-lg font-bold text-white">
|
|
{activeTab === 'crawlers' && 'مدیریت کرالرها'}
|
|
{activeTab === 'ads' && 'فید آگهیهای دیوار'}
|
|
{activeTab === 'logs' && 'تاریخچه اجرای کرالرها'}
|
|
</h2>
|
|
</div>
|
|
|
|
<div className="header-actions">
|
|
<div className="flex items-center gap-5 bg-white/2 px-4 py-2 rounded-full border border-white/5 text-xs text-gray-400">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className={`w-2 h-2 rounded-full inline-block ${health.database === 'healthy' ? 'bg-emerald-500 shadow-[0_0_8px_#10b981]' : 'bg-rose-500 shadow-[0_0_8px_#ef4444]'}`}></span>
|
|
<span>دیتابیس</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<span className={`w-2 h-2 rounded-full inline-block ${health.redis === 'healthy' ? 'bg-emerald-500 shadow-[0_0_8px_#10b981]' : 'bg-rose-500 shadow-[0_0_8px_#ef4444]'}`}></span>
|
|
<span>ردیس</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Viewport */}
|
|
<main className="p-6 md:p-8 flex-grow">
|
|
|
|
{/* TAB 1: Crawlers View */}
|
|
{activeTab === 'crawlers' && (
|
|
<div className="animate-fade-in">
|
|
<div className="flex justify-between items-center mb-8">
|
|
<button className="inline-flex items-center justify-center gap-2 border border-transparent rounded-lg px-4 py-2.5 text-sm font-medium cursor-pointer transition-all duration-200 bg-purple-500 text-white shadow-[0_4px_14px_rgba(168,85,247,0.3)] hover:bg-purple-600 hover:-translate-y-0.5 hover:shadow-[0_6px_20px_rgba(168,85,247,0.4)]" onClick={() => openCrawlerModal()}>
|
|
<Plus className="w-5 h-5" />
|
|
<span>تعریف کرالر جدید</span>
|
|
</button>
|
|
</div>
|
|
|
|
{loadingCrawlers ? (
|
|
<div className="flex flex-col items-center justify-center py-16 px-8 text-center w-full bg-slate-900/40 border border-dashed border-white/5 rounded-2xl text-gray-400">
|
|
<div className="border-3 border-white/5 border-t-purple-500 rounded-full w-9 h-9 animate-spin mb-4"></div>
|
|
<p>در حال بارگذاری کرالرها...</p>
|
|
</div>
|
|
) : crawlers.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-16 px-8 text-center w-full bg-slate-900/40 border border-dashed border-white/5 rounded-2xl text-gray-400">
|
|
<AlertTriangle className="w-12 h-12 mb-4 text-gray-500" />
|
|
<p>هیچ کرالری تعریف نشده است. جهت پایش دیوار، یک کرالر جدید ایجاد کنید.</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{crawlers.map(c => (
|
|
<div className="bg-slate-900/40 backdrop-blur-md border border-white/5 rounded-2xl p-6 shadow-xl hover:bg-slate-900/60 hover:border-purple-500/40 transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl flex flex-col relative overflow-hidden" key={c.id}>
|
|
<div className="flex justify-between items-start mb-4 gap-4">
|
|
<h4 className="text-lg font-bold text-white overflow-hidden text-overflow-ellipsis whitespace-nowrap max-w-[200px]" title={c.title}>{c.title}</h4>
|
|
<div className="flex items-center gap-3">
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={c.is_active}
|
|
onChange={() => toggleCrawlerActive(c)}
|
|
className="sr-only peer"
|
|
/>
|
|
<div className="w-11 h-6 bg-white/10 border border-white/10 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-gray-400 after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-600 peer-checked:after:bg-white"></div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-grow flex flex-col gap-3 mb-5">
|
|
<div className="flex flex-col gap-1">
|
|
<span className="text-[11px] text-gray-500">لینک فیلتر دیوار</span>
|
|
<span className="text-xs text-blue-400 font-en direction-ltr break-all overflow-hidden text-ellipsis whitespace-nowrap block" title={c.divar_url}>{c.divar_url}</span>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<span className="text-[11px] text-gray-500">پرامپت ارزیابی AI</span>
|
|
<span className="text-[13px] text-gray-300 line-clamp-2" title={c.detection_prompt}>{c.detection_prompt}</span>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3.5">
|
|
<div className="flex flex-col gap-1">
|
|
<span className="text-[11px] text-gray-500">فاصله زمانی پایش</span>
|
|
<span className="text-xs text-gray-300 font-en">{c.interval_minutes}m</span>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<span className="text-[11px] text-gray-500">بازه زمانی مجاز</span>
|
|
<span className="text-xs text-gray-300 font-en">{c.start_hour.substring(0,5)} - {c.end_hour.substring(0,5)}</span>
|
|
</div>
|
|
</div>
|
|
{c.telegram_channel_id && (
|
|
<div className="flex flex-col gap-1">
|
|
<span className="text-[11px] text-gray-500">کانال اعلانات تلگرام</span>
|
|
<span className="text-xs text-purple-400 font-mono">{c.telegram_channel_id}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex justify-between items-center border-t border-white/5 pt-4 mt-auto">
|
|
<button className="inline-flex items-center justify-center border rounded-lg p-2 text-sm font-medium cursor-pointer transition-all duration-200 bg-white/5 border-white/5 text-white hover:bg-white/10" title="اجرای فوری" onClick={() => triggerCrawler(c.id)}>
|
|
<Play className="w-5 h-5 fill-current" />
|
|
</button>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<button className="inline-flex items-center justify-center border rounded-lg p-2 text-sm font-medium cursor-pointer transition-all duration-200 bg-white/5 border-white/5 text-white hover:bg-white/10" title="ویرایش" onClick={() => openCrawlerModal(c)}>
|
|
<Edit2 className="w-4 h-4" />
|
|
</button>
|
|
<button className="inline-flex items-center justify-center border border-transparent rounded-lg p-2 text-sm font-medium cursor-pointer transition-all duration-200 bg-rose-500 text-white shadow-[0_4px_14px_rgba(239,68,68,0.2)] hover:bg-rose-600 hover:-translate-y-0.5" title="حذف" onClick={() => deleteCrawler(c.id)}>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* TAB 2: Ads View */}
|
|
{activeTab === 'ads' && (
|
|
<div className="animate-fade-in">
|
|
<div className="flex flex-col lg:flex-row justify-between gap-5 bg-slate-900/40 p-5 rounded-2xl border border-white/5 mb-8 items-center">
|
|
<div className="relative w-full lg:max-w-md">
|
|
<Search className="absolute right-3 top-1/2 -translate-y-1/2 w-4.5 h-4.5 text-gray-500" />
|
|
<input
|
|
type="text"
|
|
placeholder="جستجو در فید آگهیها..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="w-full bg-black/20 border border-white/5 rounded-lg py-2.5 pr-10 pl-4 text-white text-sm outline-none transition-all duration-200 focus:border-purple-500 focus:shadow-[0_0_10px_rgba(168,85,247,0.15)]"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-6 flex-wrap w-full lg:w-auto justify-between lg:justify-end">
|
|
<div className="flex items-center gap-2 text-sm text-gray-300">
|
|
<label>فیلتر کرالر:</label>
|
|
<select
|
|
value={filterTaskId}
|
|
onChange={(e) => setFilterTaskId(e.target.value)}
|
|
className="bg-black/20 border border-white/5 rounded-lg px-4 py-2 text-white text-sm outline-none focus:border-purple-500"
|
|
>
|
|
<option value="">همه کرالرها</option>
|
|
{crawlers.map(c => (
|
|
<option key={c.id} value={c.id}>{c.title}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={filterOnlyFlagged}
|
|
onChange={(e) => setFilterOnlyFlagged(e.target.checked)}
|
|
className="sr-only peer"
|
|
/>
|
|
<div className="w-11 h-6 bg-white/10 border border-white/10 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-gray-400 after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-600 peer-checked:after:bg-white"></div>
|
|
</label>
|
|
<span className="text-sm text-gray-300">فقط پرچمگذاری شده (AI)</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{loadingAds ? (
|
|
<div className="flex flex-col items-center justify-center py-16 px-8 text-center w-full bg-slate-900/40 border border-dashed border-white/5 rounded-2xl text-gray-400">
|
|
<div className="border-3 border-white/5 border-t-purple-500 rounded-full w-9 h-9 animate-spin mb-4"></div>
|
|
<p>در حال بارگذاری آگهیها...</p>
|
|
</div>
|
|
) : getFilteredAds().length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-16 px-8 text-center w-full bg-slate-900/40 border border-dashed border-white/5 rounded-2xl text-gray-400">
|
|
<AlertTriangle className="w-12 h-12 mb-4 text-gray-500" />
|
|
<p>هیچ آگهی با معیارهای فیلتر یافت نشد.</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
{getFilteredAds().map(adEval => (
|
|
<div className={`bg-slate-900/40 backdrop-blur-md rounded-2xl p-6 shadow-xl transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl flex flex-col relative overflow-hidden border-t-4 ${adEval.is_flagged ? 'border-purple-500 bg-gradient-to-b from-purple-500/3 to-slate-900/40 shadow-[0_8px_32px_rgba(168,85,247,0.05)]' : 'border-white/5'}`} key={adEval.id}>
|
|
<div className="flex justify-between items-center mb-2 w-full text-xs text-gray-500">
|
|
<span className="font-en">{formatDate(adEval.evaluated_at)}</span>
|
|
{adEval.ad.price && (
|
|
<span className="text-[13px] font-bold text-purple-400">{adEval.ad.price}</span>
|
|
)}
|
|
</div>
|
|
|
|
<h4 className="text-base font-bold text-white mb-2 leading-snug">
|
|
{adEval.ad.title}
|
|
</h4>
|
|
|
|
<p className="text-xs.5 text-gray-400 line-clamp-3 mb-4 leading-relaxed break-all">{adEval.ad.description}</p>
|
|
|
|
{adEval.is_flagged && (
|
|
<div className="bg-purple-500/8 border border-purple-500/20 rounded-xl p-4 text-sm mb-4">
|
|
<div className="flex items-center justify-between text-purple-400 font-bold mb-1.5">
|
|
<span>تحلیل هوش مصنوعی (پرچمگذاری شده)</span>
|
|
{adEval.confidence && (
|
|
<span className="text-[11px] bg-purple-500/25 px-1.5 py-0.5 rounded font-en">Confidence: {Math.round(adEval.confidence * 100)}%</span>
|
|
)}
|
|
</div>
|
|
<p className="text-gray-300 text-xs.5 leading-relaxed">{adEval.reason}</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-wrap gap-2 mb-4">
|
|
<span className="bg-white/2 border border-white/5 px-2 py-0.5 rounded text-[11px] text-gray-400 font-en">Token: {adEval.ad.divar_token}</span>
|
|
{adEval.ad.category && (
|
|
<span className="bg-white/2 border border-white/5 px-2 py-0.5 rounded text-[11px] text-gray-400">{adEval.ad.category}</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex justify-between items-center border-t border-white/5 pt-4 mt-auto">
|
|
<a href={adEval.ad.url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center justify-center border rounded-lg p-2 text-sm font-medium cursor-pointer transition-all duration-200 bg-white/5 border-white/5 text-blue-400 hover:bg-white/10" title="مشاهده در دیوار">
|
|
<ExternalLink className="w-5 h-5" />
|
|
</a>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* TAB 3: Logs View */}
|
|
{activeTab === 'logs' && (
|
|
<div className="animate-fade-in">
|
|
{loadingLogs ? (
|
|
<div className="flex flex-col items-center justify-center py-16 px-8 text-center w-full bg-slate-900/40 border border-dashed border-white/5 rounded-2xl text-gray-400">
|
|
<div className="border-3 border-white/5 border-t-purple-500 rounded-full w-9 h-9 animate-spin mb-4"></div>
|
|
<p>در حال دریافت تاریخچه اجرا...</p>
|
|
</div>
|
|
) : logs.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-16 px-8 text-center w-full bg-slate-900/40 border border-dashed border-white/5 rounded-2xl text-gray-400">
|
|
<AlertTriangle className="w-12 h-12 mb-4 text-gray-500" />
|
|
<p>هیچ لاگ تاریخی ثبت نشده است.</p>
|
|
</div>
|
|
) : (
|
|
<div className="bg-slate-900/40 backdrop-blur-md border border-white/5 rounded-2xl shadow-xl overflow-hidden w-full overflow-x-auto">
|
|
<table className="min-w-full text-right text-sm text-gray-300 border-collapse">
|
|
<thead className="bg-black/20 text-white font-bold">
|
|
<tr>
|
|
<th className="px-5 py-4 border-b border-white/5">کرالر</th>
|
|
<th className="px-5 py-4 border-b border-white/5">شروع اجرا</th>
|
|
<th className="px-5 py-4 border-b border-white/5">پایان اجرا</th>
|
|
<th className="px-5 py-4 border-b border-white/5">وضعیت</th>
|
|
<th className="px-5 py-4 border-b border-white/5">آگهی یافت شده</th>
|
|
<th className="px-5 py-4 border-b border-white/5">پردازش AI</th>
|
|
<th className="px-5 py-4 border-b border-white/5">پرچمگذاری شده</th>
|
|
<th className="px-5 py-4 border-b border-white/5">لاگ خطا</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{logs.map(run => (
|
|
<tr key={run.id} className="hover:bg-white/1">
|
|
<td className="px-5 py-4 border-b border-white/5 font-semibold">{run.crawler_title}</td>
|
|
<td className="px-5 py-4 border-b border-white/5 font-en text-xs">{formatDate(run.started_at)}</td>
|
|
<td className="px-5 py-4 border-b border-white/5 font-en text-xs">{formatDate(run.finished_at)}</td>
|
|
<td className="px-5 py-4 border-b border-white/5">
|
|
<span className={`px-2.5 py-1 rounded-full text-xs font-medium inline-flex items-center gap-1 border ${run.status === 'SUCCESS' ? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30' : run.status === 'FAILED' ? 'bg-rose-500/10 text-rose-400 border-rose-500/30' : 'bg-amber-500/10 text-amber-400 border-amber-500/30'}`}>
|
|
{run.status === 'RUNNING' && 'در حال اجرا'}
|
|
{run.status === 'SUCCESS' && 'موفق'}
|
|
{run.status === 'FAILED' && 'ناموفق'}
|
|
</span>
|
|
</td>
|
|
<td className="px-5 py-4 border-b border-white/5 font-en">{run.ads_fetched_count}</td>
|
|
<td className="px-5 py-4 border-b border-white/5 font-en">{run.ads_evaluated_count}</td>
|
|
<td className={`px-5 py-4 border-b border-white/5 font-en ${run.ads_flagged_count > 0 ? 'text-purple-400 font-bold' : ''}`}>
|
|
{run.ads_flagged_count}
|
|
</td>
|
|
<td className="px-5 py-4 border-b border-white/5 max-w-[180px] overflow-hidden text-ellipsis whitespace-nowrap" title={run.error_log}>
|
|
{run.error_log || '-'}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
</main>
|
|
</div>
|
|
|
|
{/* Form Dialog Modal */}
|
|
<div className={`fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50 transition-opacity duration-200 ${modalOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}`}>
|
|
<div className={`bg-slate-900 border border-white/5 rounded-2xl w-[600px] max-w-[95%] max-h-[90vh] shadow-[0_20px_50px_rgba(0,0,0,0.6)] flex flex-col transition-transform duration-200 ${modalOpen ? 'scale-100' : 'scale-95'}`}>
|
|
<div className="flex items-center justify-between p-5 border-b border-white/5">
|
|
<h3 className="text-base font-bold text-white">{editingCrawler ? 'ویرایش کرالر' : 'تعریف کرالر جدید'}</h3>
|
|
<button className="bg-transparent border-none text-gray-400 cursor-pointer flex items-center hover:text-white" onClick={closeCrawlerModal}>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleFormSubmit} className="flex flex-col min-h-0">
|
|
<div className="p-6 overflow-y-auto flex flex-col gap-5">
|
|
{formError && (
|
|
<div className="bg-rose-500/10 border border-rose-500/30 p-3 rounded-lg text-xs text-rose-300">
|
|
{formError}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-xs text-gray-400 font-medium">عنوان کرالر</label>
|
|
<input
|
|
type="text"
|
|
value={formTitle}
|
|
onChange={(e) => setFormTitle(e.target.value)}
|
|
placeholder="مثال: رهن کامل فوری نیاوران"
|
|
className="bg-black/20 border border-white/5 rounded-lg px-4 py-2 text-white text-sm outline-none focus:border-purple-500 focus:shadow-[0_0_10px_rgba(168,85,247,0.2)]"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-xs text-gray-400 font-medium">لینک فیلتر دیوار (باید با https://divar.ir/s/ شروع شود)</label>
|
|
<input
|
|
type="url"
|
|
value={formUrl}
|
|
onChange={(e) => setFormUrl(e.target.value)}
|
|
placeholder="https://divar.ir/s/tehran/rent-apartment/niavaran?..."
|
|
className="bg-black/20 border border-white/5 rounded-lg px-4 py-2 text-white text-sm outline-none focus:border-purple-500 focus:shadow-[0_0_10px_rgba(168,85,247,0.2)]"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-xs text-gray-400 font-medium">پرامپت ارزیابی هوش مصنوعی (AI)</label>
|
|
<textarea
|
|
value={formPrompt}
|
|
onChange={(e) => setFormPrompt(e.target.value)}
|
|
placeholder="بررسی کن آیا آگهی پول لازم و رهن کامل است یا خیر..."
|
|
rows={3}
|
|
className="bg-black/20 border border-white/5 rounded-lg px-4 py-2 text-white text-sm outline-none focus:border-purple-500 focus:shadow-[0_0_10px_rgba(168,85,247,0.2)] resize-vertical"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-xs text-gray-400 font-medium">بازه زمانی پایش</label>
|
|
<select
|
|
value={formInterval}
|
|
onChange={(e) => setFormInterval(e.target.value)}
|
|
className="bg-black/20 border border-white/5 rounded-lg px-4 py-2 text-white text-sm outline-none focus:border-purple-500"
|
|
>
|
|
<option value="5">۵ دقیقه</option>
|
|
<option value="15">۱۵ دقیقه</option>
|
|
<option value="30">۳۰ دقیقه</option>
|
|
<option value="60">۱ ساعت</option>
|
|
<option value="120">۲ ساعت</option>
|
|
<option value="180">۳ ساعت</option>
|
|
<option value="240">۴ ساعت</option>
|
|
<option value="300">۵ ساعت</option>
|
|
<option value="360">۶ ساعت</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-xs text-gray-400 font-medium">آیدی کانال تلگرام (اختیاری)</label>
|
|
<input
|
|
type="text"
|
|
value={formTelegram}
|
|
onChange={(e) => setFormTelegram(e.target.value)}
|
|
placeholder="مثال: @my_alerts"
|
|
className="bg-black/20 border border-white/5 rounded-lg px-4 py-2 text-white text-sm outline-none focus:border-purple-500 focus:shadow-[0_0_10px_rgba(168,85,247,0.2)]"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-xs text-gray-400 font-medium">ساعت شروع پنجره مجاز</label>
|
|
<div className="flex flex-row-reverse items-center gap-2">
|
|
<select
|
|
value={startH || '08'}
|
|
onChange={(e) => updateStartHour(e.target.value)}
|
|
className="flex-1 bg-[#1a1625] border border-white/5 rounded-lg px-3 py-2 text-white text-sm outline-none focus:border-purple-500 cursor-pointer"
|
|
>
|
|
{HOURS_LIST.map(h => (
|
|
<option key={h} value={h} className="bg-slate-900 text-white">{h}</option>
|
|
))}
|
|
</select>
|
|
<span className="text-gray-500 font-bold">:</span>
|
|
<select
|
|
value={startM || '00'}
|
|
onChange={(e) => updateStartMinute(e.target.value)}
|
|
className="flex-1 bg-[#1a1625] border border-white/5 rounded-lg px-3 py-2 text-white text-sm outline-none focus:border-purple-500 cursor-pointer"
|
|
>
|
|
{MINUTES_LIST.map(m => (
|
|
<option key={m} value={m} className="bg-slate-900 text-white">{m}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-xs text-gray-400 font-medium">ساعت پایان پنجره مجاز</label>
|
|
<div className="flex flex-row-reverse items-center gap-2">
|
|
<select
|
|
value={endH || '23'}
|
|
onChange={(e) => updateEndHour(e.target.value)}
|
|
className="flex-1 bg-[#1a1625] border border-white/5 rounded-lg px-3 py-2 text-white text-sm outline-none focus:border-purple-500 cursor-pointer"
|
|
>
|
|
{HOURS_LIST.map(h => (
|
|
<option key={h} value={h} className="bg-slate-900 text-white">{h}</option>
|
|
))}
|
|
</select>
|
|
<span className="text-gray-500 font-bold">:</span>
|
|
<select
|
|
value={endM || '00'}
|
|
onChange={(e) => updateEndMinute(e.target.value)}
|
|
className="flex-1 bg-[#1a1625] border border-white/5 rounded-lg px-3 py-2 text-white text-sm outline-none focus:border-purple-500 cursor-pointer"
|
|
>
|
|
{MINUTES_LIST.map(m => (
|
|
<option key={m} value={m} className="bg-slate-900 text-white">{m}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-row justify-between items-center bg-white/1 p-3.5 rounded-lg border border-white/5">
|
|
<span className="text-xs text-gray-300 font-medium">کرالر فعال باشد</span>
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={formIsActive}
|
|
onChange={(e) => setFormIsActive(e.target.checked)}
|
|
className="sr-only peer"
|
|
/>
|
|
<div className="w-11 h-6 bg-white/10 border border-white/10 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-gray-400 after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-600 peer-checked:after:bg-white"></div>
|
|
</label>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2 p-5 border-t border-white/5">
|
|
<button type="button" className="inline-flex items-center justify-center border border-white/5 rounded-lg px-4 py-2 text-sm font-medium cursor-pointer transition-all duration-200 bg-white/5 text-white hover:bg-white/10" onClick={closeCrawlerModal}>انصراف</button>
|
|
<button type="submit" className="inline-flex items-center justify-center border border-transparent rounded-lg px-4 py-2 text-sm font-medium cursor-pointer transition-all duration-200 bg-purple-500 text-white shadow-[0_4px_14px_rgba(168,85,247,0.3)] hover:bg-purple-600 hover:-translate-y-0.5">ذخیره</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App;
|