o2o-negosium-original/lps-temp-fe/src/pages/Crawl.tsx
민헌 6eb71eb035 feat(lps-temp-fe): React 관리자 페이지 — 대시보드·작업큐·상품가격·크롤·비용
임시 단일 HTML 콘솔을 걷어내고 5페이지 관리자 앱으로 교체(협의:
모니터링+필수 액션, negodata 계열 경량 스택 — Vite+React+TS+Tailwind
+recharts+react-query+react-router).

- 대시보드: /ops 5초 폴링 스탯 타일 8종 + 임계 배너(서버 AlertConfig
  기본값 미러) + 세션 내 누적 큐 추이.
- 작업 큐: 상태 탭·상품 검색·페이지네이션·상세 패널(결과/원가/오류),
  DEAD 재큐 버튼.
- 상품·가격: 가격 추이 3선 + 몰별 최저가 바 + 검증 링크 + 수동 재검색.
  시리즈 색 엔터티 고정 — 네이버 #008a43 은 쿠팡 적색과의 적록색약
  분리(ΔE 14.5)·3:1 대비를 검증해 톤다운한 값.
- 크롤 상태: 종료사유 도넛(의미 기반 상태색)·세션당 요청 수 분포
  +차단 시작 기준선(예산 튜닝 뷰)·차단 추이/이력·세션 테이블.
- 비용: 시간별 원가 스택(AI vs 프록시)·건당 평균·평균 소요(축이 달라
  별도 차트 — 이중축 금지).
- 설정: API 주소·guard 키(localStorage → X-API-Key 자동 첨부).
  서버 설정 변경 UI 없음(toml 단일 소스 원칙).
- dev 는 vite 프록시(:9600)로 CORS 불필요. 5페이지 실렌더 스크린샷 검증.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 21:59:15 +09:00

231 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";
// 종료 사유 — 의미 기반 상태색(정상 회전=초록 계열, 차단=적색, 포트사망=주황, 중립=회색/파랑).
const REASONS: Record<string, { label: string; color: string; desc: string }> = {
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<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: "#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 (
<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="#d03b3b" strokeDasharray="4 3"
label={{ value: `차단 시작 ${ip.data.block_min_requests}회`, position: "top", fill: "#d03b3b", 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="#d03b3b" 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: "#9aa1ac" };
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>
);
}