Crawl 이 dead-600/ink-400/cost-ai 와 동일한 값을 raw hex 로 재복제해 토큰 변경 시 어긋나는 이중 정의 상태였다. 종료사유 팔레트를 상태 토큰(ok/run/warn/dead)+신규 neutral-300/400 으로 치환, ReferenceLine· 막대·폴백 hex 전부 var() 참조로. src 내 raw hex 0 확인. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
232 lines
13 KiB
TypeScript
232 lines
13 KiB
TypeScript
/** 크롤 상태 — IP 세션 종료 사유·요청 수 분포(예산 튜닝 뷰)·차단 이력. */
|
|
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { useState } from "react";
|
|
import {
|
|
Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis,
|
|
} from "recharts";
|
|
import { get } from "../api/client";
|
|
import type { BotStatsRes, IpSessionStatsRes } from "../api/types";
|
|
import { Card, Empty, ErrorNote, Loading } from "../components/ui";
|
|
import { dateShort, durationSec, hhmm } from "../lib/format";
|
|
|
|
// 종료 사유 — 의미 기반 상태색. 색은 전부 index.css @theme 토큰 참조(단일 소스) —
|
|
// raw hex 재정의 금지(토큰을 바꾸면 여기도 함께 바뀌어야 한다).
|
|
const REASONS: Record<string, { label: string; color: string; desc: string }> = {
|
|
budget: { label: "예산 선제 회전", color: "var(--color-ok-600)", desc: "정상 — 차단 전에 IP 교체(평판 보존)" },
|
|
window: { label: "시간창 만료", color: "var(--color-run-600)", desc: "정상 — sticky 10분 주기 교체" },
|
|
idle: { label: "유휴 정리", color: "var(--color-neutral-400)", desc: "정상 — 한동안 검색 없어 브라우저 회수" },
|
|
shutdown: { label: "종료", color: "var(--color-neutral-300)", desc: "정상 — 워커 재시작/종료" },
|
|
rotate: { label: "기타 회전", color: "var(--color-ink-400)", desc: "웜업 재시도 등" },
|
|
proxy_error: { label: "포트 사망", color: "var(--color-warn-600)", desc: "주의 — 프록시 전송 실패로 교체" },
|
|
block: { label: "차단됨", color: "var(--color-dead-600)", desc: "위험 — 예산 안에서도 차단(예산 하향 검토)" },
|
|
};
|
|
|
|
const RANGES = [
|
|
{ h: 24, label: "24시간" },
|
|
{ h: 168, label: "7일" },
|
|
{ h: 720, label: "30일" },
|
|
];
|
|
|
|
export default function Crawl() {
|
|
const [hours, setHours] = useState(168);
|
|
const ip = useQuery({
|
|
queryKey: ["ip-sessions", hours],
|
|
queryFn: () => get<IpSessionStatsRes>(`/v1/lps/stats/ip-sessions?hours=${hours}`),
|
|
refetchInterval: 30_000,
|
|
});
|
|
const bot = useQuery({
|
|
queryKey: ["bot", hours],
|
|
queryFn: () => get<BotStatsRes>(`/v1/lps/stats/bot?hours=${hours}`),
|
|
refetchInterval: 30_000,
|
|
});
|
|
|
|
const reasons = Object.entries(ip.data?.by_reason ?? {})
|
|
.map(([k, v]) => ({ key: k, ...(REASONS[k] ?? { label: k, color: "var(--color-ink-400)", desc: "" }), value: v }))
|
|
.sort((a, b) => b.value - a.value);
|
|
const totalSessions = reasons.reduce((s, r) => s + r.value, 0);
|
|
const blockCount = ip.data?.by_reason?.block ?? 0;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-[17px] font-bold tracking-tight">크롤 상태</h1>
|
|
<div role="tablist" aria-label="기간" className="ml-auto flex rounded-lg border border-line-200 bg-surface p-0.5">
|
|
{RANGES.map((r) => (
|
|
<button key={r.h} role="tab" aria-selected={hours === r.h} onClick={() => setHours(r.h)}
|
|
className={`rounded-md px-3 py-1.5 text-[12px] font-semibold ${hours === r.h ? "bg-primary-50 text-primary-700" : "text-ink-500 hover:text-ink-700"}`}>
|
|
{r.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{ip.isError && <ErrorNote error={ip.error} />}
|
|
|
|
{ip.data && (
|
|
blockCount === 0 ? (
|
|
<p className="rounded-lg bg-ok-50 px-3 py-2 text-[12.5px] font-semibold text-ok-600">
|
|
✓ 기간 내 차단된 IP 세션 0건 — 요청 예산(선제 회전)이 잘 작동하고 있습니다
|
|
</p>
|
|
) : (
|
|
<p role="alert" className="rounded-lg border border-dead-600/20 bg-dead-50 px-3 py-2 text-[12.5px] font-semibold text-dead-600">
|
|
⚠ 예산 안에서도 차단된 세션 {blockCount}건 — 최소 {ip.data.block_min_requests}회 요청에서 차단됐습니다.
|
|
요청 예산을 그보다 낮게 유지하세요(현재 서버 설정은 toml [DecodoConfig].ip_request_budget)
|
|
</p>
|
|
)
|
|
)}
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<Card title="IP 세션 종료 사유" hint={`총 ${totalSessions}건`}>
|
|
{ip.isPending && <Loading />}
|
|
{ip.data && reasons.length === 0 && <Empty>기간 내 세션 없음 — 워커가 검색을 시작하면 쌓입니다</Empty>}
|
|
{reasons.length > 0 && (
|
|
<div className="flex items-center gap-4">
|
|
<ResponsiveContainer width={150} height={150}>
|
|
<PieChart>
|
|
<Pie data={reasons} dataKey="value" nameKey="label" innerRadius={42} outerRadius={70}
|
|
paddingAngle={2} stroke="var(--color-surface)" strokeWidth={2} isAnimationActive={false}>
|
|
{reasons.map((r) => <Cell key={r.key} fill={r.color} />)}
|
|
</Pie>
|
|
<Tooltip formatter={(v: number, name: string) => [`${v}건`, name]} />
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
<ul className="min-w-0 flex-1 space-y-1.5 text-[12px]">
|
|
{reasons.map((r) => (
|
|
<li key={r.key} className="flex items-baseline gap-2">
|
|
<span className="mt-1 h-2 w-2 shrink-0 rounded-full" style={{ background: r.color }} aria-hidden />
|
|
<span className="font-semibold">{r.label}</span>
|
|
<span className="tnum ml-auto shrink-0 font-bold">{r.value}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
{reasons.length > 0 && (
|
|
<p className="mt-2 text-[11px] leading-relaxed text-ink-400">
|
|
초록(예산 선제)·파랑(시간창)이 대부분이면 건강한 상태입니다. 빨강(차단)이 보이면 예산 하향 신호.
|
|
</p>
|
|
)}
|
|
</Card>
|
|
|
|
<Card title="세션당 요청 수 분포" hint="예산 튜닝 근거">
|
|
{ip.isPending && <Loading />}
|
|
{ip.data && ip.data.histogram.length === 0 && <Empty>데이터 없음</Empty>}
|
|
{ip.data && ip.data.histogram.length > 0 && (
|
|
<>
|
|
<ResponsiveContainer width="100%" height={170}>
|
|
<BarChart data={ip.data.histogram} margin={{ top: 14, right: 12, bottom: 0, left: -22 }}>
|
|
<CartesianGrid stroke="var(--color-grid)" vertical={false} />
|
|
<XAxis dataKey="requests" tick={{ fill: "var(--color-ink-400)" }} tickLine={false}
|
|
axisLine={{ stroke: "var(--color-line-200)" }} label={undefined} />
|
|
<YAxis allowDecimals={false} tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={false} />
|
|
<Tooltip formatter={(v: number) => [`${v}건`, "세션 수"]} labelFormatter={(l) => `IP당 ${l}회 요청`} />
|
|
<Bar dataKey="count" fill="var(--color-cost-ai)" radius={[4, 4, 0, 0]} maxBarSize={36} isAnimationActive={false} />
|
|
{ip.data.block_min_requests != null && (
|
|
<ReferenceLine x={ip.data.block_min_requests} stroke="var(--color-dead-600)" strokeDasharray="4 3"
|
|
label={{ value: `차단 시작 ${ip.data.block_min_requests}회`, position: "top", fill: "var(--color-dead-600)", fontSize: 11, fontWeight: 600 }} />
|
|
)}
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
<p className="mt-1 text-[11px] leading-relaxed text-ink-400">
|
|
X축 = 한 IP로 보낸 요청 수. 분포가 예산값에 몰려 있으면 정상, 붉은 기준선(차단 시작점)보다 예산이 낮아야 안전.
|
|
</p>
|
|
</>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<Card title="차단 발생 추이" hint="시간대별">
|
|
{bot.isPending && <Loading />}
|
|
{bot.isError && <ErrorNote error={bot.error} />}
|
|
{bot.data && bot.data.hourly.length === 0 && <Empty>기간 내 차단 없음 🎉</Empty>}
|
|
{bot.data && bot.data.hourly.length > 0 && (
|
|
<ResponsiveContainer width="100%" height={150}>
|
|
<BarChart data={bot.data.hourly.map((h) => ({ ...h, x: dateShort(h.bucket) }))} margin={{ top: 6, right: 12, bottom: 0, left: -22 }}>
|
|
<CartesianGrid stroke="var(--color-grid)" vertical={false} />
|
|
<XAxis dataKey="x" tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={{ stroke: "var(--color-line-200)" }} minTickGap={40} />
|
|
<YAxis allowDecimals={false} tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={false} />
|
|
<Tooltip formatter={(v: number) => [`${v}건`, "차단"]} />
|
|
<Bar dataKey="count" fill="var(--color-dead-600)" radius={[4, 4, 0, 0]} maxBarSize={24} isAnimationActive={false} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
)}
|
|
</Card>
|
|
|
|
<Card title="최근 차단 이력" hint="최근 50건">
|
|
{bot.data && bot.data.items.length === 0 && <Empty>기간 내 차단 없음</Empty>}
|
|
{bot.data && bot.data.items.length > 0 && (
|
|
<div className="max-h-64 overflow-y-auto">
|
|
<table className="w-full text-left text-[12px]">
|
|
<thead>
|
|
<tr className="border-b border-line-200 text-[11px] text-ink-400">
|
|
<th className="pb-1 pr-2 font-semibold">시각</th>
|
|
<th className="pb-1 pr-2 font-semibold">검색어</th>
|
|
<th className="pb-1 pr-2 text-right font-semibold">요청#</th>
|
|
<th className="pb-1 pr-2 text-right font-semibold">포트</th>
|
|
<th className="pb-1 font-semibold">감지 근거</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{bot.data.items.map((b, i) => (
|
|
<tr key={i} className="border-b border-line-100">
|
|
<td className="tnum py-1.5 pr-2 text-ink-500">{hhmm(b.created_at)}</td>
|
|
<td className="max-w-32 truncate py-1.5 pr-2">{b.query || "—"}</td>
|
|
<td className="tnum py-1.5 pr-2 text-right">{b.ip_request_no ?? "—"}</td>
|
|
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{b.proxy_port ?? "—"}</td>
|
|
<td className="max-w-40 truncate py-1.5 text-[11px] text-ink-500">{b.marker}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
|
|
<Card title="최근 IP 세션" hint="최근 50건">
|
|
{ip.data && ip.data.sessions.length === 0 && <Empty>세션 없음</Empty>}
|
|
{ip.data && ip.data.sessions.length > 0 && (
|
|
<div className="max-h-72 overflow-y-auto">
|
|
<table className="w-full text-left text-[12px]">
|
|
<thead>
|
|
<tr className="border-b border-line-200 text-[11px] text-ink-400">
|
|
<th className="pb-1 pr-2 font-semibold">종료 시각</th>
|
|
<th className="pb-1 pr-2 font-semibold">소스</th>
|
|
<th className="pb-1 pr-2 text-right font-semibold">포트</th>
|
|
<th className="pb-1 pr-2 text-right font-semibold">요청</th>
|
|
<th className="pb-1 pr-2 text-right font-semibold">성공/차단</th>
|
|
<th className="pb-1 pr-2 text-right font-semibold">지속</th>
|
|
<th className="pb-1 font-semibold">종료 사유</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{ip.data.sessions.map((s, i) => {
|
|
const r = REASONS[s.end_reason] ?? { label: s.end_reason, color: "var(--color-ink-400)" };
|
|
return (
|
|
<tr key={i} className="border-b border-line-100">
|
|
<td className="tnum py-1.5 pr-2 text-ink-500">{dateShort(s.created_at)}</td>
|
|
<td className="py-1.5 pr-2">{s.source}</td>
|
|
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{s.proxy_port ?? "—"}</td>
|
|
<td className="tnum py-1.5 pr-2 text-right font-semibold">{s.requests}</td>
|
|
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{s.ok_count}/{s.blocked_count}</td>
|
|
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{durationSec(s.elapsed_sec)}</td>
|
|
<td className="py-1.5">
|
|
<span className="inline-flex items-center gap-1.5 text-[11.5px] font-semibold" style={{ color: r.color }}>
|
|
<span className="h-1.5 w-1.5 rounded-full" style={{ background: r.color }} aria-hidden />
|
|
{r.label}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|