|
|
@@ -1,5 +1,5 @@
|
|
|
import { useEffect, useState } from 'react';
|
|
|
-import { fetchTopPriceIps, fetchStats } from '../api';
|
|
|
+import { fetchTopPriceIps, fetchStats, fetchScrapeStats, type ScrapeStats } from '../api';
|
|
|
import { usePolling } from '../hooks/usePolling';
|
|
|
import './Dashboard.css';
|
|
|
|
|
|
@@ -10,12 +10,49 @@ function formatUptime(seconds: number): string {
|
|
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
|
|
}
|
|
|
|
|
|
+function LineChart({ data }: { data: { date: string; count: number }[] }) {
|
|
|
+ if (data.length === 0) return <div className="chart-empty">暂无数据</div>;
|
|
|
+ const W = 600, H = 100, PAD = { top: 8, right: 12, bottom: 24, left: 28 };
|
|
|
+ const innerW = W - PAD.left - PAD.right;
|
|
|
+ const innerH = H - PAD.top - PAD.bottom;
|
|
|
+ const maxVal = Math.max(...data.map(d => d.count), 1);
|
|
|
+ const x = (i: number) => PAD.left + (i / (data.length - 1 || 1)) * innerW;
|
|
|
+ const y = (v: number) => PAD.top + innerH - (v / maxVal) * innerH;
|
|
|
+ const points = data.map((d, i) => `${x(i)},${y(d.count)}`).join(' ');
|
|
|
+ const area = `M${x(0)},${y(data[0].count)} ` +
|
|
|
+ data.map((d, i) => `L${x(i)},${y(d.count)}`).join(' ') +
|
|
|
+ ` L${x(data.length - 1)},${PAD.top + innerH} L${x(0)},${PAD.top + innerH} Z`;
|
|
|
+ const labelIdxs = new Set([0, Math.floor(data.length / 2), data.length - 1]);
|
|
|
+ return (
|
|
|
+ <svg viewBox={`0 0 ${W} ${H}`} className="line-chart" preserveAspectRatio="none">
|
|
|
+ {[0, 0.5, 1].map(t => (
|
|
|
+ <line key={t} x1={PAD.left} y1={PAD.top + innerH * (1 - t)}
|
|
|
+ x2={PAD.left + innerW} y2={PAD.top + innerH * (1 - t)}
|
|
|
+ stroke="#1e2a3a" strokeWidth="1" />
|
|
|
+ ))}
|
|
|
+ {[0, Math.round(maxVal / 2), maxVal].map((v, i) => (
|
|
|
+ <text key={i} x={PAD.left - 4} y={y(v) + 4} textAnchor="end" fontSize="9" fill="#6b7a8d">{v}</text>
|
|
|
+ ))}
|
|
|
+ <path d={area} fill="rgba(0,212,255,0.08)" />
|
|
|
+ <polyline points={points} fill="none" stroke="#00d4ff" strokeWidth="1.5" />
|
|
|
+ {data.map((d, i) => <circle key={i} cx={x(i)} cy={y(d.count)} r="2.5" fill="#00d4ff" />)}
|
|
|
+ {data.map((d, i) => labelIdxs.has(i) && (
|
|
|
+ <text key={i} x={x(i)} y={H - 4} textAnchor="middle" fontSize="9" fill="#6b7a8d">{d.date.slice(5)}</text>
|
|
|
+ ))}
|
|
|
+ </svg>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+const STATUS_MAP: Record<string, string> = {
|
|
|
+ done: '完成', failed: '失败', running: '运行中', pending: '等待中',
|
|
|
+};
|
|
|
+
|
|
|
export function Dashboard() {
|
|
|
const { data: stats } = usePolling(fetchStats, 5000);
|
|
|
const { data: topIps } = usePolling(fetchTopPriceIps, 10000);
|
|
|
const [displayUptime, setDisplayUptime] = useState<number | null>(null);
|
|
|
+ const [scrapeStats, setScrapeStats] = useState<ScrapeStats | null>(null);
|
|
|
|
|
|
- // 每次从后端拿到 uptime 后,本地每秒递增
|
|
|
useEffect(() => {
|
|
|
if (stats == null) return;
|
|
|
setDisplayUptime(stats.uptime_seconds);
|
|
|
@@ -23,15 +60,23 @@ export function Dashboard() {
|
|
|
return () => clearInterval(timer);
|
|
|
}, [stats?.uptime_seconds != null ? Math.floor(stats.uptime_seconds / 5) : null]);
|
|
|
|
|
|
+ useEffect(() => {
|
|
|
+ fetchScrapeStats().then(setScrapeStats).catch(() => {});
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const ov = scrapeStats?.overview;
|
|
|
+ const successRate = ov && ov.total_jobs > 0
|
|
|
+ ? Math.round(ov.success_jobs / ov.total_jobs * 100) : 0;
|
|
|
+ const maxRank = Math.max(...(scrapeStats?.model_ranks.map(r => r.count) ?? [1]), 1);
|
|
|
+
|
|
|
return (
|
|
|
<div className="dashboard">
|
|
|
<header className="dash-header">
|
|
|
- <span className="dash-logo">◈ 哨兵监控</span>
|
|
|
- <span className="dash-status">
|
|
|
- <span className="dot dot--green" /> 运行中
|
|
|
- </span>
|
|
|
+ <span className="dash-logo">◈ 爬虫监控</span>
|
|
|
+ <span className="dash-status"><span className="dot dot--green" /> 运行中</span>
|
|
|
</header>
|
|
|
|
|
|
+ {/* 系统统计 */}
|
|
|
<div className="stat-grid">
|
|
|
<div className="stat-card">
|
|
|
<div className="stat-label">系统运行时间</div>
|
|
|
@@ -39,45 +84,107 @@ export function Dashboard() {
|
|
|
</div>
|
|
|
<div className="stat-card">
|
|
|
<div className="stat-label">价格接口请求数</div>
|
|
|
- <div className="stat-value neon-green">
|
|
|
- {stats ? stats.total_hits.toLocaleString() : '—'}
|
|
|
- </div>
|
|
|
+ <div className="stat-value neon-green">{stats ? stats.total_hits.toLocaleString() : '—'}</div>
|
|
|
</div>
|
|
|
<div className="stat-card">
|
|
|
<div className="stat-label">活跃 IP 数</div>
|
|
|
- <div className="stat-value neon-cyan">
|
|
|
- <span className="blink">✦</span> {stats ? stats.active_ips : '—'}
|
|
|
- </div>
|
|
|
+ <div className="stat-value neon-cyan"><span className="blink">✦</span> {stats ? stats.active_ips : '—'}</div>
|
|
|
</div>
|
|
|
<div className="stat-card">
|
|
|
<div className="stat-label">平均延迟</div>
|
|
|
- <div className="stat-value neon-cyan">
|
|
|
- <span className="blink">◈</span> {stats ? `${stats.avg_latency_ms.toFixed(0)}ms` : '—'}
|
|
|
- </div>
|
|
|
+ <div className="stat-value neon-cyan"><span className="blink">◈</span> {stats ? `${stats.avg_latency_ms.toFixed(0)}ms` : '—'}</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
+ {/* 价格接口调用来源 */}
|
|
|
<section className="geo-section">
|
|
|
- <div className="section-title">
|
|
|
- 价格接口调用来源 <span className="globe">📡</span>
|
|
|
- </div>
|
|
|
+ <div className="section-title">价格接口调用来源 <span>📡</span></div>
|
|
|
{topIps && topIps.length > 0 ? (
|
|
|
<ul className="geo-list">
|
|
|
{topIps.map((item) => (
|
|
|
<li key={item.ip} className="geo-item">
|
|
|
<span className="geo-country">{item.ip}</span>
|
|
|
- <div className="geo-bar-wrap">
|
|
|
- <div className="geo-bar" style={{ width: `${item.percentage}%` }} />
|
|
|
- </div>
|
|
|
+ <div className="geo-bar-wrap"><div className="geo-bar" style={{ width: `${item.percentage}%` }} /></div>
|
|
|
<span className="geo-pct">{item.hit_count} 次</span>
|
|
|
<span className="geo-pct geo-pct--dim">{item.percentage}%</span>
|
|
|
</li>
|
|
|
))}
|
|
|
</ul>
|
|
|
- ) : (
|
|
|
- <div className="empty-msg">暂无价格接口调用记录</div>
|
|
|
- )}
|
|
|
+ ) : <div className="empty-msg">暂无价格接口调用记录</div>}
|
|
|
</section>
|
|
|
+
|
|
|
+ {/* 爬虫统计卡片 */}
|
|
|
+ <div className="section-title" style={{ margin: '20px 0 10px' }}>爬虫统计</div>
|
|
|
+ <div className="stat-grid stat-grid--5">
|
|
|
+ <div className="stat-card">
|
|
|
+ <div className="stat-label">总爬取任务</div>
|
|
|
+ <div className="stat-value neon-cyan">{ov ? ov.total_jobs : '—'}</div>
|
|
|
+ </div>
|
|
|
+ <div className="stat-card">
|
|
|
+ <div className="stat-label">成功 / 失败</div>
|
|
|
+ <div className="stat-value stat-value--sm">
|
|
|
+ <span className="neon-green">{ov ? ov.success_jobs : '—'}</span>
|
|
|
+ <span style={{ color: 'var(--text-muted)' }}> / </span>
|
|
|
+ <span className="neon-red">{ov ? ov.failed_jobs : '—'}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div className="stat-card">
|
|
|
+ <div className="stat-label">成功率</div>
|
|
|
+ <div className="stat-value neon-green">{ov ? `${successRate}%` : '—'}</div>
|
|
|
+ </div>
|
|
|
+ <div className="stat-card">
|
|
|
+ <div className="stat-label">爬取模型数</div>
|
|
|
+ <div className="stat-value neon-cyan">{ov ? ov.total_models_scraped : '—'}</div>
|
|
|
+ </div>
|
|
|
+ <div className="stat-card">
|
|
|
+ <div className="stat-label">最近爬取</div>
|
|
|
+ <div className="stat-value stat-value--xs">
|
|
|
+ {ov?.last_scraped_at ? new Date(ov.last_scraped_at).toLocaleString() : '—'}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 近30天折线图 */}
|
|
|
+ <div className="dash-panel">
|
|
|
+ <div className="section-title">近30天每日爬取次数</div>
|
|
|
+ <LineChart data={scrapeStats?.daily_counts ?? []} />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="dash-row">
|
|
|
+ {/* 模型排行 */}
|
|
|
+ <div className="dash-panel dash-panel--half">
|
|
|
+ <div className="section-title">模型爬取次数 Top 15</div>
|
|
|
+ <div className="bar-list">
|
|
|
+ {scrapeStats?.model_ranks.length ? scrapeStats.model_ranks.map((r, i) => (
|
|
|
+ <div key={i} className="bar-row">
|
|
|
+ <span className="bar-label" title={r.model_name}>{r.model_name}</span>
|
|
|
+ <div className="bar-track">
|
|
|
+ <div className="bar-fill" style={{ width: `${(r.count / maxRank) * 100}%` }} />
|
|
|
+ </div>
|
|
|
+ <span className="bar-count">{r.count}</span>
|
|
|
+ </div>
|
|
|
+ )) : <div className="empty-msg">暂无数据</div>}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 最近任务 */}
|
|
|
+ <div className="dash-panel dash-panel--half">
|
|
|
+ <div className="section-title">最近任务</div>
|
|
|
+ <table className="dash-table">
|
|
|
+ <thead><tr><th>任务 ID</th><th>状态</th><th>模型数</th><th>时间</th></tr></thead>
|
|
|
+ <tbody>
|
|
|
+ {scrapeStats?.recent_jobs.map(j => (
|
|
|
+ <tr key={j.job_id}>
|
|
|
+ <td className="neon-cyan">{j.job_id.slice(0, 8)}…</td>
|
|
|
+ <td className={`status-${j.status}`}>{STATUS_MAP[j.status] ?? j.status}</td>
|
|
|
+ <td>{j.model_count}</td>
|
|
|
+ <td className="td-time">{new Date(j.created_at).toLocaleString()}</td>
|
|
|
+ </tr>
|
|
|
+ ))}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
);
|
|
|
}
|