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

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;