feat(lps-admin): 관리자 페이지 디자인 개선 및 컴포넌트 통일

- 공용 프리미티브(ui.tsx): PageHeader·Segmented·Button·SearchForm·Legend·
  ScrollBox·ScrollTable·Modal + Card(fill/compact) 옵션
- 차트 공용 설정(lib/chart.ts): 축·그리드·툴팁 스타일 단일화
- 뷰포트 채우기 레이아웃 + content 영역 스크롤(Layout), 버튼 커서 복구
- 표: 헤더 밴드·행 좌우 패딩·고정/채움/auto 높이·sticky 헤더, 배지 줄바꿈 방지
- 작업 큐: 풀폭 표 + 행별 상세/JSON 모달(복사)
- 상품·가격: 가로 배치, Y축 0 기준, 시점 클릭→몰별 비교, 커스텀 툴팁·
  그라데이션 area, 몰별 컴팩트 표(소스 뱃지)
- 크롤 상태: 도넛 중앙 총계·라운드 세그먼트, 막대 그라데이션, 툴팁 통일
- 테스트 검색: 결과 통일(모달·표·소스 뱃지), 진행상태 좌측 이동, 초기화 전
  재검색 차단, 퍼널 가독성(감소량), 배송(로켓배송) 표기, 한 화면 컴팩트
- 사이드바: API 상태 상단 뱃지, 테스트 메뉴 배경색으로 구분

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
민헌 2026-07-16 08:48:43 +09:00
parent e6005b4f94
commit d111610555
11 changed files with 860 additions and 525 deletions

View File

@ -4,15 +4,14 @@ import { useQuery } from "@tanstack/react-query";
import { NavLink, Outlet } from "react-router"; import { NavLink, Outlet } from "react-router";
import { healthz } from "../api/client"; import { healthz } from "../api/client";
// action: 실제 비용이 드는 실행 페이지(모니터링과 성격이 다름) — 앰버(warn) 마커로 구분. // action: 실제 비용이 드는 실행 페이지(모니터링과 성격이 다름) — 다른 메뉴와 배경색(앰버)만 다르게.
// group: 이 항목 앞에 구분선(모니터링 ↔ 실행/설정 경계).
const MENU = [ const MENU = [
{ to: "/", label: "대시보드", end: true }, { to: "/", label: "대시보드", end: true },
{ to: "/jobs", label: "작업 큐" }, { to: "/jobs", label: "작업 큐" },
{ to: "/products", label: "상품·가격" }, { to: "/products", label: "상품·가격" },
{ to: "/crawl", label: "크롤 상태" }, { to: "/crawl", label: "크롤 상태" },
{ to: "/costs", label: "비용" }, { to: "/costs", label: "비용" },
{ to: "/test", label: "테스트 검색", action: true, group: true }, { to: "/test", label: "테스트 검색", action: true },
{ to: "/settings", label: "설정" }, { to: "/settings", label: "설정" },
]; ];
@ -20,55 +19,49 @@ export default function Layout() {
const health = useQuery({ queryKey: ["healthz"], queryFn: healthz, refetchInterval: 10_000 }); const health = useQuery({ queryKey: ["healthz"], queryFn: healthz, refetchInterval: 10_000 });
const up = health.data === true; const up = health.data === true;
return ( return (
<div className="mx-auto flex min-h-screen w-full max-w-[1280px] flex-col md:flex-row"> <div className="mx-auto flex h-dvh w-full max-w-[1280px] flex-col overflow-hidden md:flex-row">
{/* 모바일(<md): 상단 바 + 가로 스크롤 네비(터치 44px). md 이상: 좌측 고정 사이드바. */} {/* 모바일(<md): 상단 바 + 가로 스크롤 네비(터치 44px). md 이상: 좌측 고정 사이드바. */}
<aside className="w-full min-w-0 shrink-0 border-b border-line-200 px-3 py-3 md:w-[196px] md:border-b-0 md:border-r md:py-5"> <aside className="w-full min-w-0 shrink-0 border-b border-line-200 px-3 py-3 md:w-[196px] md:border-b-0 md:border-r md:py-5">
<div className="flex items-center justify-between md:block"> <div className="flex items-center justify-between md:block">
<div className="flex items-center gap-2.5 px-2 md:mb-6"> <div className="flex items-center gap-2.5 px-2 md:mb-3">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-gradient-to-br from-primary-600 to-primary-400 text-[14px] font-extrabold text-white">L</span> <span className="grid h-8 w-8 place-items-center rounded-lg bg-gradient-to-br from-primary-600 to-primary-400 text-[14px] font-extrabold text-white">L</span>
<div> <div>
<div className="text-[14px] font-bold leading-tight">LPS 관리자</div> <div className="text-[14px] font-bold leading-tight">LPS 관리자</div>
<div className="text-[11px] font-medium text-ink-400">인터넷 최저가 검색</div> <div className="text-[11px] font-medium text-ink-400">인터넷 최저가 검색</div>
</div> </div>
</div> </div>
<div className="flex items-center gap-2 px-2 text-[12px] font-semibold text-ink-500 md:hidden"> {/* API 헬스 — 상단 배지(모바일: 우측, 데스크톱: 제목 아래) */}
<span className={`h-2 w-2 rounded-full ${up ? "bg-ok-600" : "bg-dead-600"}`} aria-hidden /> <div className="shrink-0 px-2 md:mb-5">
{up ? "연결됨" : "연결 안 됨"} <span className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-semibold ${up ? "bg-ok-50 text-ok-600" : "bg-dead-50 text-dead-600"}`}>
<span className={`h-1.5 w-1.5 rounded-full ${up ? "bg-ok-600" : "bg-dead-600"}`} aria-hidden />
{up ? "API 연결됨" : "API 연결 안 됨"}
</span>
</div> </div>
</div> </div>
<nav aria-label="주 메뉴" className="mt-2 md:mt-0"> <nav aria-label="주 메뉴" className="mt-2 md:mt-0">
<ul className="flex items-center gap-1 overflow-x-auto md:flex-col md:items-stretch md:gap-0 md:space-y-0.5"> <ul className="flex items-center gap-1 overflow-x-auto md:flex-col md:items-stretch md:gap-0 md:space-y-0.5">
{MENU.map((m) => ( {MENU.map((m) => (
<li key={m.to} className="flex shrink-0 items-center"> <li key={m.to} className="flex shrink-0 items-center">
{/* 모니터링 ↔ 실행/설정 경계: 모바일=세로 구분선, 데스크톱=가로 구분선 */}
{m.group && <span className="mx-1 h-5 w-px bg-line-200 md:my-1 md:h-px md:w-full" aria-hidden />}
<NavLink <NavLink
to={m.to} to={m.to}
end={m.end} end={m.end}
className={({ isActive }) => className={({ isActive }) =>
`flex min-h-[44px] w-full items-center gap-1.5 whitespace-nowrap rounded-lg px-3 text-[13px] font-semibold transition-colors md:min-h-0 md:py-2 ${ `flex min-h-[44px] w-full items-center whitespace-nowrap rounded-lg px-3 text-[13px] font-semibold transition-colors md:min-h-0 md:py-2 ${
m.action m.action
? isActive ? "bg-warn-50 text-warn-700" : "text-warn-700 hover:bg-warn-50" ? isActive ? "bg-warn-600/20 text-warn-700" : "bg-warn-600/10 text-ink-700 hover:bg-warn-600/20"
: isActive ? "bg-primary-50 text-primary-700" : "text-ink-700 hover:bg-surface-2" : isActive ? "bg-primary-50 text-primary-700" : "text-ink-700 hover:bg-surface-2"
}` }`
} }
> >
{m.action && <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-warn-600" aria-hidden />}
{m.label} {m.label}
</NavLink> </NavLink>
</li> </li>
))} ))}
</ul> </ul>
</nav> </nav>
<div className="mt-8 hidden items-center gap-2 px-3 text-[12px] font-semibold text-ink-500 md:flex">
<span
className={`h-2 w-2 rounded-full ${up ? "bg-ok-600" : "bg-dead-600"}`}
aria-hidden
/>
{up ? "API 연결됨" : "API 연결 안 됨"}
</div>
</aside> </aside>
<main className="min-w-0 flex-1 px-4 py-4 md:px-6 md:py-5"> {/* content 영역만 스크롤 — 페이지가 h-full 로 채우면 스크롤 없음, 넘치면 여기서 스크롤. */}
<main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto px-4 py-4 md:px-6 md:py-5">
<Outlet /> <Outlet />
</main> </main>
</div> </div>

View File

@ -1,20 +1,24 @@
/** 공용 UI 조각 — 카드·스탯 타일·상태 배지·빈/오류 표시. 웹 표준 시맨틱 마크업 유지. */ /** 공용 UI 조각 — 카드·스탯 타일·상태 배지·빈/오류 표시 + 페이지 공통 컨트롤
* (헤더·세그먼트 탭·버튼·검색폼·범례·고정높이 표/스크롤). 웹 표준 시맨틱 마크업 유지. */
import type { ReactNode } from "react"; import { useEffect } from "react";
import type { ButtonHTMLAttributes, ReactNode } from "react";
import type { JobStatusName } from "../api/types"; import type { JobStatusName } from "../api/types";
export function Card({ title, hint, children, className = "" }: { // fill: 카드가 부모 높이를 채우고 본문이 남는 높이를 flex 로 차지(그래프·표가 높이 채우기용).
title?: ReactNode; hint?: ReactNode; children: ReactNode; className?: string; // compact: 패딩을 줄여 밀도 높은 레이아웃(한 화면에 많이 담기).
export function Card({ title, hint, children, className = "", fill = false, compact = false }: {
title?: ReactNode; hint?: ReactNode; children: ReactNode; className?: string; fill?: boolean; compact?: boolean;
}) { }) {
return ( return (
<section className={`rounded-xl border border-line-200 bg-surface shadow-[0_1px_2px_rgba(16,24,40,.04)] ${className}`}> <section className={`rounded-xl border border-line-200 bg-surface shadow-[0_1px_2px_rgba(16,24,40,.04)] ${fill ? "flex min-h-0 flex-col" : ""} ${className}`}>
{title !== undefined && ( {title !== undefined && (
<header className="flex items-center gap-2 border-b border-line-100 px-4 py-2.5"> <header className={`flex shrink-0 items-center gap-2 border-b border-line-100 ${compact ? "px-3 py-2" : "px-4 py-2.5"}`}>
<h2 className="text-[13px] font-bold tracking-tight">{title}</h2> <h2 className="text-[13px] font-bold tracking-tight">{title}</h2>
{hint && <span className="ml-auto text-[11px] text-ink-400">{hint}</span>} {hint && <div className="ml-auto text-[11px] text-ink-400">{hint}</div>}
</header> </header>
)} )}
<div className="p-4">{children}</div> <div className={`${compact ? "p-3" : "p-4"} ${fill ? "flex min-h-0 flex-1 flex-col" : ""}`}>{children}</div>
</section> </section>
); );
} }
@ -71,3 +75,145 @@ export function ErrorNote({ error }: { error: unknown }) {
export function Loading() { export function Loading() {
return <p className="py-8 text-center text-[13px] text-ink-400" aria-busy="true">불러오는 중…</p>; return <p className="py-8 text-center text-[13px] text-ink-400" aria-busy="true">불러오는 중…</p>;
} }
// ── 페이지 헤더 — h1 + 우측 컨트롤 슬롯(기간 탭 등). 모든 페이지 제목을 이걸로 통일. ──
export function PageHeader({ title, children }: { title: ReactNode; children?: ReactNode }) {
return (
<div className="flex items-center gap-3">
<h1 className="text-[17px] font-bold tracking-tight">{title}</h1>
{children && <div className="ml-auto flex items-center gap-2">{children}</div>}
</div>
);
}
// ── 세그먼트 탭 — 상태/기간/상위N 필터 공용(기존 4곳의 동일 마크업 통일). ──
export function Segmented<T extends string | number>({ options, value, onChange, ariaLabel, size = "md" }: {
options: readonly { v: T; label: ReactNode }[];
value: T; onChange: (v: T) => void; ariaLabel: string; size?: "sm" | "md";
}) {
const cell = size === "sm" ? "px-2 py-0.5 text-[11px]" : "px-3 py-1.5 text-[12px]";
return (
<div role="tablist" aria-label={ariaLabel} className="flex rounded-lg border border-line-200 bg-surface p-0.5">
{options.map((o) => (
<button key={String(o.v)} role="tab" aria-selected={value === o.v} onClick={() => onChange(o.v)}
className={`rounded-md font-semibold ${cell} ${value === o.v ? "bg-primary-50 text-primary-700" : "text-ink-500 hover:text-ink-700"}`}>
{o.label}
</button>
))}
</div>
);
}
// ── 버튼 — primary(실행 CTA)·outline(보조). 크기 sm/md/lg. 반복되던 파랑 버튼 통일. ──
export function Button({ variant = "primary", size = "md", className = "", ...props }: {
variant?: "primary" | "outline"; size?: "sm" | "md" | "lg";
} & ButtonHTMLAttributes<HTMLButtonElement>) {
const sizes = { sm: "px-2.5 py-1 text-[12px]", md: "px-3 py-1.5 text-[13px]", lg: "px-4 py-2 text-[13px]" };
const variants = {
primary: "bg-primary-600 text-white hover:bg-primary-700",
outline: "border border-line-200 text-ink-700 hover:bg-line-200 hover:text-ink-900",
};
return <button className={`cursor-pointer rounded-lg font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${sizes[size]} ${variants[variant]} ${className}`} {...props} />;
}
// ── 검색 폼 — input + 검색 버튼(Jobs·Products 동일 폼 통일). ──
export function SearchForm({ value, onChange, onSubmit, placeholder, className = "", inputClassName = "w-64" }: {
value: string; onChange: (v: string) => void; onSubmit: () => void;
placeholder: string; className?: string; inputClassName?: string;
}) {
return (
<form className={`flex gap-1.5 ${className}`} onSubmit={(e) => { e.preventDefault(); onSubmit(); }}>
<input value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} aria-label={placeholder}
className={`rounded-lg border border-line-200 bg-surface px-3 py-1.5 text-[13px] outline-none focus:border-primary-600 ${inputClassName}`} />
<Button type="submit">검색</Button>
</form>
);
}
// ── 범례 — 색 dot + 라벨(차트 아래, Dashboard·Costs·Products 통일). ──
export function Legend({ items, note }: {
items: readonly { label: ReactNode; color: string }[]; note?: ReactNode;
}) {
return (
<p className="mt-1 flex flex-wrap gap-4 text-[11px] font-semibold text-ink-500">
{items.map((it, i) => (
<span key={i}>
<span className="mr-1 inline-block h-2 w-2 rounded-full" style={{ background: it.color }} aria-hidden />
{it.label}
</span>
))}
{note && <span className="ml-auto font-medium text-ink-400">{note}</span>}
</p>
);
}
// ── 스크롤 컨테이너 — height 숫자면 고정 높이, "fill" 이면 부모의 남는 높이를 채움. ──
export function ScrollBox({ height = 420, className = "", children }: {
height?: number | "fill"; className?: string; children: ReactNode;
}) {
const fill = height === "fill";
return (
<div className={`overflow-y-auto ${fill ? "min-h-0 flex-1" : ""} ${className}`}
style={fill ? undefined : { height }}>{children}</div>
);
}
// ── 모달 — 배경 딤 + Esc/바깥클릭 닫기. actions 로 헤더 우측 버튼(복사 등) 배치. ──
export function Modal({ title, onClose, actions, children }: {
title: ReactNode; onClose: () => void; actions?: ReactNode; children: ReactNode;
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-ink-900/30" onClick={onClose} aria-hidden />
<div role="dialog" aria-modal="true"
className="relative flex max-h-[85vh] w-full max-w-2xl flex-col rounded-xl border border-line-200 bg-surface shadow-[0_10px_40px_rgba(16,24,40,.18)]">
<header className="flex shrink-0 items-center gap-2 border-b border-line-100 px-4 py-3">
<h2 className="text-[14px] font-bold tracking-tight">{title}</h2>
<div className="ml-auto flex items-center gap-2">
{actions}
<button onClick={onClose} aria-label="닫기"
className="grid h-7 w-7 place-items-center rounded-md text-ink-400 hover:bg-surface-2 hover:text-ink-700">✕</button>
</div>
</header>
<div className="min-h-0 flex-1 overflow-auto p-4">{children}</div>
</div>
</div>
);
}
// ── 표 — thead sticky. height 숫자=고정 높이, "fill"=남는 높이 채움, "auto"=내용 높이.
// fixed=table-fixed(좁은 폭에서 열 넘침·줄바꿈 방지), 열별 width 지정 가능. ──
export function ScrollTable({ height = 420, dense = false, fixed = false, columns, children }: {
height?: number | "fill" | "auto"; dense?: boolean; fixed?: boolean;
columns: readonly { key: string; label: ReactNode; align?: "right"; width?: string }[];
children: ReactNode;
}) {
const pad = dense ? "pb-1.5 pr-2 pt-1.5" : "pb-2 pr-3 pt-2";
const fill = height === "fill";
const auto = height === "auto";
// 행 좌우 여백: 첫/마지막 셀에 padding(헤더·본문 공통). 헤더는 bg-surface-2 밴드로 카드 제목바와 구분.
const edge = dense ? "[&_th:first-child]:pl-3 [&_td:first-child]:pl-3 [&_th:last-child]:pr-3 [&_td:last-child]:pr-3"
: "[&_th:first-child]:pl-4 [&_td:first-child]:pl-4 [&_th:last-child]:pr-4 [&_td:last-child]:pr-4";
return (
<div className={`overflow-auto ${fill ? "min-h-0 flex-1" : ""}`} style={fill || auto ? undefined : { height }}>
<table className={`w-full text-left ${fixed ? "table-fixed" : ""} ${dense ? "text-[12px]" : "text-[13px]"} ${edge}`}>
<thead>
<tr>
{columns.map((c) => (
<th key={c.key} style={c.width ? { width: c.width } : undefined}
className={`sticky top-0 z-10 whitespace-nowrap border-b border-line-200 bg-surface-2 text-[11px] font-semibold text-ink-500 ${pad} ${c.align === "right" ? "text-right" : ""}`}>
{c.label}
</th>
))}
</tr>
</thead>
<tbody>{children}</tbody>
</table>
</div>
);
}

View File

@ -49,10 +49,20 @@
"Apple SD Gothic Neo", "Noto Sans KR", sans-serif; "Apple SD Gothic Neo", "Noto Sans KR", sans-serif;
} }
/* 앱을 뷰포트에 고정 — 페이지(body) 스크롤 없음. 스크롤은 content(main) 영역에서만. */
html, body, #root {
height: 100%;
}
body { body {
@apply bg-page text-ink-900 font-sans text-[14px] antialiased; @apply bg-page text-ink-900 font-sans text-[14px] antialiased;
} }
/* Tailwind v4 는 버튼 기본 커서를 default 로 바꿈 — 활성 버튼은 pointer 로 복구. */
button:not(:disabled) {
cursor: pointer;
}
/* 테이블 숫자 열 정렬 */ /* 테이블 숫자 열 정렬 */
.tnum { .tnum {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;

View File

@ -0,0 +1,35 @@
/** recharts 공용 축·그리드 props — 반복되던 XAxis/YAxis/Grid 설정 단일화.
* 색은 index.css @theme 토큰 참조(단일 소스). 사용: <XAxis dataKey="x" {...xAxisProps} minTickGap={40} /> */
export const gridProps = { stroke: "var(--color-grid)", vertical: false } as const;
// X축: 하단 눈금 + 옅은 축선(기준선 역할).
export const xAxisProps = {
tick: { fill: "var(--color-ink-400)" },
tickLine: false,
axisLine: { stroke: "var(--color-line-200)" },
} as const;
// Y축: 눈금만(축선 없음 — 그리드가 대신함).
export const yAxisProps = {
tick: { fill: "var(--color-ink-400)" },
tickLine: false,
axisLine: false,
} as const;
// 툴팁 — 둥근 카드·부드러운 그림자·토큰 색(기본 recharts 툴팁을 앱 톤으로 통일).
// 사용: <Tooltip {...tooltipProps} formatter={...} /> (막대엔 cursor={barCursor} 추가)
export const tooltipProps = {
contentStyle: {
borderRadius: 12,
border: "1px solid var(--color-line-200)",
background: "var(--color-surface)",
boxShadow: "0 8px 24px rgba(16,24,40,.14)",
padding: "8px 12px",
},
labelStyle: { color: "var(--color-ink-500)", fontWeight: 700, marginBottom: 4, fontSize: 12 },
itemStyle: { padding: "1px 0", fontSize: 12 },
} as const;
// 막대 hover 커서 — 옅은 배경 하이라이트(기본 회색 박스 대신).
export const barCursor = { fill: "color-mix(in srgb, var(--color-ink-900) 4%, transparent)" } as const;

View File

@ -7,13 +7,14 @@ import {
} from "recharts"; } from "recharts";
import { get } from "../api/client"; import { get } from "../api/client";
import type { CostStatsRes } from "../api/types"; import type { CostStatsRes } from "../api/types";
import { Card, Empty, ErrorNote, Loading, StatTile } from "../components/ui"; import { Card, Empty, ErrorNote, Legend, Loading, PageHeader, Segmented, StatTile } from "../components/ui";
import { barCursor, gridProps, tooltipProps, xAxisProps, yAxisProps } from "../lib/chart";
import { dateShort, usd } from "../lib/format"; import { dateShort, usd } from "../lib/format";
const RANGES = [ const RANGES = [
{ h: 24, label: "24시간" }, { v: 24, label: "24시간" },
{ h: 48, label: "48시간" }, { v: 48, label: "48시간" },
{ h: 168, label: "7일" }, { v: 168, label: "7일" },
]; ];
export default function Costs() { export default function Costs() {
@ -32,68 +33,66 @@ export default function Costs() {
const perSearch = totals.jobs > 0 ? totals.total / totals.jobs : null; const perSearch = totals.jobs > 0 ? totals.total / totals.jobs : null;
return ( return (
<div className="space-y-4"> <div className="flex h-full min-h-0 flex-col gap-4">
<div className="flex items-center gap-3"> <PageHeader title="비용">
<h1 className="text-[17px] font-bold tracking-tight">비용</h1> <Segmented ariaLabel="기간" options={RANGES} value={hours} onChange={setHours} />
<div role="tablist" aria-label="기간" className="ml-auto flex rounded-lg border border-line-200 bg-surface p-0.5"> </PageHeader>
{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>
{cost.isError && <ErrorNote error={cost.error} />} {cost.isError && <ErrorNote error={cost.error} />}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4"> <div className="grid shrink-0 grid-cols-2 gap-3 md:grid-cols-4">
<StatTile label="완료 검색" value={totals.jobs} sub="기간 내" /> <StatTile label="완료 검색" value={totals.jobs} sub="기간 내" />
<StatTile label="총 비용" value={usd(totals.total, 2)} sub={`AI ${usd(totals.ai, 2)} + 프록시 ${usd(totals.proxy, 2)}`} /> <StatTile label="총 비용" value={usd(totals.total, 2)} sub={`AI ${usd(totals.ai, 2)} + 프록시 ${usd(totals.proxy, 2)}`} />
<StatTile label="검색 1건당" value={perSearch === null ? "—" : usd(perSearch)} sub="평균 원가" /> <StatTile label="검색 1건당" value={perSearch === null ? "—" : usd(perSearch)} sub="평균 원가" />
<StatTile label="프록시 비중" value={totals.total > 0 ? `${Math.round((totals.proxy / totals.total) * 100)}%` : "—"} sub="대역폭이 원가의 대부분" /> <StatTile label="프록시 비중" value={totals.total > 0 ? `${Math.round((totals.proxy / totals.total) * 100)}%` : "—"} sub="대역폭이 원가의 대부분" />
</div> </div>
<Card title="시간별 검색원가" hint="AI vs 프록시 대역폭"> <Card fill className="flex-[3]" title="시간별 검색원가" hint="AI vs 프록시 대역폭">
{cost.isPending && <Loading />} {cost.isPending && <Loading />}
{cost.data && buckets.length === 0 && <Empty>기간 내 완료된 검색이 없습니다 — 워커를 켜고 검색을 돌리면 채워집니다</Empty>} {cost.data && buckets.length === 0 && (
<div className="grid h-full place-items-center"><Empty>기간 내 완료된 검색이 없습니다 — 워커를 켜고 검색을 돌리면 채워집니다</Empty></div>
)}
{buckets.length > 0 && ( {buckets.length > 0 && (
<> <>
<ResponsiveContainer width="100%" height={220}> <div className="min-h-0 flex-1">
<BarChart data={buckets} margin={{ top: 6, right: 12, bottom: 0, left: -10 }}> <ResponsiveContainer width="100%" height="100%">
<CartesianGrid stroke="var(--color-grid)" vertical={false} /> <BarChart data={buckets} margin={{ top: 6, right: 12, bottom: 0, left: -10 }}>
<XAxis dataKey="x" tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={{ stroke: "var(--color-line-200)" }} minTickGap={40} /> <CartesianGrid {...gridProps} />
<YAxis tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={false} tickFormatter={(v: number) => `$${v}`} /> <XAxis dataKey="x" {...xAxisProps} minTickGap={40} />
<Tooltip formatter={(v: number, name: string) => [usd(v), name]} <YAxis {...yAxisProps} tickFormatter={(v: number) => `$${v}`} />
labelFormatter={(l, payload) => { <Tooltip {...tooltipProps} cursor={barCursor} formatter={(v: number, name: string) => [usd(v), name]}
const jobs = (payload?.[0]?.payload as { jobs?: number } | undefined)?.jobs; labelFormatter={(l, payload) => {
return `${l}${jobs != null ? ` · 검색 ${jobs}건` : ""}`; const jobs = (payload?.[0]?.payload as { jobs?: number } | undefined)?.jobs;
}} /> return `${l}${jobs != null ? ` · 검색 ${jobs}건` : ""}`;
<Bar dataKey="ai_usd" name="AI" stackId="c" fill="var(--color-cost-ai)" maxBarSize={28} isAnimationActive={false} /> }} />
<Bar dataKey="proxy_usd" name="프록시" stackId="c" fill="var(--color-cost-proxy)" radius={[4, 4, 0, 0]} maxBarSize={28} isAnimationActive={false} /> <Bar dataKey="ai_usd" name="AI" stackId="c" fill="var(--color-cost-ai)" maxBarSize={28} isAnimationActive={false} />
</BarChart> <Bar dataKey="proxy_usd" name="프록시" stackId="c" fill="var(--color-cost-proxy)" radius={[4, 4, 0, 0]} maxBarSize={28} isAnimationActive={false} />
</ResponsiveContainer> </BarChart>
<p className="mt-1 flex gap-4 text-[11px] font-semibold text-ink-500"> </ResponsiveContainer>
<span><span className="mr-1 inline-block h-2 w-2 rounded-full bg-cost-ai" aria-hidden />AI (판정·검색어)</span> </div>
<span><span className="mr-1 inline-block h-2 w-2 rounded-full bg-cost-proxy" aria-hidden />프록시 대역폭 (DECODO)</span> <Legend items={[
</p> { label: "AI (판정·검색어)", color: "var(--color-cost-ai)" },
{ label: "프록시 대역폭 (DECODO)", color: "var(--color-cost-proxy)" },
]} />
</> </>
)} )}
</Card> </Card>
<Card title="검색 1건 평균 소요 시간" hint="시간별 · 상승하면 경합/차단 신호"> <Card fill className="flex-[2]" title="검색 1건 평균 소요 시간" hint="시간별 · 상승하면 경합/차단 신호">
{buckets.length === 0 ? ( {buckets.length === 0 ? (
<Empty>데이터 없음</Empty> <div className="grid h-full place-items-center"><Empty>데이터 없음</Empty></div>
) : ( ) : (
<ResponsiveContainer width="100%" height={160}> <div className="min-h-0 flex-1">
<LineChart data={buckets} margin={{ top: 6, right: 12, bottom: 0, left: -10 }}> <ResponsiveContainer width="100%" height="100%">
<CartesianGrid stroke="var(--color-grid)" vertical={false} /> <LineChart data={buckets} margin={{ top: 6, right: 12, bottom: 0, left: -10 }}>
<XAxis dataKey="x" tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={{ stroke: "var(--color-line-200)" }} minTickGap={40} /> <CartesianGrid {...gridProps} />
<YAxis tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={false} tickFormatter={(v: number) => `${(v / 1000).toFixed(0)}s`} /> <XAxis dataKey="x" {...xAxisProps} minTickGap={40} />
<Tooltip formatter={(v: number) => [`${(v / 1000).toFixed(1)}초`, "평균 소요"]} /> <YAxis {...yAxisProps} tickFormatter={(v: number) => `${(v / 1000).toFixed(0)}s`} />
<Line type="monotone" dataKey="avg_ms" name="평균 소요" stroke="var(--color-primary-600)" strokeWidth={2} dot={false} isAnimationActive={false} /> <Tooltip {...tooltipProps} formatter={(v: number) => [`${(v / 1000).toFixed(1)}초`, "평균 소요"]} />
</LineChart> <Line type="monotone" dataKey="avg_ms" name="평균 소요" stroke="var(--color-primary-600)" strokeWidth={2} dot={false} isAnimationActive={false} />
</ResponsiveContainer> </LineChart>
</ResponsiveContainer>
</div>
)} )}
</Card> </Card>
</div> </div>

View File

@ -7,9 +7,28 @@ import {
} from "recharts"; } from "recharts";
import { get } from "../api/client"; import { get } from "../api/client";
import type { BotStatsRes, IpSessionStatsRes } from "../api/types"; import type { BotStatsRes, IpSessionStatsRes } from "../api/types";
import { Card, Empty, ErrorNote, Loading } from "../components/ui"; import { Card, Empty, ErrorNote, Loading, PageHeader, ScrollTable, Segmented } from "../components/ui";
import { barCursor, gridProps, tooltipProps, xAxisProps, yAxisProps } from "../lib/chart";
import { dateShort, durationSec, hhmm } from "../lib/format"; import { dateShort, durationSec, hhmm } from "../lib/format";
const BOT_COLS = [
{ key: "time", label: "시각" },
{ key: "query", label: "검색어" },
{ key: "reqno", label: "요청#", align: "right" },
{ key: "port", label: "포트", align: "right" },
{ key: "marker", label: "감지 근거" },
] as const;
const SESSION_COLS = [
{ key: "end", label: "종료 시각" },
{ key: "source", label: "소스" },
{ key: "port", label: "포트", align: "right" },
{ key: "req", label: "요청", align: "right" },
{ key: "okblock", label: "성공/차단", align: "right" },
{ key: "dur", label: "지속", align: "right" },
{ key: "reason", label: "종료 사유" },
] as const;
// 종료 사유 — 의미 기반 상태색. 색은 전부 index.css @theme 토큰 참조(단일 소스) — // 종료 사유 — 의미 기반 상태색. 색은 전부 index.css @theme 토큰 참조(단일 소스) —
// raw hex 재정의 금지(토큰을 바꾸면 여기도 함께 바뀌어야 한다). // raw hex 재정의 금지(토큰을 바꾸면 여기도 함께 바뀌어야 한다).
const REASONS: Record<string, { label: string; color: string; desc: string }> = { const REASONS: Record<string, { label: string; color: string; desc: string }> = {
@ -23,9 +42,9 @@ const REASONS: Record<string, { label: string; color: string; desc: string }> =
}; };
const RANGES = [ const RANGES = [
{ h: 24, label: "24시간" }, { v: 24, label: "24시간" },
{ h: 168, label: "7일" }, { v: 168, label: "7일" },
{ h: 720, label: "30일" }, { v: 720, label: "30일" },
]; ];
export default function Crawl() { export default function Crawl() {
@ -49,17 +68,9 @@ export default function Crawl() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-3"> <PageHeader title="크롤 상태">
<h1 className="text-[17px] font-bold tracking-tight">크롤 상태</h1> <Segmented ariaLabel="기간" options={RANGES} value={hours} onChange={setHours} />
<div role="tablist" aria-label="기간" className="ml-auto flex rounded-lg border border-line-200 bg-surface p-0.5"> </PageHeader>
{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.isError && <ErrorNote error={ip.error} />}
@ -82,14 +93,22 @@ export default function Crawl() {
{ip.data && reasons.length === 0 && <Empty>기간 내 세션 없음 — 워커가 검색을 시작하면 쌓입니다</Empty>} {ip.data && reasons.length === 0 && <Empty>기간 내 세션 없음 — 워커가 검색을 시작하면 쌓입니다</Empty>}
{reasons.length > 0 && ( {reasons.length > 0 && (
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* 고정 크기 차트 — ResponsiveContainer 불필요(dev 경고 발생원) */} {/* 고정 크기 차트 — ResponsiveContainer 불필요(dev 경고 발생원). 중앙에 총계 오버레이. */}
<PieChart width={150} height={150}> <div className="relative shrink-0">
<Pie data={reasons} dataKey="value" nameKey="label" innerRadius={42} outerRadius={70} <PieChart width={150} height={150}>
paddingAngle={2} stroke="var(--color-surface)" strokeWidth={2} isAnimationActive={false}> <Pie data={reasons} dataKey="value" nameKey="label" innerRadius={46} outerRadius={70}
{reasons.map((r) => <Cell key={r.key} fill={r.color} />)} paddingAngle={2} cornerRadius={4} stroke="var(--color-surface)" strokeWidth={2} isAnimationActive={false}>
</Pie> {reasons.map((r) => <Cell key={r.key} fill={r.color} />)}
<Tooltip formatter={(v: number, name: string) => [`${v}건`, name]} /> </Pie>
</PieChart> {/* 툴팁 제거 — 오른쪽 범례가 사유별 건수를 모두 보여줘 중복이고, 중앙 총계와 겹침 방지 */}
</PieChart>
<div className="pointer-events-none absolute inset-0 grid place-items-center">
<div className="text-center">
<div className="tnum text-[20px] font-bold leading-none">{totalSessions}</div>
<div className="mt-0.5 text-[10px] font-medium text-ink-400">세션</div>
</div>
</div>
</div>
<ul className="min-w-0 flex-1 space-y-1.5 text-[12px]"> <ul className="min-w-0 flex-1 space-y-1.5 text-[12px]">
{reasons.map((r) => ( {reasons.map((r) => (
<li key={r.key} className="flex items-baseline gap-2"> <li key={r.key} className="flex items-baseline gap-2">
@ -108,25 +127,32 @@ export default function Crawl() {
)} )}
</Card> </Card>
<Card title="세션당 요청 수 분포" hint="예산 튜닝 근거"> <Card fill title="세션당 요청 수 분포" hint="예산 튜닝 근거">
{ip.isPending && <Loading />} {ip.isPending && <Loading />}
{ip.data && ip.data.histogram.length === 0 && <Empty>데이터 없음</Empty>} {ip.data && ip.data.histogram.length === 0 && <div className="grid h-full place-items-center"><Empty>데이터 없음</Empty></div>}
{ip.data && ip.data.histogram.length > 0 && ( {ip.data && ip.data.histogram.length > 0 && (
<> <>
<ResponsiveContainer width="100%" height={170}> <div className="min-h-0 flex-1">
<BarChart data={ip.data.histogram} margin={{ top: 14, right: 12, bottom: 0, left: -22 }}> <ResponsiveContainer width="100%" height="100%">
<CartesianGrid stroke="var(--color-grid)" vertical={false} /> <BarChart data={ip.data.histogram} margin={{ top: 14, right: 12, bottom: 0, left: -22 }}>
<XAxis dataKey="requests" tick={{ fill: "var(--color-ink-400)" }} tickLine={false} <defs>
axisLine={{ stroke: "var(--color-line-200)" }} label={undefined} /> <linearGradient id="histBar" x1="0" y1="0" x2="0" y2="1">
<YAxis allowDecimals={false} tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={false} /> <stop offset="0%" stopColor="var(--color-cost-ai)" stopOpacity={0.95} />
<Tooltip formatter={(v: number) => [`${v}건`, "세션 수"]} labelFormatter={(l) => `IP당 ${l}회 요청`} /> <stop offset="100%" stopColor="var(--color-cost-ai)" stopOpacity={0.55} />
<Bar dataKey="count" fill="var(--color-cost-ai)" radius={[4, 4, 0, 0]} maxBarSize={36} isAnimationActive={false} /> </linearGradient>
{ip.data.block_min_requests != null && ( </defs>
<ReferenceLine x={ip.data.block_min_requests} stroke="var(--color-dead-600)" strokeDasharray="4 3" <CartesianGrid {...gridProps} />
label={{ value: `차단 시작 ${ip.data.block_min_requests}회`, position: "top", fill: "var(--color-dead-600)", fontSize: 11, fontWeight: 600 }} /> <XAxis dataKey="requests" {...xAxisProps} />
)} <YAxis allowDecimals={false} {...yAxisProps} />
</BarChart> <Tooltip {...tooltipProps} cursor={barCursor} formatter={(v: number) => [`${v}건`, "세션 수"]} labelFormatter={(l) => `IP당 ${l}회 요청`} />
</ResponsiveContainer> <Bar dataKey="count" fill="url(#histBar)" radius={[5, 5, 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>
</div>
<p className="mt-1 text-[11px] leading-relaxed text-ink-400"> <p className="mt-1 text-[11px] leading-relaxed text-ink-400">
X축 = 한 IP로 보낸 요청 수. 분포가 예산값에 몰려 있으면 정상, 붉은 기준선(차단 시작점)보다 예산이 낮아야 안전. X축 = 한 IP로 보낸 요청 수. 분포가 예산값에 몰려 있으면 정상, 붉은 기준선(차단 시작점)보다 예산이 낮아야 안전.
</p> </p>
@ -136,50 +162,45 @@ export default function Crawl() {
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<Card title="차단 발생 추이" hint="시간대별"> <Card fill title="차단 발생 추이" hint="시간대별">
{bot.isPending && <Loading />} {bot.isPending && <Loading />}
{bot.isError && <ErrorNote error={bot.error} />} {bot.isError && <ErrorNote error={bot.error} />}
{bot.data && bot.data.hourly.length === 0 && <Empty>기간 내 차단 없음 <span aria-hidden>🎉</span></Empty>} {bot.data && bot.data.hourly.length === 0 && <div className="grid h-full place-items-center"><Empty>기간 내 차단 없음 <span aria-hidden>🎉</span></Empty></div>}
{bot.data && bot.data.hourly.length > 0 && ( {bot.data && bot.data.hourly.length > 0 && (
<ResponsiveContainer width="100%" height={150}> <div className="min-h-0 flex-1">
<BarChart data={bot.data.hourly.map((h) => ({ ...h, x: dateShort(h.bucket) }))} margin={{ top: 6, right: 12, bottom: 0, left: -22 }}> <ResponsiveContainer width="100%" height="100%">
<CartesianGrid stroke="var(--color-grid)" vertical={false} /> <BarChart data={bot.data.hourly.map((h) => ({ ...h, x: dateShort(h.bucket) }))} margin={{ top: 6, right: 12, bottom: 0, left: -22 }}>
<XAxis dataKey="x" tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={{ stroke: "var(--color-line-200)" }} minTickGap={40} /> <defs>
<YAxis allowDecimals={false} tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={false} /> <linearGradient id="blockBar" x1="0" y1="0" x2="0" y2="1">
<Tooltip formatter={(v: number) => [`${v}건`, "차단"]} /> <stop offset="0%" stopColor="var(--color-dead-600)" stopOpacity={0.95} />
<Bar dataKey="count" fill="var(--color-dead-600)" radius={[4, 4, 0, 0]} maxBarSize={24} isAnimationActive={false} /> <stop offset="100%" stopColor="var(--color-dead-600)" stopOpacity={0.55} />
</BarChart> </linearGradient>
</ResponsiveContainer> </defs>
<CartesianGrid {...gridProps} />
<XAxis dataKey="x" {...xAxisProps} minTickGap={40} />
<YAxis allowDecimals={false} {...yAxisProps} />
<Tooltip {...tooltipProps} cursor={barCursor} formatter={(v: number) => [`${v}건`, "차단"]} />
<Bar dataKey="count" fill="url(#blockBar)" radius={[5, 5, 0, 0]} maxBarSize={28} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div>
)} )}
</Card> </Card>
<Card title="최근 차단 이력" hint="최근 50건"> <Card title="최근 차단 이력" hint="최근 50건">
{bot.data && bot.data.items.length === 0 && <Empty>기간 내 차단 없음</Empty>} {bot.data && bot.data.items.length === 0 && <Empty>기간 내 차단 없음</Empty>}
{bot.data && bot.data.items.length > 0 && ( {bot.data && bot.data.items.length > 0 && (
<div className="max-h-64 overflow-y-auto"> <ScrollTable height={240} dense columns={BOT_COLS}>
<table className="w-full text-left text-[12px]"> {bot.data.items.map((b, i) => (
<thead> <tr key={i} className="border-b border-line-100">
<tr className="border-b border-line-200 text-[11px] text-ink-400"> <td className="tnum py-1.5 pr-2 text-ink-500">{hhmm(b.created_at)}</td>
<th className="pb-1 pr-2 font-semibold">시각</th> <td className="max-w-32 truncate py-1.5 pr-2">{b.query || "—"}</td>
<th className="pb-1 pr-2 font-semibold">검색어</th> <td className="tnum py-1.5 pr-2 text-right">{b.ip_request_no ?? "—"}</td>
<th className="pb-1 pr-2 text-right font-semibold">요청#</th> <td className="tnum py-1.5 pr-2 text-right text-ink-500">{b.proxy_port ?? "—"}</td>
<th className="pb-1 pr-2 text-right font-semibold">포트</th> <td className="max-w-40 truncate py-1.5 text-[11px] text-ink-500">{b.marker}</td>
<th className="pb-1 font-semibold">감지 근거</th> </tr>
</tr> ))}
</thead> </ScrollTable>
<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> </Card>
</div> </div>
@ -187,42 +208,27 @@ export default function Crawl() {
<Card title="최근 IP 세션" hint="최근 50건"> <Card title="최근 IP 세션" hint="최근 50건">
{ip.data && ip.data.sessions.length === 0 && <Empty>세션 없음</Empty>} {ip.data && ip.data.sessions.length === 0 && <Empty>세션 없음</Empty>}
{ip.data && ip.data.sessions.length > 0 && ( {ip.data && ip.data.sessions.length > 0 && (
<div className="max-h-72 overflow-y-auto"> <ScrollTable height={320} dense columns={SESSION_COLS}>
<table className="w-full text-left text-[12px]"> {ip.data.sessions.map((s, i) => {
<thead> const r = REASONS[s.end_reason] ?? { label: s.end_reason, color: "var(--color-ink-400)" };
<tr className="border-b border-line-200 text-[11px] text-ink-400"> return (
<th className="pb-1 pr-2 font-semibold">종료 시각</th> <tr key={i} className="border-b border-line-100">
<th className="pb-1 pr-2 font-semibold">소스</th> <td className="tnum py-1.5 pr-2 text-ink-500">{dateShort(s.created_at)}</td>
<th className="pb-1 pr-2 text-right font-semibold">포트</th> <td className="py-1.5 pr-2">{s.source}</td>
<th className="pb-1 pr-2 text-right font-semibold">요청</th> <td className="tnum py-1.5 pr-2 text-right text-ink-500">{s.proxy_port ?? "—"}</td>
<th className="pb-1 pr-2 text-right font-semibold">성공/차단</th> <td className="tnum py-1.5 pr-2 text-right font-semibold">{s.requests}</td>
<th className="pb-1 pr-2 text-right font-semibold">지속</th> <td className="tnum py-1.5 pr-2 text-right text-ink-500">{s.ok_count}/{s.blocked_count}</td>
<th className="pb-1 font-semibold">종료 사유</th> <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-[12px] 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> </tr>
</thead> );
<tbody> })}
{ip.data.sessions.map((s, i) => { </ScrollTable>
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-[12px] 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> </Card>
</div> </div>

View File

@ -7,7 +7,8 @@ import {
} from "recharts"; } from "recharts";
import { get } from "../api/client"; import { get } from "../api/client";
import type { OpsSnapshot } from "../api/types"; import type { OpsSnapshot } from "../api/types";
import { Card, ErrorNote, StatTile } from "../components/ui"; import { Card, ErrorNote, Legend, PageHeader, StatTile } from "../components/ui";
import { gridProps, tooltipProps, xAxisProps, yAxisProps } from "../lib/chart";
import { durationSec, usd } from "../lib/format"; import { durationSec, usd } from "../lib/format";
// 알림 임계 기본값 미러 — 서버 [AlertConfig] 와 동일 기본(서버가 조정했으면 다를 수 있음, 참고용 배너). // 알림 임계 기본값 미러 — 서버 [AlertConfig] 와 동일 기본(서버가 조정했으면 다를 수 있음, 참고용 배너).
@ -44,8 +45,8 @@ export default function Dashboard() {
} }
return ( return (
<div className="space-y-4"> <div className="flex h-full min-h-0 flex-col gap-4">
<h1 className="text-[17px] font-bold tracking-tight">대시보드</h1> <PageHeader title="대시보드" />
{ops.isError && <ErrorNote error={ops.error} />} {ops.isError && <ErrorNote error={ops.error} />}
{alerts.map((a) => ( {alerts.map((a) => (
@ -71,28 +72,32 @@ export default function Dashboard() {
<StatTile label="DB 풀" value={d ? `${d.pool_pct}%` : "—"} sub={d ? `${d.pool_checked_out}/${d.pool_capacity} 사용 중` : undefined} /> <StatTile label="DB 풀" value={d ? `${d.pool_pct}%` : "—"} sub={d ? `${d.pool_checked_out}/${d.pool_capacity} 사용 중` : undefined} />
</div> </div>
<Card title="큐 추이" hint="5초 간격 · 이 화면을 열어둔 동안 누적"> <Card fill className="flex-1" title="큐 추이" hint="5초 간격 · 이 화면을 열어둔 동안 누적">
{trend.current.length < 2 ? ( <div className="min-h-0 flex-1">
<p className="py-6 text-center text-[12px] text-ink-400">수집 중… 잠시 후 그래프가 나타납니다</p> {trend.current.length < 2 ? (
) : ( <div className="grid h-full place-items-center">
<ResponsiveContainer width="100%" height={180}> <p className="text-[12px] text-ink-400">수집 중… 잠시 후 그래프가 나타납니다</p>
<LineChart data={trend.current} margin={{ top: 6, right: 12, bottom: 0, left: -18 }}> </div>
<CartesianGrid stroke="var(--color-grid)" vertical={false} /> ) : (
<XAxis dataKey="t" tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={{ stroke: "var(--color-line-200)" }} minTickGap={48} /> <ResponsiveContainer width="100%" height="100%">
<YAxis allowDecimals={false} tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={false} /> <LineChart data={trend.current} margin={{ top: 6, right: 12, bottom: 0, left: -18 }}>
<Tooltip formatter={(v: number, name: string) => [v, name === "pending" ? "대기" : "처리중"]} /> <CartesianGrid {...gridProps} />
<Line type="monotone" dataKey="pending" name="대기" stroke="var(--color-warn-600)" strokeWidth={2} dot={false} isAnimationActive={false} /> <XAxis dataKey="t" {...xAxisProps} minTickGap={48} />
<Line type="monotone" dataKey="running" name="처리중" stroke="var(--color-run-600)" strokeWidth={2} dot={false} isAnimationActive={false} /> <YAxis allowDecimals={false} {...yAxisProps} />
</LineChart> <Tooltip {...tooltipProps} formatter={(v: number, name: string) => [v, name === "pending" ? "대기" : "처리중"]} />
</ResponsiveContainer> <Line type="monotone" dataKey="pending" name="대기" stroke="var(--color-warn-600)" strokeWidth={2} dot={false} isAnimationActive={false} />
)} <Line type="monotone" dataKey="running" name="처리중" stroke="var(--color-run-600)" strokeWidth={2} dot={false} isAnimationActive={false} />
<p className="mt-1 flex gap-4 text-[11px] font-semibold text-ink-500"> </LineChart>
<span><span className="mr-1 inline-block h-2 w-2 rounded-full bg-warn-600" aria-hidden />대기</span> </ResponsiveContainer>
<span><span className="mr-1 inline-block h-2 w-2 rounded-full bg-run-600" aria-hidden />처리중</span> )}
</p> </div>
<Legend items={[
{ label: "대기", color: "var(--color-warn-600)" },
{ label: "처리중", color: "var(--color-run-600)" },
]} />
</Card> </Card>
<p className="text-[11px] text-ink-400"> <p className="shrink-0 text-[11px] text-ink-400">
임계 배너는 서버 알림 기본값 기준입니다(서버 toml 에서 조정했으면 실제 알림과 다를 수 있음). 임계 배너는 서버 알림 기본값 기준입니다(서버 toml 에서 조정했으면 실제 알림과 다를 수 있음).
전체 룰은 lps/docs/operations.md 참고. 전체 룰은 lps/docs/operations.md 참고.
</p> </p>

View File

@ -1,12 +1,24 @@
/** 작업 큐 — 잡 목록(필터·검색·페이지네이션) + 상세 패널 + DEAD 재큐. */ /** 작업 큐 — 잡 목록(필터·검색·페이지네이션) 표 + 행별 상세/JSON 모달 + DEAD 재큐. */
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { get, post } from "../api/client"; import { get, post } from "../api/client";
import type { JobDetailRes, JobListItem, JobListRes, JobStatusName, RequeueRes } from "../api/types"; import type { JobDetailRes, JobListItem, JobListRes, JobStatusName, RequeueRes } from "../api/types";
import { Card, Empty, ErrorNote, Loading, StatusBadge } from "../components/ui"; import { Button, Card, Empty, ErrorNote, Loading, Modal, PageHeader, ScrollTable, SearchForm, Segmented, StatusBadge } from "../components/ui";
import { dateShort, usd, won } from "../lib/format"; import { dateShort, usd, won } from "../lib/format";
type DetailView = "detail" | "json";
const COLS = [
{ key: "product", label: "상품" },
{ key: "status", label: "상태" },
{ key: "outcome", label: "결과" },
{ key: "price", label: "최저가", align: "right" },
{ key: "cost", label: "원가", align: "right" },
{ key: "time", label: "요청 시각" },
{ key: "actions", label: "", align: "right" },
] as const;
const PAGE = 20; const PAGE = 20;
const TABS: { v: JobStatusName | ""; label: string }[] = [ const TABS: { v: JobStatusName | ""; label: string }[] = [
{ v: "", label: "전체" }, { v: "", label: "전체" },
@ -21,7 +33,10 @@ export default function Jobs() {
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [qInput, setQInput] = useState(""); const [qInput, setQInput] = useState("");
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [selected, setSelected] = useState<JobListItem | null>(null); const [modalJob, setModalJob] = useState<JobListItem | null>(null);
const [modalView, setModalView] = useState<DetailView>("detail");
const open = (j: JobListItem, view: DetailView) => { setModalView(view); setModalJob(j); };
const params = new URLSearchParams({ limit: String(PAGE), offset: String(page * PAGE) }); const params = new URLSearchParams({ limit: String(PAGE), offset: String(page * PAGE) });
if (status) params.set("status", status); if (status) params.set("status", status);
@ -35,21 +50,12 @@ export default function Jobs() {
const pages = Math.max(1, Math.ceil((list.data?.total ?? 0) / PAGE)); const pages = Math.max(1, Math.ceil((list.data?.total ?? 0) / PAGE));
return ( return (
<div className="space-y-4"> <div className="flex h-full min-h-0 flex-col gap-4">
<h1 className="text-[17px] font-bold tracking-tight">작업 큐</h1> <PageHeader title="작업 큐" />
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<div role="tablist" aria-label="상태 필터" className="flex rounded-lg border border-line-200 bg-surface p-0.5"> <Segmented ariaLabel="상태 필터" options={TABS} value={status}
{TABS.map((t) => ( onChange={(v) => { setStatus(v); setPage(0); }} />
<button key={t.v} role="tab" aria-selected={status === t.v}
onClick={() => { setStatus(t.v); setPage(0); }}
className={`rounded-md px-3 py-1.5 text-[12px] font-semibold ${
status === t.v ? "bg-primary-50 text-primary-700" : "text-ink-500 hover:text-ink-700"
}`}>
{t.label}
</button>
))}
</div>
<button onClick={() => { const v = q === "TEST-" ? "" : "TEST-"; setQ(v); setQInput(v); setPage(0); }} <button onClick={() => { const v = q === "TEST-" ? "" : "TEST-"; setQ(v); setQInput(v); setPage(0); }}
aria-pressed={q === "TEST-"} aria-pressed={q === "TEST-"}
className={`rounded-lg border px-3 py-1.5 text-[12px] font-semibold ${ className={`rounded-lg border px-3 py-1.5 text-[12px] font-semibold ${
@ -57,119 +63,130 @@ export default function Jobs() {
}`}> }`}>
테스트만 테스트만
</button> </button>
<form className="ml-auto flex gap-1.5" onSubmit={(e) => { e.preventDefault(); setQ(qInput.trim()); setPage(0); }}> <SearchForm className="ml-auto" inputClassName="w-56" placeholder="상품코드·상품명 검색"
<input value={qInput} onChange={(e) => setQInput(e.target.value)} placeholder="상품코드·상품명 검색" aria-label="상품코드·상품명 검색" value={qInput} onChange={setQInput} onSubmit={() => { setQ(qInput.trim()); setPage(0); }} />
className="w-56 rounded-lg border border-line-200 bg-surface px-3 py-1.5 text-[13px] outline-none focus:border-primary-600" />
<button type="submit" className="rounded-lg bg-primary-600 px-3 py-1.5 text-[12px] font-semibold text-white hover:bg-primary-700">검색</button>
</form>
</div> </div>
<div className="grid gap-4 lg:grid-cols-[1fr_360px]"> <Card fill className="min-h-0 flex-1" title={`작업 ${list.data?.total ?? "—"}건`} hint="15초마다 갱신">
<Card title={`작업 ${list.data?.total ?? "—"}건`} hint="15초마다 갱신"> {list.isPending && <Loading />}
{list.isPending && <Loading />} {list.isError && <ErrorNote error={list.error} />}
{list.isError && <ErrorNote error={list.error} />} {list.data && list.data.items.length === 0 && <Empty>조건에 맞는 작업이 없습니다</Empty>}
{list.data && list.data.items.length === 0 && <Empty>조건에 맞는 작업이 없습니다</Empty>} {list.data && list.data.items.length > 0 && (
{list.data && list.data.items.length > 0 && ( <ScrollTable height="fill" columns={COLS}>
<div className="overflow-x-auto"> {list.data.items.map((j) => (
<table className="w-full text-left text-[13px]"> <tr key={j.job_id} className="border-b border-line-100 hover:bg-surface-2">
<thead> <td className="py-2 pr-3">
<tr className="border-b border-line-200 text-[11px] text-ink-400"> <div className="font-semibold">{j.product_name || "—"}</div>
<th className="pb-1.5 pr-3 font-semibold">상품</th> <div className="text-[11px] text-ink-400">{j.product_code}</div>
<th className="pb-1.5 pr-3 font-semibold">상태</th> </td>
<th className="pb-1.5 pr-3 font-semibold">결과</th> <td className="py-2 pr-3"><StatusBadge status={j.status} /></td>
<th className="pb-1.5 pr-3 text-right font-semibold">최저가</th> <td className="py-2 pr-3">{j.outcome === "found" ? "찾음" : j.outcome === "not_found" ? "없음" : "—"}</td>
<th className="pb-1.5 pr-3 text-right font-semibold">원가</th> <td className="tnum py-2 pr-3 text-right">{won(j.final_lowest)}</td>
<th className="pb-1.5 font-semibold">요청 시각</th> <td className="tnum py-2 pr-3 text-right text-ink-500">{usd(j.cost_usd)}</td>
</tr> <td className="tnum py-2 pr-3 text-[12px] text-ink-500">{dateShort(j.created_at)}</td>
</thead> <td className="py-2 text-right">
<tbody> <div className="flex justify-end gap-1.5">
{list.data.items.map((j) => ( <Button variant="outline" size="sm" onClick={() => open(j, "detail")}>상세</Button>
<tr key={j.job_id} onClick={() => setSelected(j)} tabIndex={0} <Button variant="outline" size="sm" onClick={() => open(j, "json")}>JSON</Button>
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setSelected(j); } }} </div>
aria-selected={selected?.job_id === j.job_id} </td>
className={`cursor-pointer border-b border-line-100 hover:bg-surface-2 focus-visible:bg-primary-50/60 ${selected?.job_id === j.job_id ? "bg-primary-50/60" : ""}`}> </tr>
<td className="py-2 pr-3"> ))}
<div className="font-semibold">{j.product_name || "—"}</div> </ScrollTable>
<div className="text-[11px] text-ink-400">{j.product_code}</div> )}
</td> {pages > 1 && (
<td className="py-2 pr-3"><StatusBadge status={j.status} /></td> <nav aria-label="페이지" className="mt-3 flex shrink-0 items-center justify-end gap-2 text-[12px]">
<td className="py-2 pr-3">{j.outcome === "found" ? "찾음" : j.outcome === "not_found" ? "없음" : "—"}</td> <Button variant="outline" size="sm" disabled={page === 0} onClick={() => setPage((p) => p - 1)}>이전</Button>
<td className="tnum py-2 pr-3 text-right">{won(j.final_lowest)}</td> <span className="tnum text-ink-500">{page + 1} / {pages}</span>
<td className="tnum py-2 pr-3 text-right text-ink-500">{usd(j.cost_usd)}</td> <Button variant="outline" size="sm" disabled={page + 1 >= pages} onClick={() => setPage((p) => p + 1)}>다음</Button>
<td className="tnum py-2 text-[12px] text-ink-500">{dateShort(j.created_at)}</td> </nav>
</tr> )}
))} </Card>
</tbody>
</table>
</div>
)}
{pages > 1 && (
<nav aria-label="페이지" className="mt-3 flex items-center justify-end gap-2 text-[12px]">
<button disabled={page === 0} onClick={() => setPage((p) => p - 1)}
className="rounded-md border border-line-200 px-2.5 py-1 font-semibold disabled:opacity-40">이전</button>
<span className="tnum text-ink-500">{page + 1} / {pages}</span>
<button disabled={page + 1 >= pages} onClick={() => setPage((p) => p + 1)}
className="rounded-md border border-line-200 px-2.5 py-1 font-semibold disabled:opacity-40">다음</button>
</nav>
)}
</Card>
<JobDetail job={selected} onClose={() => setSelected(null)} /> {modalJob && (
</div> <JobModal job={modalJob} view={modalView} onView={setModalView} onClose={() => setModalJob(null)} />
)}
</div> </div>
); );
} }
function JobDetail({ job, onClose }: { job: JobListItem | null; onClose: () => void }) { // 행별 "상세"/"JSON" 버튼이 여는 모달. 상단 토글로 두 뷰 전환, JSON 뷰엔 복사 버튼.
function JobModal({ job, view, onView, onClose }: {
job: JobListItem; view: DetailView; onView: (v: DetailView) => void; onClose: () => void;
}) {
const qc = useQueryClient(); const qc = useQueryClient();
const detail = useQuery({ const detail = useQuery({
queryKey: ["job", job?.job_id], queryKey: ["job", job.job_id],
queryFn: () => get<JobDetailRes>(`/v1/lps/jobs/${job!.job_id}`), queryFn: () => get<JobDetailRes>(`/v1/lps/jobs/${job.job_id}`),
enabled: !!job,
}); });
const requeue = useMutation({ const requeue = useMutation({
mutationFn: () => post<RequeueRes>(`/v1/lps/jobs/${job!.job_id}/requeue`), mutationFn: () => post<RequeueRes>(`/v1/lps/jobs/${job.job_id}/requeue`),
onSuccess: () => qc.invalidateQueries({ queryKey: ["jobs"] }), onSuccess: () => qc.invalidateQueries({ queryKey: ["jobs"] }),
}); });
const [copied, setCopied] = useState(false);
if (!job) return <Card title="상세"><Empty>왼쪽 목록에서 작업을 선택하세요</Empty></Card>;
const out = detail.data?.output as Record<string, any> | undefined; const out = detail.data?.output as Record<string, any> | undefined;
const jsonText = out ? JSON.stringify(out, null, 2) : "";
const copyJson = async () => {
try {
await navigator.clipboard.writeText(jsonText);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch { /* 클립보드 권한 없음 — 무시 */ }
};
return ( return (
<Card title="상세" hint={<button onClick={onClose} className="font-semibold text-ink-400 hover:text-ink-700">닫기</button>}> <Modal
<dl className="space-y-2 text-[13px]"> title={<>상세 <span className="font-medium text-ink-400">— {job.product_name || job.product_code}</span></>}
<Row k="작업 ID"><span className="tnum break-all text-[12px]">{job.job_id}</span></Row> onClose={onClose}
<Row k="상품">{job.product_name || "—"}{job.product_code && <span className="text-ink-400"> ({job.product_code})</span>}</Row> actions={
<Row k="상태"><StatusBadge status={job.status} /> <span className="text-ink-400">시도 {job.attempts}/{job.max_attempts}</span></Row> <>
{job.last_error && <Row k="오류"><span className="break-all text-dead-600">{job.last_error}</span></Row>} <Segmented ariaLabel="보기 방식" size="sm" value={view}
{out?.outcome && <Row k="결과">{out.outcome === "found" ? "같은 상품을 찾음" : "같은 상품 없음"}</Row>} options={[{ v: "detail", label: "상세" }, { v: "json", label: "JSON" }]} onChange={onView} />
{out?.lowest && <Row k="최저가"><b>{won(out.lowest.price)}</b> <span className="text-ink-400">({out.lowest.source} · {out.lowest.mall_name || "—"})</span></Row>} {view === "json" && (
{out?.metrics?.cost && ( <Button variant="outline" size="sm" disabled={!out} onClick={copyJson}>{copied ? "복사됨 ✓" : "복사"}</Button>
<Row k="검색 원가"> )}
{usd(out.metrics.cost.total_usd)} <span className="text-ink-400">(AI {usd(out.metrics.cost.ai_usd)} + 프록시 {usd(out.metrics.cost.proxy_usd)})</span> </>
</Row> }
)} >
{out?.metrics?.duration_ms != null && <Row k="소요">{(out.metrics.duration_ms / 1000).toFixed(1)}초</Row>} {view === "detail" ? (
</dl> <>
<dl className="space-y-2 text-[13px]">
<Row k="작업 ID"><span className="tnum break-all text-[12px]">{job.job_id}</span></Row>
<Row k="상품">{job.product_name || "—"}{job.product_code && <span className="text-ink-400"> ({job.product_code})</span>}</Row>
<Row k="상태"><StatusBadge status={job.status} /> <span className="text-ink-400">시도 {job.attempts}/{job.max_attempts}</span></Row>
{job.last_error && <Row k="오류"><span className="break-all text-dead-600">{job.last_error}</span></Row>}
{detail.isPending && <Row k="결과"><span className="text-ink-400">불러오는 중…</span></Row>}
{out?.outcome && <Row k="결과">{out.outcome === "found" ? "같은 상품을 찾음" : "같은 상품 없음"}</Row>}
{out?.lowest && <Row k="최저가"><b>{won(out.lowest.price)}</b> <span className="text-ink-400">({out.lowest.source} · {out.lowest.mall_name || "—"})</span></Row>}
{out?.metrics?.cost && (
<Row k="검색 원가">
{usd(out.metrics.cost.total_usd)} <span className="text-ink-400">(AI {usd(out.metrics.cost.ai_usd)} + 프록시 {usd(out.metrics.cost.proxy_usd)})</span>
</Row>
)}
{out?.metrics?.duration_ms != null && <Row k="소요">{(out.metrics.duration_ms / 1000).toFixed(1)}초</Row>}
</dl>
{job.status === "DEAD" && ( {job.status === "DEAD" && (
<div className="mt-4 border-t border-line-100 pt-3"> <div className="mt-4 border-t border-line-100 pt-3">
<button onClick={() => requeue.mutate()} disabled={requeue.isPending} <Button size="lg" className="w-full" onClick={() => requeue.mutate()} disabled={requeue.isPending}>
className="w-full rounded-lg bg-primary-600 px-3 py-2 text-[13px] font-semibold text-white hover:bg-primary-700 disabled:opacity-50"> {requeue.isPending ? "재시도 요청 중…" : "다시 검색하기 (재큐)"}
{requeue.isPending ? "재시도 요청 중…" : "다시 검색하기 (재큐)"} </Button>
</button> {requeue.isError && <p className="mt-2 text-[12px] font-medium text-dead-600">{(requeue.error as Error).message}</p>}
{requeue.isError && <p className="mt-2 text-[12px] font-medium text-dead-600">{(requeue.error as Error).message}</p>} {requeue.isSuccess && <p className="mt-2 text-[12px] font-medium text-ok-600">대기열에 다시 넣었습니다 — 워커가 곧 처리합니다</p>}
{requeue.isSuccess && <p className="mt-2 text-[12px] font-medium text-ok-600">대기열에 다시 넣었습니다 — 워커가 곧 처리합니다</p>} </div>
</div> )}
</>
) : detail.isPending ? (
<Loading />
) : detail.isError ? (
<ErrorNote error={detail.error} />
) : out ? (
<pre className="w-max min-w-full rounded-lg bg-surface-2 p-3 text-[11px] leading-relaxed">{jsonText}</pre>
) : (
<Empty>이 작업에는 원본 결과가 없습니다 (완료 전이거나 실패)</Empty>
)} )}
</Modal>
{out && (
<details className="mt-3">
<summary className="cursor-pointer text-[12px] font-semibold text-ink-400">원본 결과(JSON)</summary>
<pre className="mt-1 max-h-64 overflow-auto rounded-lg bg-surface-2 p-2 text-[11px] leading-relaxed">{JSON.stringify(out, null, 2)}</pre>
</details>
)}
</Card>
); );
} }

View File

@ -3,11 +3,12 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { import {
CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Area, CartesianGrid, ComposedChart, Line, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis,
} from "recharts"; } from "recharts";
import { get, post } from "../api/client"; import { get, post } from "../api/client";
import type { PriceHistoryRes, PricePoint, ProductItem, ProductListRes, SearchRes } from "../api/types"; import type { PriceHistoryRes, PricePoint, ProductItem, ProductListRes, SearchRes } from "../api/types";
import { Card, Empty, ErrorNote, Loading } from "../components/ui"; import { Button, Card, Empty, ErrorNote, Legend, Loading, PageHeader, ScrollBox, ScrollTable, SearchForm, Segmented } from "../components/ui";
import { gridProps, xAxisProps, yAxisProps } from "../lib/chart";
import { dateShort, timeAgo, won } from "../lib/format"; import { dateShort, timeAgo, won } from "../lib/format";
// 시리즈 색은 엔터티 고정(네이버 초록은 CVD 분리 검증 통과 톤) — 순서·색 재배정 금지. // 시리즈 색은 엔터티 고정(네이버 초록은 CVD 분리 검증 통과 톤) — 순서·색 재배정 금지.
@ -17,6 +18,34 @@ const SERIES = [
{ key: "final", label: "최종 최저가", color: "var(--color-final)" }, { key: "final", label: "최종 최저가", color: "var(--color-final)" },
] as const; ] as const;
// 커스텀 툴팁 — 둥근 카드·부드러운 그림자·토큰 색. 값 없는(못 찾은) 소스는 생략, 시리즈 순서 고정.
function ChartTooltip({ active, payload, label }: {
active?: boolean;
payload?: { dataKey?: string | number; value?: number; name?: string }[];
label?: string;
}) {
if (!active || !payload?.length) return null;
const byKey = new Map(payload.map((p) => [p.dataKey, p]));
return (
<div className="rounded-xl border border-line-200 bg-surface px-3 py-2 shadow-[0_8px_24px_rgba(16,24,40,.14)]">
<div className="mb-1.5 text-[11px] font-semibold text-ink-400">{label}</div>
<ul className="space-y-1">
{SERIES.map((s) => {
const p = byKey.get(s.key);
if (p?.value == null) return null;
return (
<li key={s.key} className="flex items-center gap-2 text-[12px]">
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: s.color }} aria-hidden />
<span className="text-ink-500">{s.label}</span>
<span className="tnum ml-auto pl-6 font-bold text-ink-900">{won(p.value)}</span>
</li>
);
})}
</ul>
</div>
);
}
export default function Products() { export default function Products() {
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [qInput, setQInput] = useState(""); const [qInput, setQInput] = useState("");
@ -28,26 +57,26 @@ export default function Products() {
}); });
return ( return (
<div className="space-y-4"> <div className="flex h-full min-h-0 flex-col gap-4">
<h1 className="text-[17px] font-bold tracking-tight">상품·가격</h1> <PageHeader title="상품·가격" />
<form className="flex gap-1.5" onSubmit={(e) => { e.preventDefault(); setQ(qInput.trim()); }}> <SearchForm placeholder="상품코드 검색" value={qInput} onChange={setQInput} onSubmit={() => setQ(qInput.trim())} />
<input value={qInput} onChange={(e) => setQInput(e.target.value)} placeholder="상품코드 검색" aria-label="상품코드 검색"
className="w-64 rounded-lg border border-line-200 bg-surface px-3 py-1.5 text-[13px] outline-none focus:border-primary-600" />
<button type="submit" className="rounded-lg bg-primary-600 px-3 py-1.5 text-[12px] font-semibold text-white hover:bg-primary-700">검색</button>
</form>
<div className="grid min-w-0 gap-4 lg:grid-cols-[400px_1fr]"> <Card fill className="min-h-0 min-w-0 flex-1" title={`검색 이력 상품 ${list.data?.items.length ?? "—"}건`} hint="최근 검색순">
<Card className="min-w-0" title={`검색 이력 상품 ${list.data?.items.length ?? "—"}건`} hint="최근 검색순">
{list.isPending && <Loading />} {list.isPending && <Loading />}
{list.isError && <ErrorNote error={list.error} />} {list.isError && <ErrorNote error={list.error} />}
{list.data && list.data.items.length === 0 && <Empty>아직 검색된 상품이 없습니다</Empty>} {list.data && list.data.items.length === 0 && <Empty>아직 검색된 상품이 없습니다</Empty>}
{list.data && list.data.items.length > 0 && ( {list.data && list.data.items.length > 0 && (
<ul className="max-h-[560px] divide-y divide-line-100 overflow-y-auto"> <ScrollBox height="fill">
<ul className="divide-y divide-line-100">
{list.data.items.map((p) => ( {list.data.items.map((p) => (
<li key={p.product_code}> <li key={p.product_code}>
<button onClick={() => setSelected(p)} <button onClick={() => setSelected(p)}
className={`w-full px-1 py-2 text-left hover:bg-surface-2 ${selected?.product_code === p.product_code ? "bg-primary-50/60" : ""}`}> className={`block w-full border-l-2 px-3 py-2.5 text-left transition-colors ${
selected?.product_code === p.product_code
? "border-primary-600 bg-primary-50/70"
: "border-transparent hover:bg-surface-2"
}`}>
<div className="flex items-baseline justify-between gap-2"> <div className="flex items-baseline justify-between gap-2">
<span className="truncate text-[13px] font-semibold">{p.display_name || p.product_code}</span> <span className="truncate text-[13px] font-semibold">{p.display_name || p.product_code}</span>
<span className="tnum shrink-0 text-[13px] font-bold">{won(p.final_lowest)}</span> <span className="tnum shrink-0 text-[13px] font-bold">{won(p.final_lowest)}</span>
@ -59,18 +88,20 @@ export default function Products() {
</button> </button>
</li> </li>
))} ))}
</ul> </ul>
</ScrollBox>
)} )}
</Card> </Card>
<ProductDetail product={selected} /> <ProductDetail product={selected} />
</div>
</div> </div>
); );
} }
function ProductDetail({ product }: { product: ProductItem | null }) { function ProductDetail({ product }: { product: ProductItem | null }) {
const qc = useQueryClient(); const qc = useQueryClient();
// 그래프에서 클릭한 시점(triggered_at). 없으면 최신 시점. 상품이 바뀌어 목록에 없으면 자동으로 최신으로 폴백.
const [selectedTs, setSelectedTs] = useState<string | null>(null);
const history = useQuery({ const history = useQuery({
queryKey: ["history", product?.product_code], queryKey: ["history", product?.product_code],
queryFn: () => get<PriceHistoryRes>(`/v1/lps/products/${encodeURIComponent(product!.product_code)}/history?limit=200`), queryFn: () => get<PriceHistoryRes>(`/v1/lps/products/${encodeURIComponent(product!.product_code)}/history?limit=200`),
@ -83,141 +114,161 @@ function ProductDetail({ product }: { product: ProductItem | null }) {
onSuccess: () => qc.invalidateQueries({ queryKey: ["jobs"] }), onSuccess: () => qc.invalidateQueries({ queryKey: ["jobs"] }),
}); });
if (!product) return <Card title="가격 추이"><Empty>왼쪽에서 상품을 선택하세요</Empty></Card>; if (!product) return (
<Card fill className="min-h-0 flex-[2]" title="가격 추이">
<div className="grid h-full place-items-center"><Empty>목록에서 상품을 선택하세요</Empty></div>
</Card>
);
const points = (history.data?.points ?? []).map((p) => ({ ...p, x: dateShort(p.triggered_at) })); const points = (history.data?.points ?? []).map((p) => ({ ...p, x: dateShort(p.triggered_at) }));
const latest = history.data?.points.at(-1); const latest = history.data?.points.at(-1);
// 클릭한 시점의 데이터 포인트(없으면 최신). 몰별 비교 표에 이 시점의 by_mall 을 보여준다.
const activePoint = points.find((p) => p.triggered_at === selectedTs) ?? latest;
return ( return (
// min-w-0: 그리드 자식이 콘텐츠(차트) 최소폭 때문에 안 줄어들어 가로 넘침 나는 것 방지 // xl 이상: 왼쪽 그래프 + 오른쪽 몰별 표 가로 배치. 그 이하: 세로 스택(스크롤).
<div className="min-w-0 space-y-4"> // min-w-0: 자식이 콘텐츠(차트) 최소폭 때문에 안 줄어들어 가로 넘침 나는 것 방지.
<Card <div className="flex min-h-0 min-w-0 flex-[2] flex-col gap-4 max-xl:overflow-y-auto xl:flex-row">
<Card fill className="min-w-0 flex-1"
title={<>가격 추이 — {product.display_name || product.product_code}</>} title={<>가격 추이 — {product.display_name || product.product_code}</>}
hint={ hint={
<button onClick={() => research.mutate()} disabled={research.isPending} <Button size="sm" onClick={() => research.mutate()} disabled={research.isPending}>
className="rounded-md bg-primary-600 px-2.5 py-1 text-[12px] font-semibold text-white hover:bg-primary-700 disabled:opacity-50">
{research.isPending ? "요청 중…" : "지금 다시 검색"} {research.isPending ? "요청 중…" : "지금 다시 검색"}
</button> </Button>
}> }>
{research.isSuccess && ( {research.isSuccess && (
<p className="mb-2 rounded-lg bg-ok-50 px-3 py-1.5 text-[12px] font-medium text-ok-600"> <p className="mb-2 shrink-0 rounded-lg bg-ok-50 px-3 py-1.5 text-[12px] font-medium text-ok-600">
{research.data.accepted > 0 ? "검색을 접수했습니다 — 잠시 후 새 점이 추가됩니다" : "이미 같은 상품 검색이 진행 중입니다"} {research.data.accepted > 0 ? "검색을 접수했습니다 — 잠시 후 새 점이 추가됩니다" : "이미 같은 상품 검색이 진행 중입니다"}
</p> </p>
)} )}
{research.isError && <ErrorNote error={research.error} />} {research.isError && <div className="mb-2 shrink-0"><ErrorNote error={research.error} /></div>}
{history.isPending && <Loading />} <div className="min-h-[240px] flex-1">
{history.isError && <ErrorNote error={history.error} />} {history.isPending ? (
{history.data && points.length === 0 && <Empty>이력이 없습니다</Empty>} <div className="grid h-full place-items-center"><Loading /></div>
{points.length > 0 && ( ) : history.isError ? (
<> <ErrorNote error={history.error} />
<ResponsiveContainer width="100%" height={240}> ) : points.length === 0 ? (
<LineChart data={points} margin={{ top: 6, right: 12, bottom: 0, left: 0 }}> <div className="grid h-full place-items-center"><Empty>이력이 없습니다</Empty></div>
<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={56} /> <ResponsiveContainer width="100%" height="100%">
<YAxis tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={false} <ComposedChart data={points} margin={{ top: 10, right: 16, bottom: 0, left: 0 }} style={{ cursor: "pointer" }}
width={64} tickFormatter={(v: number) => v.toLocaleString("ko-KR")} domain={["auto", "auto"]} /> onClick={(state) => {
<Tooltip formatter={(v: number, name: string) => [won(v), name]} /> const ts = (state as { activePayload?: { payload?: PricePoint }[] })?.activePayload?.[0]?.payload?.triggered_at;
{SERIES.map((s) => ( if (ts) setSelectedTs(ts);
<Line key={s.key} type="monotone" dataKey={s.key} name={s.label} stroke={s.color} }}>
strokeWidth={2} dot={{ r: 2.5, strokeWidth: 0, fill: s.color }} connectNulls isAnimationActive={false} /> <defs>
<linearGradient id="finalArea" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--color-final)" stopOpacity={0.14} />
<stop offset="100%" stopColor="var(--color-final)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid {...gridProps} />
<XAxis dataKey="x" {...xAxisProps} minTickGap={56} tickMargin={8} />
<YAxis {...yAxisProps} width={64} tickMargin={4} tickFormatter={(v: number) => v.toLocaleString("ko-KR")} domain={[0, "auto"]} />
<Tooltip content={<ChartTooltip />} cursor={{ stroke: "var(--color-line-200)", strokeWidth: 1 }} />
{activePoint && (
<ReferenceLine x={dateShort(activePoint.triggered_at)} stroke="var(--color-ink-400)" strokeDasharray="3 3" />
)}
{SERIES.filter((s) => s.key !== "final").map((s) => (
<Line key={s.key} type="monotone" dataKey={s.key} name={s.label} stroke={s.color} strokeWidth={2.5}
strokeLinecap="round" connectNulls isAnimationActive={false}
dot={{ r: 3, fill: s.color, stroke: "var(--color-surface)", strokeWidth: 1.5 }}
activeDot={{ r: 5, fill: s.color, stroke: "var(--color-surface)", strokeWidth: 2 }} />
))} ))}
</LineChart> <Area type="monotone" dataKey="final" name="최종 최저가" stroke="var(--color-final)" strokeWidth={2.5}
strokeLinecap="round" fill="url(#finalArea)" connectNulls isAnimationActive={false}
dot={{ r: 3, fill: "var(--color-final)", stroke: "var(--color-surface)", strokeWidth: 1.5 }}
activeDot={{ r: 5, fill: "var(--color-final)", stroke: "var(--color-surface)", strokeWidth: 2 }} />
</ComposedChart>
</ResponsiveContainer> </ResponsiveContainer>
<p className="mt-1 flex flex-wrap gap-4 text-[11px] font-semibold text-ink-500"> )}
{SERIES.map((s) => ( </div>
<span key={s.key}> {points.length > 0 && (
<span className="mr-1 inline-block h-2 w-2 rounded-full" style={{ background: s.color }} aria-hidden /> <Legend items={SERIES.map((s) => ({ label: s.label, color: s.color }))}
{s.label} note="빈 구간 = 그 시점에 해당 소스에서 못 찾음" />
</span>
))}
<span className="ml-auto font-medium text-ink-400">빈 구간 = 그 시점에 해당 소스에서 못 찾음</span>
</p>
</>
)} )}
</Card> </Card>
{latest && <MallCompare latest={latest} />} {activePoint ? (
<MallCompare point={activePoint} />
) : (
<Card fill className="min-w-0 xl:w-[320px] xl:shrink-0" title="몰별 가격 비교">
<div className="grid h-full place-items-center"><Empty>최근 검색 데이터가 없습니다</Empty></div>
</Card>
)}
</div> </div>
); );
} }
// ── 몰별 가격 비교(최근 검색) — 기존 '몰별 최저가' + '검증 링크' 를 하나로. // ── 몰별 가격 비교(최근 검색) — 컴팩트 표. 막대 제거, 소스(발견 채널) 뱃지 추가, 상품명은 보조줄로 유지.
// by_mall(몰별 최저가, 가격 오름차순)을 상위 N개 표로: 순위·쇼핑몰·상품명·가격(+배송)·최저가 대비·링크. const TOP_OPTIONS: { v: number | "all"; label: string }[] = [
const TOP_N_PRESETS = [3, 5, 10] as const; { v: 3, label: "3" }, { v: 5, label: "5" }, { v: 10, label: "10" }, { v: "all", label: "전체" },
];
function MallCompare({ latest }: { latest: PricePoint }) { const MALL_COLS = [
{ key: "rank", label: "#", width: "2.25rem" },
{ key: "mall", label: "쇼핑몰" },
{ key: "price", label: "가격", align: "right", width: "5rem" },
] as const;
// 소스(발견 채널) 색·라벨 — 시리즈 색 재사용(엔터티 고정).
const srcColor = (s?: string) => s === "naver" ? "var(--color-naver)" : s === "coupang" ? "var(--color-coupang)" : "var(--color-ink-400)";
const srcLabel = (s?: string) => s === "naver" ? "네이버" : s === "coupang" ? "쿠팡" : (s || "기타");
function MallCompare({ point }: { point: PricePoint }) {
const [topN, setTopN] = useState<number | "all">(5); const [topN, setTopN] = useState<number | "all">(5);
const malls = (latest.by_mall ?? []) const malls = (point.by_mall ?? [])
.filter((m) => m.price != null) .filter((m) => m.price != null)
.sort((a, b) => (a.price ?? 0) - (b.price ?? 0)); .sort((a, b) => (a.price ?? 0) - (b.price ?? 0));
const shown = topN === "all" ? malls : malls.slice(0, topN); const shown = topN === "all" ? malls : malls.slice(0, topN);
const cheapest = malls[0]?.price ?? 0; const cheapest = malls[0]?.price ?? 0;
const maxPrice = Math.max(1, ...shown.map((m) => m.price ?? 0));
return ( return (
<Card <Card fill className="min-w-0 xl:w-[320px] xl:shrink-0"
title="몰별 가격 비교 (최근 검색)" title="몰별 가격 비교"
hint={ hint={<Segmented ariaLabel="상위 N개" size="sm" value={topN} options={TOP_OPTIONS} onChange={setTopN} />}>
<span className="flex items-center gap-2"> <p className="mb-1.5 shrink-0 text-[11px] text-ink-400">
<span className="hidden text-ink-400 sm:inline">{dateShort(latest.triggered_at)}</span> <span className="tnum font-semibold text-ink-500">{dateShort(point.triggered_at)}</span> 기준 · 그래프의 시점을 클릭해 이동
<span role="tablist" aria-label="상위 N개" className="flex rounded-lg border border-line-200 p-0.5"> </p>
{TOP_N_PRESETS.map((n) => (
<button key={n} role="tab" aria-selected={topN === n} onClick={() => setTopN(n)}
className={`rounded-md px-2 py-0.5 text-[11px] font-semibold ${topN === n ? "bg-primary-50 text-primary-700" : "text-ink-500 hover:text-ink-700"}`}>
{n}
</button>
))}
<button role="tab" aria-selected={topN === "all"} onClick={() => setTopN("all")}
className={`rounded-md px-2 py-0.5 text-[11px] font-semibold ${topN === "all" ? "bg-primary-50 text-primary-700" : "text-ink-500 hover:text-ink-700"}`}>
전체
</button>
</span>
</span>
}>
{shown.length === 0 ? ( {shown.length === 0 ? (
<Empty>몰별 데이터 없음</Empty> <div className="grid h-full place-items-center"><Empty>몰별 데이터 없음</Empty></div>
) : ( ) : (
<> <>
<ul className="space-y-2.5"> <ScrollTable height="fill" dense fixed columns={MALL_COLS}>
{shown.map((m, i) => { {shown.map((m, i) => {
const price = m.price ?? 0; const price = m.price ?? 0;
const delta = price - cheapest; const delta = price - cheapest;
const src = m.source === "naver" ? "var(--color-naver)" : m.source === "coupang" ? "var(--color-coupang)" : "var(--color-ink-400)"; const pct = cheapest > 0 ? (delta / cheapest) * 100 : 0;
const src = srcColor(m.source);
return ( return (
<li key={i} className="grid grid-cols-[auto_1fr_auto] items-center gap-x-3 gap-y-1"> <tr key={i} className="border-b border-line-100 hover:bg-surface-2">
{/* 순위 */} <td className="py-1.5 pr-1 align-top">
<span className={`tnum grid h-6 w-6 place-items-center rounded-full text-[12px] font-bold ${i === 0 ? "bg-ok-50 text-ok-600" : "bg-surface-2 text-ink-500"}`}> <span className={`tnum grid h-4 w-4 place-items-center rounded-full text-[10px] font-bold ${i === 0 ? "bg-ok-50 text-ok-600" : "bg-surface-2 text-ink-500"}`}>{i + 1}</span>
{i + 1} </td>
</span> <td className="min-w-0 py-1.5 pr-2 align-top">
{/* 쇼핑몰 + 상품명 */} <div className="flex items-center gap-1">
<div className="min-w-0"> <span className="shrink-0 rounded px-1 text-[9px] font-bold leading-normal" style={{ color: src, background: `color-mix(in srgb, ${src} 14%, transparent)` }}>{srcLabel(m.source)}</span>
<div className="flex items-center gap-1.5"> <span className="truncate text-[11px] font-semibold">{m.mall_name || m.source}</span>
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: src }} aria-hidden /> {i === 0 && <span className="shrink-0 rounded bg-ok-50 px-1 text-[9px] font-bold text-ok-600">최저</span>}
<span className="truncate text-[13px] font-semibold">{m.mall_name || m.source}</span>
{i === 0 && <span className="shrink-0 rounded bg-ok-50 px-1.5 text-[10px] font-bold text-ok-600">최저</span>}
</div> </div>
<div className="truncate text-[11px] text-ink-400">{m.name}</div> <div className="truncate text-[10px] leading-tight text-ink-400">{m.name}</div>
</div> </td>
{/* 가격 + 최저가 대비 + 링크 */} <td className="py-1.5 text-right align-top">
<div className="text-right"> <div className="tnum whitespace-nowrap text-[12px] font-bold leading-tight">{won(price)}</div>
<div className="tnum text-[14px] font-bold">{won(price)}</div> <div className="tnum mt-0.5 flex items-center justify-end gap-0.5 whitespace-nowrap text-[10px] text-ink-400">
<div className="tnum text-[11px] text-ink-400"> <span>{delta === 0 ? "최저" : `+${pct.toFixed(1)}%`}</span>
{delta === 0 ? "최저가" : `+${delta.toLocaleString("ko-KR")}원`}
{m.detail_url && ( {m.detail_url && (
<a href={m.detail_url} target="_blank" rel="noreferrer" className="ml-1.5 font-semibold text-primary-600 hover:underline">열기 ↗</a> <a href={m.detail_url} target="_blank" rel="noreferrer"
className="font-bold text-primary-600 hover:underline" aria-label="상품 페이지 열기 (새 탭)">↗</a>
)} )}
</div> </div>
</div> </td>
{/* 가격 비교 막대(전체 폭, 최고가 기준 상대 길이) */} </tr>
<div className="col-span-3 h-1.5 overflow-hidden rounded bg-surface-2" aria-hidden>
<div className="h-full rounded" style={{ width: `${(price / maxPrice) * 100}%`, background: src }} />
</div>
</li>
); );
})} })}
</ul> </ScrollTable>
{topN !== "all" && malls.length > shown.length && ( {topN !== "all" && malls.length > shown.length && (
<p className="mt-2 text-[11px] text-ink-400">몰 {malls.length}개 중 상위 {shown.length}개 표시 — '전체'로 모두 보기</p> <p className="mt-2 shrink-0 text-[11px] text-ink-400">몰 {malls.length}개 중 상위 {shown.length}개 — '전체'로 모두 보기</p>
)} )}
</> </>
)} )}

View File

@ -2,7 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { settings } from "../api/client"; import { settings } from "../api/client";
import { Card } from "../components/ui"; import { Button, Card, PageHeader } from "../components/ui";
export default function Settings() { export default function Settings() {
const [baseUrl, setBaseUrl] = useState(settings.baseUrl()); const [baseUrl, setBaseUrl] = useState(settings.baseUrl());
@ -11,7 +11,7 @@ export default function Settings() {
return ( return (
<div className="max-w-xl space-y-4"> <div className="max-w-xl space-y-4">
<h1 className="text-[17px] font-bold tracking-tight">설정</h1> <PageHeader title="설정" />
<Card title="API 연결"> <Card title="API 연결">
<form <form
className="space-y-4" className="space-y-4"
@ -37,9 +37,7 @@ export default function Settings() {
className="w-full rounded-lg border border-line-200 bg-surface px-3 py-2 text-[13px] outline-none focus:border-primary-600" /> className="w-full rounded-lg border border-line-200 bg-surface px-3 py-2 text-[13px] outline-none focus:border-primary-600" />
<p className="mt-1 text-[11px] text-ink-400">서버 toml 의 [WebServerConfig].api_keys 중 하나. 이 브라우저에만 저장됩니다.</p> <p className="mt-1 text-[11px] text-ink-400">서버 toml 의 [WebServerConfig].api_keys 중 하나. 이 브라우저에만 저장됩니다.</p>
</div> </div>
<button type="submit" className="rounded-lg bg-primary-600 px-4 py-2 text-[13px] font-semibold text-white hover:bg-primary-700"> <Button type="submit" size="lg">저장</Button>
저장
</button>
{saved && <span className="ml-2 text-[12px] font-medium text-ok-600">저장했습니다</span>} {saved && <span className="ml-2 text-[12px] font-medium text-ok-600">저장했습니다</span>}
</form> </form>
</Card> </Card>

View File

@ -9,11 +9,38 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { get, post } from "../api/client"; import { get, post } from "../api/client";
import type { JobDetailRes, JobOutput, SearchRes } from "../api/types"; import type { JobDetailRes, JobOutput, SearchRes } from "../api/types";
import { Card, Empty, ErrorNote } from "../components/ui"; import { Button, Card, Empty, ErrorNote, Modal, PageHeader, ScrollTable } from "../components/ui";
import { usd, won } from "../lib/format"; import { usd, won } from "../lib/format";
const TOP_COLS = [
{ key: "source", label: "소스" },
{ key: "name", label: "상품명" },
{ key: "price", label: "가격", align: "right" },
{ key: "ship", label: "배송", align: "right" },
{ key: "link", label: "" },
] as const;
const SOURCE_LABEL: Record<string, string> = { naver: "네이버", coupang: "쿠팡", gmarket: "G마켓", auction: "옥션", st11: "11번가" }; const SOURCE_LABEL: Record<string, string> = { naver: "네이버", coupang: "쿠팡", gmarket: "G마켓", auction: "옥션", st11: "11번가" };
const sourceLabel = (s: string) => SOURCE_LABEL[s] ?? s; const sourceLabel = (s: string) => SOURCE_LABEL[s] ?? s;
// 소스(발견 채널) 색 — 시리즈 색 재사용(엔터티 고정). 상품·가격 몰별 비교와 동일 언어.
const srcColor = (s: string) => s === "naver" ? "var(--color-naver)" : s === "coupang" ? "var(--color-coupang)" : "var(--color-ink-400)";
// 배송 표기 — 쿠팡 로켓 계열(로켓배송·판매자로켓·로켓프레시…) / 무료 / 유료 / 미상.
function shipInfo(fee?: number | null, type?: string | null): { text: string; tone: "rocket" | "free" | "paid" | "none" } {
const t = (type ?? "").toLowerCase();
if (t.startsWith("rocket")) {
const label = t.includes("fresh") ? "로켓프레시" : t.includes("wow") ? "로켓와우"
: t.includes("global") ? "로켓직구" : (t.includes("merchant") || t.includes("seller")) ? "판매자로켓" : "로켓배송";
return { text: label, tone: "rocket" };
}
if (t === "free" || fee === 0) return { text: "무료", tone: "free" };
if (fee != null && fee > 0) return { text: `+${fee.toLocaleString("ko-KR")}원`, tone: "paid" };
return { text: "—", tone: "none" };
}
// 소스·몰명 중복 제거 — 몰명이 없거나 소스 라벨과 같으면 소스만.
const sourceMall = (source: string, mall?: string) => {
const sl = sourceLabel(source);
return mall && mall !== sl ? `${sl} · ${mall}` : sl;
};
// 파이프라인 단계 한글 라벨 — result.stages 의 stage 키 매핑. // 파이프라인 단계 한글 라벨 — result.stages 의 stage 키 매핑.
const STAGE_LABEL: Record<string, string> = { const STAGE_LABEL: Record<string, string> = {
@ -102,54 +129,48 @@ export default function TestSearch() {
}; };
return ( return (
<div className="space-y-4"> <div className="space-y-3">
<h1 className="text-[17px] font-bold tracking-tight">테스트 검색</h1> <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<p className="max-w-2xl text-[12px] leading-relaxed text-ink-500"> <PageHeader title="테스트 검색" />
임의의 상품을 실제 검색 파이프라인에 흘려보내 셀렉터·프록시·AI 매칭이 정상인지 점검합니다. <p className="text-[11px] text-ink-400">
워커가 실제로 크롤하므로 <b>프록시 대역폭 비용이 실제로 발생</b>하고, 결과는 비용 통계에 집계됩니다 실제 크롤이라 <b className="font-semibold text-warn-700">프록시 비용 발생</b> · 비용 통계 집계 (상품코드 <code className="rounded bg-surface-2 px-1">TEST-</code>)
(상품코드는 <code className="rounded bg-surface-2 px-1">TEST-</code> 접두로 구분). </p>
</p> </div>
<div className="grid gap-4 lg:grid-cols-[380px_1fr]"> <div className="grid gap-3 lg:grid-cols-[360px_1fr]">
<Card title="검색 조건"> {/* 왼쪽: 검색 조건 + 진행 상태(함께 sticky — 결과를 스크롤해도 폼·상태가 붙어 있음) */}
<form <div className="space-y-3 lg:sticky lg:top-0 lg:self-start">
className="space-y-3" <Card compact title="검색 조건">
onSubmit={(e) => { e.preventDefault(); if (form.product_name.trim()) submit.mutate(); }} <form
> className="space-y-3"
<Field id="pn" label="상품명" required value={form.product_name} onSubmit={(e) => { e.preventDefault(); if (form.product_name.trim() && !jobId) submit.mutate(); }}
onChange={(v) => setForm((f) => ({ ...f, product_name: v }))} placeholder="예: 맥심 모카골드 커피믹스" /> >
<div className="grid grid-cols-2 gap-3"> <Field id="pn" label="상품명" required value={form.product_name}
<Field id="model" label="모델명" value={form.model} onChange={(v) => setForm((f) => ({ ...f, model: v }))} placeholder="선택" /> onChange={(v) => setForm((f) => ({ ...f, product_name: v }))} placeholder="예: 맥심 모카골드 커피믹스" />
<Field id="company" label="제조사" value={form.company} onChange={(v) => setForm((f) => ({ ...f, company: v }))} placeholder="선택" /> <div className="grid grid-cols-2 gap-3">
</div> <Field id="model" label="모델명" value={form.model} onChange={(v) => setForm((f) => ({ ...f, model: v }))} placeholder="선택" />
<Field id="spec" label="규격" value={form.specification} <Field id="company" label="제조사" value={form.company} onChange={(v) => setForm((f) => ({ ...f, company: v }))} placeholder="선택" />
onChange={(v) => setForm((f) => ({ ...f, specification: v }))} placeholder="선택 — 예: 1박스 160개입" /> </div>
<Field id="price" label="현재가(원)" value={form.price} inputMode="numeric" <Field id="spec" label="규격" value={form.specification}
onChange={(v) => setForm((f) => ({ ...f, price: v.replace(/[^0-9]/g, "") }))} placeholder="선택 — 있으면 가격밴드 필터 기준" /> onChange={(v) => setForm((f) => ({ ...f, specification: v }))} placeholder="선택 — 예: 1박스 160개입" />
<div className="flex gap-2 pt-1"> <Field id="price" label="현재가(원)" value={form.price} inputMode="numeric"
{/* 앰버(warn) — 실제 크롤·프록시 비용이 드는 실행 액션임을 색으로 신호(모니터링 CTA 와 구분). */} onChange={(v) => setForm((f) => ({ ...f, price: v.replace(/[^0-9]/g, "") }))} placeholder="선택 — 있으면 가격밴드 필터 기준" />
<button type="submit" disabled={!form.product_name.trim() || submit.isPending || (!!jobId && status !== "DONE" && status !== "DEAD")} <div className="flex gap-2 pt-1">
className="flex-1 rounded-lg bg-warn-700 px-3 py-2 text-[13px] font-semibold text-white hover:bg-warn-600 disabled:opacity-50"> {/* 앰버(warn) — 실제 크롤·프록시 비용이 드는 실행 액션. 제출 후엔 초기화 전까지 재검색 차단(중복 비용 방지). */}
{submit.isPending ? "제출 중…" : "검색 실행"} <button type="submit" disabled={!form.product_name.trim() || submit.isPending || !!jobId}
</button> className="flex-1 rounded-lg bg-warn-700 px-3 py-2 text-[13px] font-semibold text-white hover:bg-warn-600 disabled:opacity-50">
{jobId && ( {submit.isPending ? "제출 중…" : jobId ? "실행됨 — 초기화 후 재검색" : "검색 실행"}
<button type="button" onClick={reset}
className="rounded-lg border border-line-200 px-3 py-2 text-[13px] font-semibold text-ink-700 hover:bg-surface-2">
초기화
</button> </button>
)} {jobId && (
</div> <Button type="button" variant="outline" size="lg" onClick={reset}>초기화</Button>
{submit.isError && <ErrorNote error={submit.error} />} )}
</form> </div>
</Card> {submit.isError && <ErrorNote error={submit.error} />}
</form>
<div className="space-y-4"> </Card>
{!jobId && (
<Card title="결과"><Empty>왼쪽에서 상품명을 입력하고 검색을 실행하세요</Empty></Card>
)}
{jobId && ( {jobId && (
<Card title="진행 상태" hint={testCode ?? undefined}> <Card compact title="진행 상태" hint={testCode ?? undefined}>
<Progress status={status} elapsed={elapsed} /> <Progress status={status} elapsed={elapsed} />
{job.isError && <div className="mt-2"><ErrorNote error={job.error} /></div>} {job.isError && <div className="mt-2"><ErrorNote error={job.error} /></div>}
{pendingTooLong && ( {pendingTooLong && (
@ -164,8 +185,21 @@ export default function TestSearch() {
)} )}
</Card> </Card>
)} )}
</div>
{status === "DONE" && output && <ResultView output={output} />} {/* 오른쪽: 결과 */}
<div className="space-y-3">
{status === "DONE" && output ? (
<ResultView output={output} />
) : (
<Card compact title="결과">
<Empty>
{!jobId ? "왼쪽에서 상품명을 입력하고 검색을 실행하세요"
: status === "DEAD" ? "검색이 실패했습니다 — 왼쪽 진행 상태를 확인하세요"
: "검색 진행 중 — 완료되면 결과가 여기 표시됩니다"}
</Empty>
</Card>
)}
</div> </div>
</div> </div>
</div> </div>
@ -207,9 +241,20 @@ function Progress({ status, elapsed }: { status?: string; elapsed: number }) {
function ResultView({ output }: { output: JobOutput }) { function ResultView({ output }: { output: JobOutput }) {
const found = output.outcome === "found"; const found = output.outcome === "found";
const m = output.metrics; const m = output.metrics;
const [showJson, setShowJson] = useState(false);
const [copied, setCopied] = useState(false);
const jsonText = JSON.stringify(output, null, 2);
const copyJson = async () => {
try {
await navigator.clipboard.writeText(jsonText);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch { /* 클립보드 권한 없음 — 무시 */ }
};
return ( return (
<> <>
<Card title="판정" hint={output.round ? `${output.round} 라운드에서 매칭` : output.cached ? "네거티브 캐시" : undefined}> <Card compact className={found ? "border-l-[3px] border-l-primary-500" : ""}
title="판정" hint={output.round ? `${output.round} 라운드에서 매칭` : output.cached ? "네거티브 캐시" : undefined}>
{found ? ( {found ? (
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1"> <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="rounded-full bg-ok-50 px-2 py-0.5 text-[12px] font-semibold text-ok-600">같은 상품 찾음</span> <span className="rounded-full bg-ok-50 px-2 py-0.5 text-[12px] font-semibold text-ok-600">같은 상품 찾음</span>
@ -217,7 +262,7 @@ function ResultView({ output }: { output: JobOutput }) {
<span className="text-[15px] font-bold"> <span className="text-[15px] font-bold">
최저가 {won(output.lowest.price)} 최저가 {won(output.lowest.price)}
<span className="ml-1.5 text-[12px] font-medium text-ink-400"> <span className="ml-1.5 text-[12px] font-medium text-ink-400">
({sourceLabel(output.lowest.source)}{output.lowest.mall_name ? ` · ${output.lowest.mall_name}` : ""}) ({sourceMall(output.lowest.source, output.lowest.mall_name)})
</span> </span>
</span> </span>
)} )}
@ -231,15 +276,20 @@ function ResultView({ output }: { output: JobOutput }) {
</Card> </Card>
{output.stages && output.stages.length > 0 && ( {output.stages && output.stages.length > 0 && (
<Card title="파이프라인 퍼널" hint="각 단계가 몇 건을 남겼나"> <Card compact title="파이프라인 퍼널" hint="각 단계가 몇 건을 남겼나">
<FunnelChart stages={output.stages} totalFound={output.total_found} /> <FunnelChart stages={output.stages} totalFound={output.total_found} />
</Card> </Card>
)} )}
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
{output.sources && Object.keys(output.sources).length > 0 && ( {output.sources && Object.keys(output.sources).length > 0 && (
<Card title="소스별 수집" hint="검색어 매칭 전 원본 건수"> <Card compact title="소스별 수집" hint="검색어 매칭 전 원본 건수">
<ul className="space-y-1.5 text-[12px]"> {output.query && (
<p className="mb-2 rounded-md bg-surface-2 px-2 py-1 text-[11px] text-ink-500">
각 사이트 검색어 <span className="font-semibold text-ink-700">"{output.query}"</span>
</p>
)}
<ul className="space-y-1 text-[12px]">
{Object.entries(output.sources).map(([s, v]) => ( {Object.entries(output.sources).map(([s, v]) => (
<li key={s} className="flex items-baseline justify-between gap-2"> <li key={s} className="flex items-baseline justify-between gap-2">
<span className="font-semibold">{sourceLabel(s)}</span> <span className="font-semibold">{sourceLabel(s)}</span>
@ -258,8 +308,8 @@ function ResultView({ output }: { output: JobOutput }) {
)} )}
{m && ( {m && (
<Card title="검색 원가" hint={m.duration_ms != null ? `${(m.duration_ms / 1000).toFixed(1)}초 소요` : undefined}> <Card compact title="검색 원가" hint={m.duration_ms != null ? `${(m.duration_ms / 1000).toFixed(1)}초 소요` : undefined}>
<ul className="space-y-1.5 text-[12px]"> <ul className="space-y-1 text-[12px]">
<li className="flex justify-between"><span className="text-ink-500">총 비용</span><span className="tnum font-bold">{usd(m.cost?.total_usd)}</span></li> <li className="flex justify-between"><span className="text-ink-500">총 비용</span><span className="tnum font-bold">{usd(m.cost?.total_usd)}</span></li>
<li className="flex justify-between"><span className="text-ink-500">AI 판정·검색어</span><span className="tnum">{usd(m.cost?.ai_usd)}</span></li> <li className="flex justify-between"><span className="text-ink-500">AI 판정·검색어</span><span className="tnum">{usd(m.cost?.ai_usd)}</span></li>
<li className="flex justify-between"><span className="text-ink-500">프록시 대역폭</span><span className="tnum">{usd(m.cost?.proxy_usd)}</span></li> <li className="flex justify-between"><span className="text-ink-500">프록시 대역폭</span><span className="tnum">{usd(m.cost?.proxy_usd)}</span></li>
@ -275,38 +325,52 @@ function ResultView({ output }: { output: JobOutput }) {
</div> </div>
{output.top && output.top.length > 0 && ( {output.top && output.top.length > 0 && (
<Card title="매칭된 상품" hint={`최저가순 상위 ${output.top.length}건`}> <Card compact title="매칭된 상품" hint={`최저가순 상위 ${output.top.length}건`}>
<div className="overflow-x-auto"> <ScrollTable height="auto" dense columns={TOP_COLS}>
<table className="w-full text-left text-[12px]"> {output.top.map((p, i) => {
<thead> const src = srcColor(p.source);
<tr className="border-b border-line-200 text-[11px] text-ink-400"> const ship = shipInfo(p.shipping_fee, p.shipping_type);
<th className="pb-1.5 pr-3 font-semibold">소스</th> return (
<th className="pb-1.5 pr-3 font-semibold">상품명</th> <tr key={i} className="border-b border-line-100 hover:bg-surface-2">
<th className="pb-1.5 pr-3 text-right font-semibold">가격</th> <td className="py-1.5 pr-2 align-top">
<th className="pb-1.5 font-semibold">링크</th> <span className="inline-block whitespace-nowrap rounded px-1.5 py-px text-[10px] font-bold" style={{ color: src, background: `color-mix(in srgb, ${src} 14%, transparent)` }}>{sourceLabel(p.source)}</span>
</td>
<td className="max-w-md truncate py-1.5 pr-2 align-top">{p.name}</td>
<td className="py-1.5 pr-2 text-right align-top whitespace-nowrap">
<span className={`tnum font-bold ${i === 0 ? "text-ok-600" : ""}`}>{won(p.price)}</span>
{i === 0 && <span className="ml-1 whitespace-nowrap rounded bg-ok-50 px-1 text-[10px] font-bold text-ok-600">최저</span>}
</td>
<td className="py-1.5 pr-2 text-right align-top">
{ship.tone === "rocket" ? (
<span className="inline-flex items-center gap-0.5 whitespace-nowrap rounded px-1.5 py-px text-[10px] font-bold text-run-600"
style={{ background: "color-mix(in srgb, var(--color-run-600) 12%, transparent)" }}>🚀 {ship.text}</span>
) : (
<span className={`tnum whitespace-nowrap text-[11px] ${ship.tone === "free" ? "font-semibold text-ok-600" : "text-ink-400"}`}>{ship.text}</span>
)}
</td>
<td className="py-1.5 text-right align-top">
{p.detail_url && (
<a href={p.detail_url} target="_blank" rel="noreferrer"
className="inline-flex items-center gap-1 whitespace-nowrap rounded-md border border-line-200 px-2 py-0.5 text-[11px] font-semibold text-ink-700 transition-colors hover:bg-line-200 hover:text-ink-900">사이트 →</a>
)}
</td>
</tr> </tr>
</thead> );
<tbody> })}
{output.top.map((p, i) => ( </ScrollTable>
<tr key={i} className="border-b border-line-100">
<td className="py-1.5 pr-3 font-semibold">{sourceLabel(p.source)}</td>
<td className="max-w-md truncate py-1.5 pr-3">{p.name}</td>
<td className="tnum py-1.5 pr-3 text-right font-semibold">{won(p.price)}</td>
<td className="py-1.5">
{p.detail_url && <a href={p.detail_url} target="_blank" rel="noreferrer" className="text-primary-600 hover:underline">열기 ↗</a>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card> </Card>
)} )}
<details> <div>
<summary className="cursor-pointer text-[12px] font-semibold text-ink-400">원본 결과(JSON)</summary> <Button variant="outline" size="sm" onClick={() => setShowJson(true)}>원본 결과(JSON) 보기</Button>
<pre className="mt-1 max-h-72 overflow-auto rounded-lg bg-surface-2 p-2 text-[11px] leading-relaxed">{JSON.stringify(output, null, 2)}</pre> </div>
</details>
{showJson && (
<Modal title="원본 결과 (JSON)" onClose={() => setShowJson(false)}
actions={<Button variant="outline" size="sm" onClick={copyJson}>{copied ? "복사됨 ✓" : "복사"}</Button>}>
<pre className="w-max min-w-full rounded-lg bg-surface-2 p-3 text-[11px] leading-relaxed">{jsonText}</pre>
</Modal>
)}
</> </>
); );
} }
@ -319,16 +383,27 @@ function FunnelChart({ stages, totalFound }: { stages: { stage: string; in: numb
]; ];
const max = Math.max(1, ...rows.map((r) => r.value)); const max = Math.max(1, ...rows.map((r) => r.value));
return ( return (
<ul className="space-y-1.5"> <ul className="space-y-1">
{rows.map((r, i) => ( {rows.map((r, i) => {
<li key={i} className="flex items-center gap-2 text-[12px]"> const prev = i > 0 ? rows[i - 1].value : null;
<span className="w-28 shrink-0 text-ink-500">{r.label}</span> const drop = prev != null ? r.value - prev : null; // 이전 단계 대비 증감(보통 감소)
<div className="h-5 flex-1 overflow-hidden rounded bg-surface-2"> const dropPct = prev && prev > 0 && drop != null && drop < 0 ? Math.round((drop / prev) * 100) : null;
<div className="h-full rounded bg-primary-600" style={{ width: `${(r.value / max) * 100}%`, minWidth: r.value > 0 ? "2px" : 0 }} aria-hidden /> return (
</div> // 막대는 고정 폭(14rem)으로 짧게, 값·감소량은 우측 넓은 영역에 한 줄로.
<span className="tnum w-10 shrink-0 text-right font-semibold">{r.value}</span> <li key={i} className="grid grid-cols-[6.5rem_14rem_2.25rem_1fr] items-center gap-x-2 text-[12px]">
</li> <span className="truncate text-ink-500">{r.label}</span>
))} <div className="h-4 overflow-hidden rounded bg-surface-2">
<div className="h-full rounded bg-gradient-to-r from-primary-400 to-primary-600"
style={{ width: `${(r.value / max) * 100}%`, minWidth: r.value > 0 ? "3px" : 0 }} aria-hidden />
</div>
<span className="tnum text-right font-semibold">{r.value}</span>
{/* 이전 단계 대비 감소량 — 어디서 확 걸러졌는지 한눈에 */}
<span className={`tnum whitespace-nowrap text-right text-[11px] ${drop != null && drop < 0 ? "font-semibold text-dead-600" : "text-ink-400"}`}>
{drop == null ? "" : drop < 0 ? `−${-drop}${dropPct != null ? ` (${dropPct}%)` : ""}` : "—"}
</span>
</li>
);
})}
</ul> </ul>
); );
} }