/** 크롤 상태 — 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"; // 종료 사유 — 의미 기반 상태색(정상 회전=초록 계열, 차단=적색, 포트사망=주황, 중립=회색/파랑). const REASONS: Record = { budget: { label: "예산 선제 회전", color: "#0ca30c", desc: "정상 — 차단 전에 IP 교체(평판 보존)" }, window: { label: "시간창 만료", color: "#2a78d6", desc: "정상 — sticky 10분 주기 교체" }, idle: { label: "유휴 정리", color: "#898781", desc: "정상 — 한동안 검색 없어 브라우저 회수" }, shutdown: { label: "종료", color: "#c3c2b7", desc: "정상 — 워커 재시작/종료" }, rotate: { label: "기타 회전", color: "#9aa1ac", desc: "웜업 재시도 등" }, proxy_error: { label: "포트 사망", color: "#ec835a", desc: "주의 — 프록시 전송 실패로 교체" }, block: { label: "차단됨", color: "#d03b3b", 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(`/v1/lps/stats/ip-sessions?hours=${hours}`), refetchInterval: 30_000, }); const bot = useQuery({ queryKey: ["bot", hours], queryFn: () => get(`/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: "#9aa1ac", 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 (

크롤 상태

{RANGES.map((r) => ( ))}
{ip.isError && } {ip.data && ( blockCount === 0 ? (

✓ 기간 내 차단된 IP 세션 0건 — 요청 예산(선제 회전)이 잘 작동하고 있습니다

) : (

⚠ 예산 안에서도 차단된 세션 {blockCount}건 — 최소 {ip.data.block_min_requests}회 요청에서 차단됐습니다. 요청 예산을 그보다 낮게 유지하세요(현재 서버 설정은 toml [DecodoConfig].ip_request_budget)

) )}
{ip.isPending && } {ip.data && reasons.length === 0 && 기간 내 세션 없음 — 워커가 검색을 시작하면 쌓입니다} {reasons.length > 0 && (
{reasons.map((r) => )} [`${v}건`, name]} />
    {reasons.map((r) => (
  • {r.label} {r.value}
  • ))}
)} {reasons.length > 0 && (

초록(예산 선제)·파랑(시간창)이 대부분이면 건강한 상태입니다. 빨강(차단)이 보이면 예산 하향 신호.

)}
{ip.isPending && } {ip.data && ip.data.histogram.length === 0 && 데이터 없음} {ip.data && ip.data.histogram.length > 0 && ( <> [`${v}건`, "세션 수"]} labelFormatter={(l) => `IP당 ${l}회 요청`} /> {ip.data.block_min_requests != null && ( )}

X축 = 한 IP로 보낸 요청 수. 분포가 예산값에 몰려 있으면 정상, 붉은 기준선(차단 시작점)보다 예산이 낮아야 안전.

)}
{bot.isPending && } {bot.isError && } {bot.data && bot.data.hourly.length === 0 && 기간 내 차단 없음 🎉} {bot.data && bot.data.hourly.length > 0 && ( ({ ...h, x: dateShort(h.bucket) }))} margin={{ top: 6, right: 12, bottom: 0, left: -22 }}> [`${v}건`, "차단"]} /> )} {bot.data && bot.data.items.length === 0 && 기간 내 차단 없음} {bot.data && bot.data.items.length > 0 && (
{bot.data.items.map((b, i) => ( ))}
시각 검색어 요청# 포트 감지 근거
{hhmm(b.created_at)} {b.query || "—"} {b.ip_request_no ?? "—"} {b.proxy_port ?? "—"} {b.marker}
)}
{ip.data && ip.data.sessions.length === 0 && 세션 없음} {ip.data && ip.data.sessions.length > 0 && (
{ip.data.sessions.map((s, i) => { const r = REASONS[s.end_reason] ?? { label: s.end_reason, color: "#9aa1ac" }; return ( ); })}
종료 시각 소스 포트 요청 성공/차단 지속 종료 사유
{dateShort(s.created_at)} {s.source} {s.proxy_port ?? "—"} {s.requests} {s.ok_count}/{s.blocked_count} {durationSec(s.elapsed_sec)} {r.label}
)}
); }