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.
 
 
 
 
 
 

761 lines
29 KiB

import React, { useState, useEffect } from 'react';
import axios from 'axios';
import {
ShieldAlert,
Layers,
Newspaper,
History,
Plus,
Search,
Trash2,
Edit2,
Play,
X,
AlertTriangle,
RefreshCw,
ExternalLink,
Info
} from 'lucide-react';
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('');
// 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 {
// In Django REST, runs are queried per-crawler. We fetch all crawlers,
// then request runs for each and merge/sort them.
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 {
const res = 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">
{/* Toast Notification */}
{toast && (
<div style={{
position: 'fixed',
top: '20px',
left: '20px',
backgroundColor: toast.type === 'success' ? 'var(--success)' : toast.type === 'danger' ? 'var(--danger)' : 'var(--accent)',
color: 'white',
padding: '10px 20px',
borderRadius: '8px',
zIndex: 9999,
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
direction: 'rtl',
animation: 'fadeIn 0.2s ease'
}}>
{toast.message}
</div>
)}
{/* Sidebar */}
<aside class="sidebar">
<div class="sidebar-brand">
<div class="brand-logo">
<ShieldAlert className="brand-icon" />
</div>
<div class="brand-info">
<h1>دیدبان دیوار</h1>
<span>کرال هوشمند با AI</span>
</div>
</div>
<nav class="sidebar-nav">
<button
className={`nav-item ${activeTab === 'crawlers' ? 'active' : ''}`}
onClick={() => switchTab('crawlers')}
>
<Layers />
<span>مدیریت کرالرها</span>
</button>
<button
className={`nav-item ${activeTab === 'ads' ? 'active' : ''}`}
onClick={() => switchTab('ads')}
>
<Newspaper />
<span>فید آگهی‌ها</span>
</button>
<button
className={`nav-item ${activeTab === 'logs' ? 'active' : ''}`}
onClick={() => switchTab('logs')}
>
<History />
<span>تاریخچه اجرا</span>
</button>
</nav>
<div class="sidebar-footer">
<span class="version-label">نسخه ۱ (MVP)</span>
</div>
</aside>
{/* Main Content */}
<div className="main-content">
{/* Header */}
<header className="app-header">
<div className="header-title">
<h2>
{activeTab === 'crawlers' && 'مدیریت کرالرها'}
{activeTab === 'ads' && 'فید آگهی‌های دیوار'}
{activeTab === 'logs' && 'تاریخچه اجرای کرالرها'}
</h2>
</div>
<div className="header-actions">
<div className="system-health">
<div className="health-item">
<span className={`dot ${health.database === 'healthy' ? 'green' : 'red'}`}></span>
<span>دیتابیس</span>
</div>
<div className="health-item">
<span className={`dot ${health.redis === 'healthy' ? 'green' : 'red'}`}></span>
<span>ردیس</span>
</div>
</div>
</div>
</header>
{/* Viewport */}
<main className="view-viewport">
{/* TAB 1: Crawlers View */}
{activeTab === 'crawlers' && (
<div className="tab-view active">
<div className="view-actions-bar">
<button className="btn btn-primary" onClick={() => openCrawlerModal()}>
<Plus />
<span>تعریف کرالر جدید</span>
</button>
</div>
{loadingCrawlers ? (
<div className="loading-state">
<div className="spinner"></div>
<p>در حال بارگذاری کرالرها...</p>
</div>
) : crawlers.length === 0 ? (
<div className="empty-state">
<AlertTriangle />
<p>هیچ کرالری تعریف نشده است. جهت پایش دیوار، یک کرالر جدید ایجاد کنید.</p>
</div>
) : (
<div className="crawlers-grid">
{crawlers.map(c => (
<div className="card" key={c.id}>
<div className="card-header">
<h4 className="card-title" title={c.title}>{c.title}</h4>
<div className="toggle-group">
<label className="switch-container">
<input
type="checkbox"
checked={c.is_active}
onChange={() => toggleCrawlerActive(c)}
/>
<span className="switch-slider"></span>
</label>
</div>
</div>
<div className="card-body">
<div className="card-field">
<span className="card-field-label">لینک فیلتر دیوار</span>
<span className="card-field-value url" title={c.divar_url}>{c.divar_url}</span>
</div>
<div className="card-field">
<span className="card-field-label">پرامپت ارزیابی AI</span>
<span className="card-field-value prompt" title={c.detection_prompt}>{c.detection_prompt}</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<div className="card-field">
<span className="card-field-label">فاصله زمانی پایش</span>
<span className="card-field-value arabic-num-font">{c.interval_minutes} دقیقه</span>
</div>
<div className="card-field">
<span className="card-field-label">بازه زمانی مجاز</span>
<span className="card-field-value arabic-num-font">{c.start_hour.substring(0,5)} الی {c.end_hour.substring(0,5)}</span>
</div>
</div>
{c.telegram_channel_id && (
<div className="card-field">
<span className="card-field-label">کانال اعلانات تلگرام</span>
<span className="card-field-value font-mono">{c.telegram_channel_id}</span>
</div>
)}
</div>
<div className="card-footer">
<button className="btn btn-secondary btn-icon-only" title="اجرای فوری" onClick={() => triggerCrawler(c.id)}>
<Play style={{ fill: 'currentColor' }} />
</button>
<div className="card-actions">
<button className="btn btn-secondary btn-icon-only" title="ویرایش" onClick={() => openCrawlerModal(c)}>
<Edit2 />
</button>
<button className="btn btn-danger btn-icon-only" title="حذف" onClick={() => deleteCrawler(c.id)}>
<Trash2 />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* TAB 2: Ads View */}
{activeTab === 'ads' && (
<div className="tab-view active">
<div className="ads-filters-bar">
<div className="search-box">
<Search className="search-icon" />
<input
type="text"
placeholder="جستجو در فید آگهی‌ها..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="filters-group">
<div className="filter-item">
<label>فیلتر کرالر:</label>
<select value={filterTaskId} onChange={(e) => setFilterTaskId(e.target.value)}>
<option value="">همه کرالرها</option>
{crawlers.map(c => (
<option key={c.id} value={c.id}>{c.title}</option>
))}
</select>
</div>
<div className="filter-item toggle-filter">
<label className="switch-container">
<input
type="checkbox"
checked={filterOnlyFlagged}
onChange={(e) => setFilterOnlyFlagged(e.target.checked)}
/>
<span class="switch-slider"></span>
</label>
<span className="toggle-label">فقط پرچم‌گذاری شده (AI)</span>
</div>
</div>
</div>
{loadingAds ? (
<div className="loading-state">
<div className="spinner"></div>
<p>در حال بارگذاری آگهی‌ها...</p>
</div>
) : getFilteredAds().length === 0 ? (
<div className="empty-state">
<AlertTriangle />
<p>هیچ آگهی با معیارهای فیلتر یافت نشد.</p>
</div>
) : (
<div className="ads-grid">
{getFilteredAds().map(adEval => (
<div className={`card ad-card ${adEval.is_flagged ? 'flagged' : ''}`} key={adEval.id}>
<div className="ad-meta-top">
<span className="ad-published-at">{formatDate(adEval.evaluated_at)}</span>
{adEval.ad.price && (
<span className="ad-price">{adEval.ad.price}</span>
)}
</div>
<h4 className="card-title" style={{ maxWidth: '100%', marginBottom: '0.5rem' }}>
{adEval.ad.title}
</h4>
<p className="ad-desc">{adEval.ad.description}</p>
{adEval.is_flagged && (
<div className="ai-flag-box">
<div className="ai-flag-header">
<span>تحلیل هوش مصنوعی (پرچم‌گذاری شده)</span>
{adEval.confidence && (
<span className="ai-confidence-badge">اطمینان: {Math.round(adEval.confidence * 100)}٪</span>
)}
</div>
<p className="ai-flag-reason">{adEval.reason}</p>
</div>
)}
<div className="ad-tag-list">
<span className="ad-tag">توکن: {adEval.ad.divar_token}</span>
{adEval.ad.category && (
<span className="ad-tag">{adEval.ad.category}</span>
)}
</div>
<div className="card-footer" style={{ borderTop: '1px solid var(--border-color)', paddingTop: '1rem', marginTop: 'auto' }}>
<a href={adEval.ad.url} target="_blank" rel="noopener noreferrer" className="btn btn-secondary btn-icon-only" title="مشاهده در دیوار" style={{ color: 'var(--info)' }}>
<ExternalLink />
</a>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* TAB 3: Logs View */}
{activeTab === 'logs' && (
<div className="tab-view active">
{loadingLogs ? (
<div className="loading-state">
<div className="spinner"></div>
<p>در حال دریافت تاریخچه اجرا...</p>
</div>
) : logs.length === 0 ? (
<div className="empty-state">
<AlertTriangle />
<p>هیچ لاگ تاریخی ثبت نشده است.</p>
</div>
) : (
<div className="table-container">
<table className="data-table">
<thead>
<tr>
<th>کرالر</th>
<th>شروع اجرا</th>
<th>پایان اجرا</th>
<th>وضعیت</th>
<th>آگهی یافت شده</th>
<th>پردازش AI</th>
<th>پرچم‌گذاری شده</th>
<th>لاگ خطا</th>
</tr>
</thead>
<tbody>
{logs.map(run => (
<tr key={run.id}>
<td style={{ fontWeight: 600 }}>{run.crawler_title}</td>
<td className="arabic-num-font">{formatDate(run.started_at)}</td>
<td className="arabic-num-font">{formatDate(run.finished_at)}</td>
<td>
<span className={`badge-status ${run.status.toLowerCase()}`}>
{run.status === 'RUNNING' && 'در حال اجرا'}
{run.status === 'SUCCESS' && 'موفق'}
{run.status === 'FAILED' && 'ناموفق'}
</span>
</td>
<td className="arabic-num-font">{run.ads_fetched_count}</td>
<td className="arabic-num-font">{run.ads_evaluated_count}</td>
<td className="arabic-num-font" style={{ color: run.ads_flagged_count > 0 ? 'var(--accent)' : 'inherit', fontWeight: run.ads_flagged_count > 0 ? 700 : 'normal' }}>
{run.ads_flagged_count}
</td>
<td style={{ maxWidth: '180px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={run.error_log}>
{run.error_log || '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</main>
</div>
{/* Form Dialog Modal */}
<div className={`modal-overlay ${modalOpen ? 'open' : ''}`}>
<div className="modal-card">
<div className="modal-header">
<h3>{editingCrawler ? 'ویرایش کرالر' : 'تعریف کرالر جدید'}</h3>
<button className="close-btn" onClick={closeCrawlerModal}>
<X />
</button>
</div>
<form onSubmit={handleFormSubmit}>
<div className="modal-body">
{formError && (
<div style={{
backgroundColor: 'rgba(239, 68, 68, 0.1)',
border: '1px solid var(--danger)',
padding: '10px',
borderRadius: '6px',
fontSize: '0.85rem',
color: '#fca5a5'
}}>
{formError}
</div>
)}
<div className="form-group">
<label>عنوان کرالر</label>
<input
type="text"
value={formTitle}
onChange={(e) => setFormTitle(e.target.value)}
placeholder="مثال: رهن کامل فوری نیاوران"
required
/>
</div>
<div className="form-group">
<label>لینک فیلتر دیوار (باید با 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?..."
required
/>
</div>
<div className="form-group">
<label>پرامپت ارزیابی هوش مصنوعی (AI)</label>
<textarea
value={formPrompt}
onChange={(e) => setFormPrompt(e.target.value)}
placeholder="بررسی کن آیا آگهی پول لازم و رهن کامل است یا خیر..."
rows={3}
required
/>
</div>
<div className="form-row">
<div className="form-group">
<label>بازه زمانی پایش</label>
<select value={formInterval} onChange={(e) => setFormInterval(e.target.value)}>
<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="form-group">
<label>آیدی کانال تلگرام (اختیاری)</label>
<input
type="text"
value={formTelegram}
onChange={(e) => setFormTelegram(e.target.value)}
placeholder="مثال: @my_alerts"
/>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>ساعت شروع پنجره مجاز</label>
<input
type="time"
value={formStartHour}
onChange={(e) => setFormStartHour(e.target.value)}
required
/>
</div>
<div className="form-group">
<label>ساعت پایان پنجره مجاز</label>
<input
type="time"
value={formEndHour}
onChange={(e) => setFormEndHour(e.target.value)}
required
/>
</div>
</div>
<div className="form-group toggle-group">
<span className="toggle-label" style={{ fontSize: '0.85rem' }}>کرالر فعال باشد</span>
<label className="switch-container">
<input
type="checkbox"
checked={formIsActive}
onChange={(e) => setFormIsActive(e.target.checked)}
/>
<span class="switch-slider"></span>
</label>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={closeCrawlerModal}>انصراف</button>
<button type="submit" className="btn btn-primary">ذخیره</button>
</div>
</form>
</div>
</div>
</div>
);
}
export default App;