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:
parent
e6005b4f94
commit
d111610555
@ -4,15 +4,14 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { NavLink, Outlet } from "react-router";
|
||||
import { healthz } from "../api/client";
|
||||
|
||||
// action: 실제 비용이 드는 실행 페이지(모니터링과 성격이 다름) — 앰버(warn) 마커로 구분.
|
||||
// group: 이 항목 앞에 구분선(모니터링 ↔ 실행/설정 경계).
|
||||
// action: 실제 비용이 드는 실행 페이지(모니터링과 성격이 다름) — 다른 메뉴와 배경색(앰버)만 다르게.
|
||||
const MENU = [
|
||||
{ to: "/", label: "대시보드", end: true },
|
||||
{ to: "/jobs", label: "작업 큐" },
|
||||
{ to: "/products", label: "상품·가격" },
|
||||
{ to: "/crawl", label: "크롤 상태" },
|
||||
{ to: "/costs", label: "비용" },
|
||||
{ to: "/test", label: "테스트 검색", action: true, group: true },
|
||||
{ to: "/test", label: "테스트 검색", action: true },
|
||||
{ to: "/settings", label: "설정" },
|
||||
];
|
||||
|
||||
@ -20,55 +19,49 @@ export default function Layout() {
|
||||
const health = useQuery({ queryKey: ["healthz"], queryFn: healthz, refetchInterval: 10_000 });
|
||||
const up = health.data === true;
|
||||
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 이상: 좌측 고정 사이드바. */}
|
||||
<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 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>
|
||||
<div>
|
||||
<div className="text-[14px] font-bold leading-tight">LPS 관리자</div>
|
||||
<div className="text-[11px] font-medium text-ink-400">인터넷 최저가 검색</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-2 text-[12px] font-semibold text-ink-500 md:hidden">
|
||||
<span className={`h-2 w-2 rounded-full ${up ? "bg-ok-600" : "bg-dead-600"}`} aria-hidden />
|
||||
{up ? "연결됨" : "연결 안 됨"}
|
||||
{/* API 헬스 — 상단 배지(모바일: 우측, 데스크톱: 제목 아래) */}
|
||||
<div className="shrink-0 px-2 md:mb-5">
|
||||
<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>
|
||||
<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">
|
||||
{MENU.map((m) => (
|
||||
<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
|
||||
to={m.to}
|
||||
end={m.end}
|
||||
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
|
||||
? 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"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{m.action && <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-warn-600" aria-hidden />}
|
||||
{m.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</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>
|
||||
<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 />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@ -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";
|
||||
|
||||
export function Card({ title, hint, children, className = "" }: {
|
||||
title?: ReactNode; hint?: ReactNode; children: ReactNode; className?: string;
|
||||
// fill: 카드가 부모 높이를 채우고 본문이 남는 높이를 flex 로 차지(그래프·표가 높이 채우기용).
|
||||
// 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 (
|
||||
<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 && (
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -71,3 +75,145 @@ export function ErrorNote({ error }: { error: unknown }) {
|
||||
export function Loading() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -49,10 +49,20 @@
|
||||
"Apple SD Gothic Neo", "Noto Sans KR", sans-serif;
|
||||
}
|
||||
|
||||
/* 앱을 뷰포트에 고정 — 페이지(body) 스크롤 없음. 스크롤은 content(main) 영역에서만. */
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-page text-ink-900 font-sans text-[14px] antialiased;
|
||||
}
|
||||
|
||||
/* Tailwind v4 는 버튼 기본 커서를 default 로 바꿈 — 활성 버튼은 pointer 로 복구. */
|
||||
button:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 테이블 숫자 열 정렬 */
|
||||
.tnum {
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
35
lps-admin/src/lib/chart.ts
Normal file
35
lps-admin/src/lib/chart.ts
Normal 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;
|
||||
@ -7,13 +7,14 @@ import {
|
||||
} from "recharts";
|
||||
import { get } from "../api/client";
|
||||
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";
|
||||
|
||||
const RANGES = [
|
||||
{ h: 24, label: "24시간" },
|
||||
{ h: 48, label: "48시간" },
|
||||
{ h: 168, label: "7일" },
|
||||
{ v: 24, label: "24시간" },
|
||||
{ v: 48, label: "48시간" },
|
||||
{ v: 168, label: "7일" },
|
||||
];
|
||||
|
||||
export default function Costs() {
|
||||
@ -32,68 +33,66 @@ export default function Costs() {
|
||||
const perSearch = totals.jobs > 0 ? totals.total / totals.jobs : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-[17px] font-bold tracking-tight">비용</h1>
|
||||
<div role="tablist" aria-label="기간" className="ml-auto flex rounded-lg border border-line-200 bg-surface p-0.5">
|
||||
{RANGES.map((r) => (
|
||||
<button key={r.h} role="tab" aria-selected={hours === r.h} onClick={() => setHours(r.h)}
|
||||
className={`rounded-md px-3 py-1.5 text-[12px] font-semibold ${hours === r.h ? "bg-primary-50 text-primary-700" : "text-ink-500 hover:text-ink-700"}`}>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full min-h-0 flex-col gap-4">
|
||||
<PageHeader title="비용">
|
||||
<Segmented ariaLabel="기간" options={RANGES} value={hours} onChange={setHours} />
|
||||
</PageHeader>
|
||||
|
||||
{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={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="프록시 비중" value={totals.total > 0 ? `${Math.round((totals.proxy / totals.total) * 100)}%` : "—"} sub="대역폭이 원가의 대부분" />
|
||||
</div>
|
||||
|
||||
<Card title="시간별 검색원가" hint="AI vs 프록시 대역폭">
|
||||
<Card fill className="flex-[3]" title="시간별 검색원가" hint="AI vs 프록시 대역폭">
|
||||
{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 && (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={buckets} margin={{ top: 6, right: 12, bottom: 0, left: -10 }}>
|
||||
<CartesianGrid stroke="var(--color-grid)" vertical={false} />
|
||||
<XAxis dataKey="x" tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={{ stroke: "var(--color-line-200)" }} minTickGap={40} />
|
||||
<YAxis tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={false} tickFormatter={(v: number) => `$${v}`} />
|
||||
<Tooltip formatter={(v: number, name: string) => [usd(v), name]}
|
||||
labelFormatter={(l, payload) => {
|
||||
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} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<p className="mt-1 flex gap-4 text-[11px] font-semibold text-ink-500">
|
||||
<span><span className="mr-1 inline-block h-2 w-2 rounded-full bg-cost-ai" aria-hidden />AI (판정·검색어)</span>
|
||||
<span><span className="mr-1 inline-block h-2 w-2 rounded-full bg-cost-proxy" aria-hidden />프록시 대역폭 (DECODO)</span>
|
||||
</p>
|
||||
<div className="min-h-0 flex-1">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={buckets} margin={{ top: 6, right: 12, bottom: 0, left: -10 }}>
|
||||
<CartesianGrid {...gridProps} />
|
||||
<XAxis dataKey="x" {...xAxisProps} minTickGap={40} />
|
||||
<YAxis {...yAxisProps} tickFormatter={(v: number) => `$${v}`} />
|
||||
<Tooltip {...tooltipProps} cursor={barCursor} formatter={(v: number, name: string) => [usd(v), name]}
|
||||
labelFormatter={(l, payload) => {
|
||||
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} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<Legend items={[
|
||||
{ label: "AI (판정·검색어)", color: "var(--color-cost-ai)" },
|
||||
{ label: "프록시 대역폭 (DECODO)", color: "var(--color-cost-proxy)" },
|
||||
]} />
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="검색 1건 평균 소요 시간" hint="시간별 · 상승하면 경합/차단 신호">
|
||||
<Card fill className="flex-[2]" title="검색 1건 평균 소요 시간" hint="시간별 · 상승하면 경합/차단 신호">
|
||||
{buckets.length === 0 ? (
|
||||
<Empty>데이터 없음</Empty>
|
||||
<div className="grid h-full place-items-center"><Empty>데이터 없음</Empty></div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<LineChart data={buckets} margin={{ top: 6, right: 12, bottom: 0, left: -10 }}>
|
||||
<CartesianGrid stroke="var(--color-grid)" vertical={false} />
|
||||
<XAxis dataKey="x" tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={{ stroke: "var(--color-line-200)" }} minTickGap={40} />
|
||||
<YAxis tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={false} tickFormatter={(v: number) => `${(v / 1000).toFixed(0)}s`} />
|
||||
<Tooltip formatter={(v: number) => [`${(v / 1000).toFixed(1)}초`, "평균 소요"]} />
|
||||
<Line type="monotone" dataKey="avg_ms" name="평균 소요" stroke="var(--color-primary-600)" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="min-h-0 flex-1">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={buckets} margin={{ top: 6, right: 12, bottom: 0, left: -10 }}>
|
||||
<CartesianGrid {...gridProps} />
|
||||
<XAxis dataKey="x" {...xAxisProps} minTickGap={40} />
|
||||
<YAxis {...yAxisProps} tickFormatter={(v: number) => `${(v / 1000).toFixed(0)}s`} />
|
||||
<Tooltip {...tooltipProps} formatter={(v: number) => [`${(v / 1000).toFixed(1)}초`, "평균 소요"]} />
|
||||
<Line type="monotone" dataKey="avg_ms" name="평균 소요" stroke="var(--color-primary-600)" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@ -7,9 +7,28 @@ import {
|
||||
} from "recharts";
|
||||
import { get } from "../api/client";
|
||||
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";
|
||||
|
||||
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 토큰 참조(단일 소스) —
|
||||
// raw hex 재정의 금지(토큰을 바꾸면 여기도 함께 바뀌어야 한다).
|
||||
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 = [
|
||||
{ h: 24, label: "24시간" },
|
||||
{ h: 168, label: "7일" },
|
||||
{ h: 720, label: "30일" },
|
||||
{ v: 24, label: "24시간" },
|
||||
{ v: 168, label: "7일" },
|
||||
{ v: 720, label: "30일" },
|
||||
];
|
||||
|
||||
export default function Crawl() {
|
||||
@ -49,17 +68,9 @@ export default function Crawl() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-[17px] font-bold tracking-tight">크롤 상태</h1>
|
||||
<div role="tablist" aria-label="기간" className="ml-auto flex rounded-lg border border-line-200 bg-surface p-0.5">
|
||||
{RANGES.map((r) => (
|
||||
<button key={r.h} role="tab" aria-selected={hours === r.h} onClick={() => setHours(r.h)}
|
||||
className={`rounded-md px-3 py-1.5 text-[12px] font-semibold ${hours === r.h ? "bg-primary-50 text-primary-700" : "text-ink-500 hover:text-ink-700"}`}>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader title="크롤 상태">
|
||||
<Segmented ariaLabel="기간" options={RANGES} value={hours} onChange={setHours} />
|
||||
</PageHeader>
|
||||
|
||||
{ip.isError && <ErrorNote error={ip.error} />}
|
||||
|
||||
@ -82,14 +93,22 @@ export default function Crawl() {
|
||||
{ip.data && reasons.length === 0 && <Empty>기간 내 세션 없음 — 워커가 검색을 시작하면 쌓입니다</Empty>}
|
||||
{reasons.length > 0 && (
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 고정 크기 차트 — ResponsiveContainer 불필요(dev 경고 발생원) */}
|
||||
<PieChart width={150} height={150}>
|
||||
<Pie data={reasons} dataKey="value" nameKey="label" innerRadius={42} outerRadius={70}
|
||||
paddingAngle={2} stroke="var(--color-surface)" strokeWidth={2} isAnimationActive={false}>
|
||||
{reasons.map((r) => <Cell key={r.key} fill={r.color} />)}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v: number, name: string) => [`${v}건`, name]} />
|
||||
</PieChart>
|
||||
{/* 고정 크기 차트 — ResponsiveContainer 불필요(dev 경고 발생원). 중앙에 총계 오버레이. */}
|
||||
<div className="relative shrink-0">
|
||||
<PieChart width={150} height={150}>
|
||||
<Pie data={reasons} dataKey="value" nameKey="label" innerRadius={46} outerRadius={70}
|
||||
paddingAngle={2} cornerRadius={4} stroke="var(--color-surface)" strokeWidth={2} isAnimationActive={false}>
|
||||
{reasons.map((r) => <Cell key={r.key} fill={r.color} />)}
|
||||
</Pie>
|
||||
{/* 툴팁 제거 — 오른쪽 범례가 사유별 건수를 모두 보여줘 중복이고, 중앙 총계와 겹침 방지 */}
|
||||
</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]">
|
||||
{reasons.map((r) => (
|
||||
<li key={r.key} className="flex items-baseline gap-2">
|
||||
@ -108,25 +127,32 @@ export default function Crawl() {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="세션당 요청 수 분포" hint="예산 튜닝 근거">
|
||||
<Card fill title="세션당 요청 수 분포" hint="예산 튜닝 근거">
|
||||
{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 && (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={170}>
|
||||
<BarChart data={ip.data.histogram} margin={{ top: 14, right: 12, bottom: 0, left: -22 }}>
|
||||
<CartesianGrid stroke="var(--color-grid)" vertical={false} />
|
||||
<XAxis dataKey="requests" tick={{ fill: "var(--color-ink-400)" }} tickLine={false}
|
||||
axisLine={{ stroke: "var(--color-line-200)" }} label={undefined} />
|
||||
<YAxis allowDecimals={false} tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={false} />
|
||||
<Tooltip formatter={(v: number) => [`${v}건`, "세션 수"]} labelFormatter={(l) => `IP당 ${l}회 요청`} />
|
||||
<Bar dataKey="count" fill="var(--color-cost-ai)" radius={[4, 4, 0, 0]} maxBarSize={36} isAnimationActive={false} />
|
||||
{ip.data.block_min_requests != null && (
|
||||
<ReferenceLine x={ip.data.block_min_requests} stroke="var(--color-dead-600)" strokeDasharray="4 3"
|
||||
label={{ value: `차단 시작 ${ip.data.block_min_requests}회`, position: "top", fill: "var(--color-dead-600)", fontSize: 11, fontWeight: 600 }} />
|
||||
)}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="min-h-0 flex-1">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={ip.data.histogram} margin={{ top: 14, right: 12, bottom: 0, left: -22 }}>
|
||||
<defs>
|
||||
<linearGradient id="histBar" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--color-cost-ai)" stopOpacity={0.95} />
|
||||
<stop offset="100%" stopColor="var(--color-cost-ai)" stopOpacity={0.55} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid {...gridProps} />
|
||||
<XAxis dataKey="requests" {...xAxisProps} />
|
||||
<YAxis allowDecimals={false} {...yAxisProps} />
|
||||
<Tooltip {...tooltipProps} cursor={barCursor} formatter={(v: number) => [`${v}건`, "세션 수"]} labelFormatter={(l) => `IP당 ${l}회 요청`} />
|
||||
<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">
|
||||
X축 = 한 IP로 보낸 요청 수. 분포가 예산값에 몰려 있으면 정상, 붉은 기준선(차단 시작점)보다 예산이 낮아야 안전.
|
||||
</p>
|
||||
@ -136,50 +162,45 @@ export default function Crawl() {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card title="차단 발생 추이" hint="시간대별">
|
||||
<Card fill title="차단 발생 추이" hint="시간대별">
|
||||
{bot.isPending && <Loading />}
|
||||
{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 && (
|
||||
<ResponsiveContainer width="100%" height={150}>
|
||||
<BarChart data={bot.data.hourly.map((h) => ({ ...h, x: dateShort(h.bucket) }))} margin={{ top: 6, right: 12, bottom: 0, left: -22 }}>
|
||||
<CartesianGrid stroke="var(--color-grid)" vertical={false} />
|
||||
<XAxis dataKey="x" tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={{ stroke: "var(--color-line-200)" }} minTickGap={40} />
|
||||
<YAxis allowDecimals={false} tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={false} />
|
||||
<Tooltip formatter={(v: number) => [`${v}건`, "차단"]} />
|
||||
<Bar dataKey="count" fill="var(--color-dead-600)" radius={[4, 4, 0, 0]} maxBarSize={24} isAnimationActive={false} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="min-h-0 flex-1">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={bot.data.hourly.map((h) => ({ ...h, x: dateShort(h.bucket) }))} margin={{ top: 6, right: 12, bottom: 0, left: -22 }}>
|
||||
<defs>
|
||||
<linearGradient id="blockBar" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--color-dead-600)" stopOpacity={0.95} />
|
||||
<stop offset="100%" stopColor="var(--color-dead-600)" stopOpacity={0.55} />
|
||||
</linearGradient>
|
||||
</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 title="최근 차단 이력" hint="최근 50건">
|
||||
{bot.data && bot.data.items.length === 0 && <Empty>기간 내 차단 없음</Empty>}
|
||||
{bot.data && bot.data.items.length > 0 && (
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
<table className="w-full text-left text-[12px]">
|
||||
<thead>
|
||||
<tr className="border-b border-line-200 text-[11px] text-ink-400">
|
||||
<th className="pb-1 pr-2 font-semibold">시각</th>
|
||||
<th className="pb-1 pr-2 font-semibold">검색어</th>
|
||||
<th className="pb-1 pr-2 text-right font-semibold">요청#</th>
|
||||
<th className="pb-1 pr-2 text-right font-semibold">포트</th>
|
||||
<th className="pb-1 font-semibold">감지 근거</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bot.data.items.map((b, i) => (
|
||||
<tr key={i} className="border-b border-line-100">
|
||||
<td className="tnum py-1.5 pr-2 text-ink-500">{hhmm(b.created_at)}</td>
|
||||
<td className="max-w-32 truncate py-1.5 pr-2">{b.query || "—"}</td>
|
||||
<td className="tnum py-1.5 pr-2 text-right">{b.ip_request_no ?? "—"}</td>
|
||||
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{b.proxy_port ?? "—"}</td>
|
||||
<td className="max-w-40 truncate py-1.5 text-[11px] text-ink-500">{b.marker}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ScrollTable height={240} dense columns={BOT_COLS}>
|
||||
{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>
|
||||
))}
|
||||
</ScrollTable>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
@ -187,42 +208,27 @@ export default function Crawl() {
|
||||
<Card title="최근 IP 세션" hint="최근 50건">
|
||||
{ip.data && ip.data.sessions.length === 0 && <Empty>세션 없음</Empty>}
|
||||
{ip.data && ip.data.sessions.length > 0 && (
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
<table className="w-full text-left text-[12px]">
|
||||
<thead>
|
||||
<tr className="border-b border-line-200 text-[11px] text-ink-400">
|
||||
<th className="pb-1 pr-2 font-semibold">종료 시각</th>
|
||||
<th className="pb-1 pr-2 font-semibold">소스</th>
|
||||
<th className="pb-1 pr-2 text-right font-semibold">포트</th>
|
||||
<th className="pb-1 pr-2 text-right font-semibold">요청</th>
|
||||
<th className="pb-1 pr-2 text-right font-semibold">성공/차단</th>
|
||||
<th className="pb-1 pr-2 text-right font-semibold">지속</th>
|
||||
<th className="pb-1 font-semibold">종료 사유</th>
|
||||
<ScrollTable height={320} dense columns={SESSION_COLS}>
|
||||
{ip.data.sessions.map((s, i) => {
|
||||
const r = REASONS[s.end_reason] ?? { label: s.end_reason, color: "var(--color-ink-400)" };
|
||||
return (
|
||||
<tr key={i} className="border-b border-line-100">
|
||||
<td className="tnum py-1.5 pr-2 text-ink-500">{dateShort(s.created_at)}</td>
|
||||
<td className="py-1.5 pr-2">{s.source}</td>
|
||||
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{s.proxy_port ?? "—"}</td>
|
||||
<td className="tnum py-1.5 pr-2 text-right font-semibold">{s.requests}</td>
|
||||
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{s.ok_count}/{s.blocked_count}</td>
|
||||
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{durationSec(s.elapsed_sec)}</td>
|
||||
<td className="py-1.5">
|
||||
<span className="inline-flex items-center gap-1.5 text-[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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ip.data.sessions.map((s, i) => {
|
||||
const r = REASONS[s.end_reason] ?? { label: s.end_reason, color: "var(--color-ink-400)" };
|
||||
return (
|
||||
<tr key={i} className="border-b border-line-100">
|
||||
<td className="tnum py-1.5 pr-2 text-ink-500">{dateShort(s.created_at)}</td>
|
||||
<td className="py-1.5 pr-2">{s.source}</td>
|
||||
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{s.proxy_port ?? "—"}</td>
|
||||
<td className="tnum py-1.5 pr-2 text-right font-semibold">{s.requests}</td>
|
||||
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{s.ok_count}/{s.blocked_count}</td>
|
||||
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{durationSec(s.elapsed_sec)}</td>
|
||||
<td className="py-1.5">
|
||||
<span className="inline-flex items-center gap-1.5 text-[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>
|
||||
);
|
||||
})}
|
||||
</ScrollTable>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@ -7,7 +7,8 @@ import {
|
||||
} from "recharts";
|
||||
import { get } from "../api/client";
|
||||
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";
|
||||
|
||||
// 알림 임계 기본값 미러 — 서버 [AlertConfig] 와 동일 기본(서버가 조정했으면 다를 수 있음, 참고용 배너).
|
||||
@ -44,8 +45,8 @@ export default function Dashboard() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-[17px] font-bold tracking-tight">대시보드</h1>
|
||||
<div className="flex h-full min-h-0 flex-col gap-4">
|
||||
<PageHeader title="대시보드" />
|
||||
|
||||
{ops.isError && <ErrorNote error={ops.error} />}
|
||||
{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} />
|
||||
</div>
|
||||
|
||||
<Card title="큐 추이" hint="5초 간격 · 이 화면을 열어둔 동안 누적">
|
||||
{trend.current.length < 2 ? (
|
||||
<p className="py-6 text-center text-[12px] text-ink-400">수집 중… 잠시 후 그래프가 나타납니다</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<LineChart data={trend.current} margin={{ top: 6, right: 12, bottom: 0, left: -18 }}>
|
||||
<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} />
|
||||
<YAxis allowDecimals={false} tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={false} />
|
||||
<Tooltip formatter={(v: number, name: string) => [v, name === "pending" ? "대기" : "처리중"]} />
|
||||
<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} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
<p className="mt-1 flex gap-4 text-[11px] font-semibold text-ink-500">
|
||||
<span><span className="mr-1 inline-block h-2 w-2 rounded-full bg-warn-600" aria-hidden />대기</span>
|
||||
<span><span className="mr-1 inline-block h-2 w-2 rounded-full bg-run-600" aria-hidden />처리중</span>
|
||||
</p>
|
||||
<Card fill className="flex-1" title="큐 추이" hint="5초 간격 · 이 화면을 열어둔 동안 누적">
|
||||
<div className="min-h-0 flex-1">
|
||||
{trend.current.length < 2 ? (
|
||||
<div className="grid h-full place-items-center">
|
||||
<p className="text-[12px] text-ink-400">수집 중… 잠시 후 그래프가 나타납니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={trend.current} margin={{ top: 6, right: 12, bottom: 0, left: -18 }}>
|
||||
<CartesianGrid {...gridProps} />
|
||||
<XAxis dataKey="t" {...xAxisProps} minTickGap={48} />
|
||||
<YAxis allowDecimals={false} {...yAxisProps} />
|
||||
<Tooltip {...tooltipProps} formatter={(v: number, name: string) => [v, name === "pending" ? "대기" : "처리중"]} />
|
||||
<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} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
<Legend items={[
|
||||
{ label: "대기", color: "var(--color-warn-600)" },
|
||||
{ label: "처리중", color: "var(--color-run-600)" },
|
||||
]} />
|
||||
</Card>
|
||||
|
||||
<p className="text-[11px] text-ink-400">
|
||||
<p className="shrink-0 text-[11px] text-ink-400">
|
||||
임계 배너는 서버 알림 기본값 기준입니다(서버 toml 에서 조정했으면 실제 알림과 다를 수 있음).
|
||||
전체 룰은 lps/docs/operations.md 참고.
|
||||
</p>
|
||||
|
||||
@ -1,12 +1,24 @@
|
||||
/** 작업 큐 — 잡 목록(필터·검색·페이지네이션) + 상세 패널 + DEAD 재큐. */
|
||||
/** 작업 큐 — 잡 목록(필터·검색·페이지네이션) 표 + 행별 상세/JSON 모달 + DEAD 재큐. */
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { get, post } from "../api/client";
|
||||
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";
|
||||
|
||||
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 TABS: { v: JobStatusName | ""; label: string }[] = [
|
||||
{ v: "", label: "전체" },
|
||||
@ -21,7 +33,10 @@ export default function Jobs() {
|
||||
const [q, setQ] = useState("");
|
||||
const [qInput, setQInput] = useState("");
|
||||
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) });
|
||||
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));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-[17px] font-bold tracking-tight">작업 큐</h1>
|
||||
<div className="flex h-full min-h-0 flex-col gap-4">
|
||||
<PageHeader title="작업 큐" />
|
||||
|
||||
<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">
|
||||
{TABS.map((t) => (
|
||||
<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>
|
||||
<Segmented ariaLabel="상태 필터" options={TABS} value={status}
|
||||
onChange={(v) => { setStatus(v); setPage(0); }} />
|
||||
<button onClick={() => { const v = q === "TEST-" ? "" : "TEST-"; setQ(v); setQInput(v); setPage(0); }}
|
||||
aria-pressed={q === "TEST-"}
|
||||
className={`rounded-lg border px-3 py-1.5 text-[12px] font-semibold ${
|
||||
@ -57,119 +63,130 @@ export default function Jobs() {
|
||||
}`}>
|
||||
테스트만
|
||||
</button>
|
||||
<form className="ml-auto flex gap-1.5" onSubmit={(e) => { e.preventDefault(); setQ(qInput.trim()); setPage(0); }}>
|
||||
<input value={qInput} onChange={(e) => setQInput(e.target.value)} placeholder="상품코드·상품명 검색" aria-label="상품코드·상품명 검색"
|
||||
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>
|
||||
<SearchForm className="ml-auto" inputClassName="w-56" placeholder="상품코드·상품명 검색"
|
||||
value={qInput} onChange={setQInput} onSubmit={() => { setQ(qInput.trim()); setPage(0); }} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[1fr_360px]">
|
||||
<Card title={`작업 ${list.data?.total ?? "—"}건`} hint="15초마다 갱신">
|
||||
{list.isPending && <Loading />}
|
||||
{list.isError && <ErrorNote error={list.error} />}
|
||||
{list.data && list.data.items.length === 0 && <Empty>조건에 맞는 작업이 없습니다</Empty>}
|
||||
{list.data && list.data.items.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-[13px]">
|
||||
<thead>
|
||||
<tr className="border-b border-line-200 text-[11px] text-ink-400">
|
||||
<th className="pb-1.5 pr-3 font-semibold">상품</th>
|
||||
<th className="pb-1.5 pr-3 font-semibold">상태</th>
|
||||
<th className="pb-1.5 pr-3 font-semibold">결과</th>
|
||||
<th className="pb-1.5 pr-3 text-right font-semibold">최저가</th>
|
||||
<th className="pb-1.5 pr-3 text-right font-semibold">원가</th>
|
||||
<th className="pb-1.5 font-semibold">요청 시각</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.data.items.map((j) => (
|
||||
<tr key={j.job_id} onClick={() => setSelected(j)} tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setSelected(j); } }}
|
||||
aria-selected={selected?.job_id === j.job_id}
|
||||
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" : ""}`}>
|
||||
<td className="py-2 pr-3">
|
||||
<div className="font-semibold">{j.product_name || "—"}</div>
|
||||
<div className="text-[11px] text-ink-400">{j.product_code}</div>
|
||||
</td>
|
||||
<td className="py-2 pr-3"><StatusBadge status={j.status} /></td>
|
||||
<td className="py-2 pr-3">{j.outcome === "found" ? "찾음" : j.outcome === "not_found" ? "없음" : "—"}</td>
|
||||
<td className="tnum py-2 pr-3 text-right">{won(j.final_lowest)}</td>
|
||||
<td className="tnum py-2 pr-3 text-right text-ink-500">{usd(j.cost_usd)}</td>
|
||||
<td className="tnum py-2 text-[12px] text-ink-500">{dateShort(j.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</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>
|
||||
<Card fill className="min-h-0 flex-1" title={`작업 ${list.data?.total ?? "—"}건`} hint="15초마다 갱신">
|
||||
{list.isPending && <Loading />}
|
||||
{list.isError && <ErrorNote error={list.error} />}
|
||||
{list.data && list.data.items.length === 0 && <Empty>조건에 맞는 작업이 없습니다</Empty>}
|
||||
{list.data && list.data.items.length > 0 && (
|
||||
<ScrollTable height="fill" columns={COLS}>
|
||||
{list.data.items.map((j) => (
|
||||
<tr key={j.job_id} className="border-b border-line-100 hover:bg-surface-2">
|
||||
<td className="py-2 pr-3">
|
||||
<div className="font-semibold">{j.product_name || "—"}</div>
|
||||
<div className="text-[11px] text-ink-400">{j.product_code}</div>
|
||||
</td>
|
||||
<td className="py-2 pr-3"><StatusBadge status={j.status} /></td>
|
||||
<td className="py-2 pr-3">{j.outcome === "found" ? "찾음" : j.outcome === "not_found" ? "없음" : "—"}</td>
|
||||
<td className="tnum py-2 pr-3 text-right">{won(j.final_lowest)}</td>
|
||||
<td className="tnum py-2 pr-3 text-right text-ink-500">{usd(j.cost_usd)}</td>
|
||||
<td className="tnum py-2 pr-3 text-[12px] text-ink-500">{dateShort(j.created_at)}</td>
|
||||
<td className="py-2 text-right">
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<Button variant="outline" size="sm" onClick={() => open(j, "detail")}>상세</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => open(j, "json")}>JSON</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</ScrollTable>
|
||||
)}
|
||||
{pages > 1 && (
|
||||
<nav aria-label="페이지" className="mt-3 flex shrink-0 items-center justify-end gap-2 text-[12px]">
|
||||
<Button variant="outline" size="sm" disabled={page === 0} onClick={() => setPage((p) => p - 1)}>이전</Button>
|
||||
<span className="tnum text-ink-500">{page + 1} / {pages}</span>
|
||||
<Button variant="outline" size="sm" disabled={page + 1 >= pages} onClick={() => setPage((p) => p + 1)}>다음</Button>
|
||||
</nav>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<JobDetail job={selected} onClose={() => setSelected(null)} />
|
||||
</div>
|
||||
{modalJob && (
|
||||
<JobModal job={modalJob} view={modalView} onView={setModalView} onClose={() => setModalJob(null)} />
|
||||
)}
|
||||
</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 detail = useQuery({
|
||||
queryKey: ["job", job?.job_id],
|
||||
queryFn: () => get<JobDetailRes>(`/v1/lps/jobs/${job!.job_id}`),
|
||||
enabled: !!job,
|
||||
queryKey: ["job", job.job_id],
|
||||
queryFn: () => get<JobDetailRes>(`/v1/lps/jobs/${job.job_id}`),
|
||||
});
|
||||
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"] }),
|
||||
});
|
||||
|
||||
if (!job) return <Card title="상세"><Empty>왼쪽 목록에서 작업을 선택하세요</Empty></Card>;
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
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 (
|
||||
<Card title="상세" hint={<button onClick={onClose} className="font-semibold text-ink-400 hover:text-ink-700">닫기</button>}>
|
||||
<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>}
|
||||
{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>
|
||||
<Modal
|
||||
title={<>상세 <span className="font-medium text-ink-400">— {job.product_name || job.product_code}</span></>}
|
||||
onClose={onClose}
|
||||
actions={
|
||||
<>
|
||||
<Segmented ariaLabel="보기 방식" size="sm" value={view}
|
||||
options={[{ v: "detail", label: "상세" }, { v: "json", label: "JSON" }]} onChange={onView} />
|
||||
{view === "json" && (
|
||||
<Button variant="outline" size="sm" disabled={!out} onClick={copyJson}>{copied ? "복사됨 ✓" : "복사"}</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{view === "detail" ? (
|
||||
<>
|
||||
<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" && (
|
||||
<div className="mt-4 border-t border-line-100 pt-3">
|
||||
<button 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 ? "재시도 요청 중…" : "다시 검색하기 (재큐)"}
|
||||
</button>
|
||||
{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>}
|
||||
</div>
|
||||
{job.status === "DEAD" && (
|
||||
<div className="mt-4 border-t border-line-100 pt-3">
|
||||
<Button size="lg" className="w-full" onClick={() => requeue.mutate()} disabled={requeue.isPending}>
|
||||
{requeue.isPending ? "재시도 요청 중…" : "다시 검색하기 (재큐)"}
|
||||
</Button>
|
||||
{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>}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -3,11 +3,12 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
|
||||
Area, CartesianGrid, ComposedChart, Line, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis,
|
||||
} from "recharts";
|
||||
import { get, post } from "../api/client";
|
||||
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";
|
||||
|
||||
// 시리즈 색은 엔터티 고정(네이버 초록은 CVD 분리 검증 통과 톤) — 순서·색 재배정 금지.
|
||||
@ -17,6 +18,34 @@ const SERIES = [
|
||||
{ key: "final", label: "최종 최저가", color: "var(--color-final)" },
|
||||
] 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() {
|
||||
const [q, setQ] = useState("");
|
||||
const [qInput, setQInput] = useState("");
|
||||
@ -28,26 +57,26 @@ export default function Products() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-[17px] font-bold tracking-tight">상품·가격</h1>
|
||||
<div className="flex h-full min-h-0 flex-col gap-4">
|
||||
<PageHeader title="상품·가격" />
|
||||
|
||||
<form className="flex gap-1.5" onSubmit={(e) => { e.preventDefault(); 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>
|
||||
<SearchForm placeholder="상품코드 검색" value={qInput} onChange={setQInput} onSubmit={() => setQ(qInput.trim())} />
|
||||
|
||||
<div className="grid min-w-0 gap-4 lg:grid-cols-[400px_1fr]">
|
||||
<Card className="min-w-0" title={`검색 이력 상품 ${list.data?.items.length ?? "—"}건`} hint="최근 검색순">
|
||||
<Card fill className="min-h-0 min-w-0 flex-1" title={`검색 이력 상품 ${list.data?.items.length ?? "—"}건`} hint="최근 검색순">
|
||||
{list.isPending && <Loading />}
|
||||
{list.isError && <ErrorNote error={list.error} />}
|
||||
{list.data && list.data.items.length === 0 && <Empty>아직 검색된 상품이 없습니다</Empty>}
|
||||
{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) => (
|
||||
<li key={p.product_code}>
|
||||
<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">
|
||||
<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>
|
||||
@ -59,18 +88,20 @@ export default function Products() {
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ul>
|
||||
</ScrollBox>
|
||||
)}
|
||||
</Card>
|
||||
</Card>
|
||||
|
||||
<ProductDetail product={selected} />
|
||||
</div>
|
||||
<ProductDetail product={selected} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductDetail({ product }: { product: ProductItem | null }) {
|
||||
const qc = useQueryClient();
|
||||
// 그래프에서 클릭한 시점(triggered_at). 없으면 최신 시점. 상품이 바뀌어 목록에 없으면 자동으로 최신으로 폴백.
|
||||
const [selectedTs, setSelectedTs] = useState<string | null>(null);
|
||||
const history = useQuery({
|
||||
queryKey: ["history", product?.product_code],
|
||||
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"] }),
|
||||
});
|
||||
|
||||
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 latest = history.data?.points.at(-1);
|
||||
// 클릭한 시점의 데이터 포인트(없으면 최신). 몰별 비교 표에 이 시점의 by_mall 을 보여준다.
|
||||
const activePoint = points.find((p) => p.triggered_at === selectedTs) ?? latest;
|
||||
|
||||
return (
|
||||
// min-w-0: 그리드 자식이 콘텐츠(차트) 최소폭 때문에 안 줄어들어 가로 넘침 나는 것 방지
|
||||
<div className="min-w-0 space-y-4">
|
||||
<Card
|
||||
// xl 이상: 왼쪽 그래프 + 오른쪽 몰별 표 가로 배치. 그 이하: 세로 스택(스크롤).
|
||||
// min-w-0: 자식이 콘텐츠(차트) 최소폭 때문에 안 줄어들어 가로 넘침 나는 것 방지.
|
||||
<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}</>}
|
||||
hint={
|
||||
<button 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">
|
||||
<Button size="sm" onClick={() => research.mutate()} disabled={research.isPending}>
|
||||
{research.isPending ? "요청 중…" : "지금 다시 검색"}
|
||||
</button>
|
||||
</Button>
|
||||
}>
|
||||
{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 ? "검색을 접수했습니다 — 잠시 후 새 점이 추가됩니다" : "이미 같은 상품 검색이 진행 중입니다"}
|
||||
</p>
|
||||
)}
|
||||
{research.isError && <ErrorNote error={research.error} />}
|
||||
{history.isPending && <Loading />}
|
||||
{history.isError && <ErrorNote error={history.error} />}
|
||||
{history.data && points.length === 0 && <Empty>이력이 없습니다</Empty>}
|
||||
{points.length > 0 && (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<LineChart data={points} margin={{ top: 6, right: 12, bottom: 0, left: 0 }}>
|
||||
<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} />
|
||||
<YAxis tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={false}
|
||||
width={64} tickFormatter={(v: number) => v.toLocaleString("ko-KR")} domain={["auto", "auto"]} />
|
||||
<Tooltip formatter={(v: number, name: string) => [won(v), name]} />
|
||||
{SERIES.map((s) => (
|
||||
<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} />
|
||||
{research.isError && <div className="mb-2 shrink-0"><ErrorNote error={research.error} /></div>}
|
||||
<div className="min-h-[240px] flex-1">
|
||||
{history.isPending ? (
|
||||
<div className="grid h-full place-items-center"><Loading /></div>
|
||||
) : history.isError ? (
|
||||
<ErrorNote error={history.error} />
|
||||
) : points.length === 0 ? (
|
||||
<div className="grid h-full place-items-center"><Empty>이력이 없습니다</Empty></div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={points} margin={{ top: 10, right: 16, bottom: 0, left: 0 }} style={{ cursor: "pointer" }}
|
||||
onClick={(state) => {
|
||||
const ts = (state as { activePayload?: { payload?: PricePoint }[] })?.activePayload?.[0]?.payload?.triggered_at;
|
||||
if (ts) setSelectedTs(ts);
|
||||
}}>
|
||||
<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>
|
||||
<p className="mt-1 flex flex-wrap gap-4 text-[11px] font-semibold text-ink-500">
|
||||
{SERIES.map((s) => (
|
||||
<span key={s.key}>
|
||||
<span className="mr-1 inline-block h-2 w-2 rounded-full" style={{ background: s.color }} aria-hidden />
|
||||
{s.label}
|
||||
</span>
|
||||
))}
|
||||
<span className="ml-auto font-medium text-ink-400">빈 구간 = 그 시점에 해당 소스에서 못 찾음</span>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{points.length > 0 && (
|
||||
<Legend items={SERIES.map((s) => ({ label: s.label, color: s.color }))}
|
||||
note="빈 구간 = 그 시점에 해당 소스에서 못 찾음" />
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 몰별 가격 비교(최근 검색) — 기존 '몰별 최저가' + '검증 링크' 를 하나로.
|
||||
// by_mall(몰별 최저가, 가격 오름차순)을 상위 N개 표로: 순위·쇼핑몰·상품명·가격(+배송)·최저가 대비·링크.
|
||||
const TOP_N_PRESETS = [3, 5, 10] as const;
|
||||
// ── 몰별 가격 비교(최근 검색) — 컴팩트 표. 막대 제거, 소스(발견 채널) 뱃지 추가, 상품명은 보조줄로 유지.
|
||||
const TOP_OPTIONS: { v: number | "all"; label: string }[] = [
|
||||
{ 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 malls = (latest.by_mall ?? [])
|
||||
const malls = (point.by_mall ?? [])
|
||||
.filter((m) => m.price != null)
|
||||
.sort((a, b) => (a.price ?? 0) - (b.price ?? 0));
|
||||
const shown = topN === "all" ? malls : malls.slice(0, topN);
|
||||
const cheapest = malls[0]?.price ?? 0;
|
||||
const maxPrice = Math.max(1, ...shown.map((m) => m.price ?? 0));
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="몰별 가격 비교 (최근 검색)"
|
||||
hint={
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="hidden text-ink-400 sm:inline">{dateShort(latest.triggered_at)}</span>
|
||||
<span role="tablist" aria-label="상위 N개" className="flex rounded-lg border border-line-200 p-0.5">
|
||||
{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>
|
||||
}>
|
||||
<Card fill className="min-w-0 xl:w-[320px] xl:shrink-0"
|
||||
title="몰별 가격 비교"
|
||||
hint={<Segmented ariaLabel="상위 N개" size="sm" value={topN} options={TOP_OPTIONS} onChange={setTopN} />}>
|
||||
<p className="mb-1.5 shrink-0 text-[11px] text-ink-400">
|
||||
<span className="tnum font-semibold text-ink-500">{dateShort(point.triggered_at)}</span> 기준 · 그래프의 시점을 클릭해 이동
|
||||
</p>
|
||||
{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) => {
|
||||
const price = m.price ?? 0;
|
||||
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 (
|
||||
<li key={i} className="grid grid-cols-[auto_1fr_auto] items-center gap-x-3 gap-y-1">
|
||||
{/* 순위 */}
|
||||
<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"}`}>
|
||||
{i + 1}
|
||||
</span>
|
||||
{/* 쇼핑몰 + 상품명 */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: src }} aria-hidden />
|
||||
<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>}
|
||||
<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-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>
|
||||
</td>
|
||||
<td className="min-w-0 py-1.5 pr-2 align-top">
|
||||
<div className="flex items-center gap-1">
|
||||
<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>
|
||||
<span className="truncate text-[11px] font-semibold">{m.mall_name || m.source}</span>
|
||||
{i === 0 && <span className="shrink-0 rounded bg-ok-50 px-1 text-[9px] font-bold text-ok-600">최저</span>}
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-ink-400">{m.name}</div>
|
||||
</div>
|
||||
{/* 가격 + 최저가 대비 + 링크 */}
|
||||
<div className="text-right">
|
||||
<div className="tnum text-[14px] font-bold">{won(price)}</div>
|
||||
<div className="tnum text-[11px] text-ink-400">
|
||||
{delta === 0 ? "최저가" : `+${delta.toLocaleString("ko-KR")}원`}
|
||||
<div className="truncate text-[10px] leading-tight text-ink-400">{m.name}</div>
|
||||
</td>
|
||||
<td className="py-1.5 text-right align-top">
|
||||
<div className="tnum whitespace-nowrap text-[12px] font-bold leading-tight">{won(price)}</div>
|
||||
<div className="tnum mt-0.5 flex items-center justify-end gap-0.5 whitespace-nowrap text-[10px] text-ink-400">
|
||||
<span>{delta === 0 ? "최저" : `+${pct.toFixed(1)}%`}</span>
|
||||
{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 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>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</ScrollTable>
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { settings } from "../api/client";
|
||||
import { Card } from "../components/ui";
|
||||
import { Button, Card, PageHeader } from "../components/ui";
|
||||
|
||||
export default function Settings() {
|
||||
const [baseUrl, setBaseUrl] = useState(settings.baseUrl());
|
||||
@ -11,7 +11,7 @@ export default function Settings() {
|
||||
|
||||
return (
|
||||
<div className="max-w-xl space-y-4">
|
||||
<h1 className="text-[17px] font-bold tracking-tight">설정</h1>
|
||||
<PageHeader title="설정" />
|
||||
<Card title="API 연결">
|
||||
<form
|
||||
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" />
|
||||
<p className="mt-1 text-[11px] text-ink-400">서버 toml 의 [WebServerConfig].api_keys 중 하나. 이 브라우저에만 저장됩니다.</p>
|
||||
</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>
|
||||
<Button type="submit" size="lg">저장</Button>
|
||||
{saved && <span className="ml-2 text-[12px] font-medium text-ok-600">저장했습니다</span>}
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
@ -9,11 +9,38 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { get, post } from "../api/client";
|
||||
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";
|
||||
|
||||
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 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 키 매핑.
|
||||
const STAGE_LABEL: Record<string, string> = {
|
||||
@ -102,54 +129,48 @@ export default function TestSearch() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-[17px] font-bold tracking-tight">테스트 검색</h1>
|
||||
<p className="max-w-2xl text-[12px] leading-relaxed text-ink-500">
|
||||
임의의 상품을 실제 검색 파이프라인에 흘려보내 셀렉터·프록시·AI 매칭이 정상인지 점검합니다.
|
||||
워커가 실제로 크롤하므로 <b>프록시 대역폭 비용이 실제로 발생</b>하고, 결과는 비용 통계에 집계됩니다
|
||||
(상품코드는 <code className="rounded bg-surface-2 px-1">TEST-</code> 접두로 구분).
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<PageHeader title="테스트 검색" />
|
||||
<p className="text-[11px] text-ink-400">
|
||||
실제 크롤이라 <b className="font-semibold text-warn-700">프록시 비용 발생</b> · 비용 통계 집계 (상품코드 <code className="rounded bg-surface-2 px-1">TEST-</code>)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[380px_1fr]">
|
||||
<Card title="검색 조건">
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => { e.preventDefault(); if (form.product_name.trim()) submit.mutate(); }}
|
||||
>
|
||||
<Field id="pn" label="상품명" required value={form.product_name}
|
||||
onChange={(v) => setForm((f) => ({ ...f, product_name: v }))} placeholder="예: 맥심 모카골드 커피믹스" />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field id="model" label="모델명" value={form.model} onChange={(v) => setForm((f) => ({ ...f, model: v }))} placeholder="선택" />
|
||||
<Field id="company" label="제조사" value={form.company} onChange={(v) => setForm((f) => ({ ...f, company: v }))} placeholder="선택" />
|
||||
</div>
|
||||
<Field id="spec" label="규격" value={form.specification}
|
||||
onChange={(v) => setForm((f) => ({ ...f, specification: v }))} placeholder="선택 — 예: 1박스 160개입" />
|
||||
<Field id="price" label="현재가(원)" value={form.price} inputMode="numeric"
|
||||
onChange={(v) => setForm((f) => ({ ...f, price: v.replace(/[^0-9]/g, "") }))} placeholder="선택 — 있으면 가격밴드 필터 기준" />
|
||||
<div className="flex gap-2 pt-1">
|
||||
{/* 앰버(warn) — 실제 크롤·프록시 비용이 드는 실행 액션임을 색으로 신호(모니터링 CTA 와 구분). */}
|
||||
<button type="submit" disabled={!form.product_name.trim() || submit.isPending || (!!jobId && status !== "DONE" && status !== "DEAD")}
|
||||
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">
|
||||
{submit.isPending ? "제출 중…" : "검색 실행"}
|
||||
</button>
|
||||
{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">
|
||||
초기화
|
||||
<div className="grid gap-3 lg:grid-cols-[360px_1fr]">
|
||||
{/* 왼쪽: 검색 조건 + 진행 상태(함께 sticky — 결과를 스크롤해도 폼·상태가 붙어 있음) */}
|
||||
<div className="space-y-3 lg:sticky lg:top-0 lg:self-start">
|
||||
<Card compact title="검색 조건">
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => { e.preventDefault(); if (form.product_name.trim() && !jobId) submit.mutate(); }}
|
||||
>
|
||||
<Field id="pn" label="상품명" required value={form.product_name}
|
||||
onChange={(v) => setForm((f) => ({ ...f, product_name: v }))} placeholder="예: 맥심 모카골드 커피믹스" />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field id="model" label="모델명" value={form.model} onChange={(v) => setForm((f) => ({ ...f, model: v }))} placeholder="선택" />
|
||||
<Field id="company" label="제조사" value={form.company} onChange={(v) => setForm((f) => ({ ...f, company: v }))} placeholder="선택" />
|
||||
</div>
|
||||
<Field id="spec" label="규격" value={form.specification}
|
||||
onChange={(v) => setForm((f) => ({ ...f, specification: v }))} placeholder="선택 — 예: 1박스 160개입" />
|
||||
<Field id="price" label="현재가(원)" value={form.price} inputMode="numeric"
|
||||
onChange={(v) => setForm((f) => ({ ...f, price: v.replace(/[^0-9]/g, "") }))} placeholder="선택 — 있으면 가격밴드 필터 기준" />
|
||||
<div className="flex gap-2 pt-1">
|
||||
{/* 앰버(warn) — 실제 크롤·프록시 비용이 드는 실행 액션. 제출 후엔 초기화 전까지 재검색 차단(중복 비용 방지). */}
|
||||
<button type="submit" disabled={!form.product_name.trim() || submit.isPending || !!jobId}
|
||||
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">
|
||||
{submit.isPending ? "제출 중…" : jobId ? "실행됨 — 초기화 후 재검색" : "검색 실행"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{submit.isError && <ErrorNote error={submit.error} />}
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4">
|
||||
{!jobId && (
|
||||
<Card title="결과"><Empty>왼쪽에서 상품명을 입력하고 검색을 실행하세요</Empty></Card>
|
||||
)}
|
||||
{jobId && (
|
||||
<Button type="button" variant="outline" size="lg" onClick={reset}>초기화</Button>
|
||||
)}
|
||||
</div>
|
||||
{submit.isError && <ErrorNote error={submit.error} />}
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{jobId && (
|
||||
<Card title="진행 상태" hint={testCode ?? undefined}>
|
||||
<Card compact title="진행 상태" hint={testCode ?? undefined}>
|
||||
<Progress status={status} elapsed={elapsed} />
|
||||
{job.isError && <div className="mt-2"><ErrorNote error={job.error} /></div>}
|
||||
{pendingTooLong && (
|
||||
@ -164,8 +185,21 @@ export default function TestSearch() {
|
||||
)}
|
||||
</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>
|
||||
@ -207,9 +241,20 @@ function Progress({ status, elapsed }: { status?: string; elapsed: number }) {
|
||||
function ResultView({ output }: { output: JobOutput }) {
|
||||
const found = output.outcome === "found";
|
||||
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 (
|
||||
<>
|
||||
<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 ? (
|
||||
<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>
|
||||
@ -217,7 +262,7 @@ function ResultView({ output }: { output: JobOutput }) {
|
||||
<span className="text-[15px] font-bold">
|
||||
최저가 {won(output.lowest.price)}
|
||||
<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>
|
||||
)}
|
||||
@ -231,15 +276,20 @@ function ResultView({ output }: { output: JobOutput }) {
|
||||
</Card>
|
||||
|
||||
{output.stages && output.stages.length > 0 && (
|
||||
<Card title="파이프라인 퍼널" hint="각 단계가 몇 건을 남겼나">
|
||||
<Card compact title="파이프라인 퍼널" hint="각 단계가 몇 건을 남겼나">
|
||||
<FunnelChart stages={output.stages} totalFound={output.total_found} />
|
||||
</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 && (
|
||||
<Card title="소스별 수집" hint="검색어 매칭 전 원본 건수">
|
||||
<ul className="space-y-1.5 text-[12px]">
|
||||
<Card compact title="소스별 수집" hint="검색어 매칭 전 원본 건수">
|
||||
{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]) => (
|
||||
<li key={s} className="flex items-baseline justify-between gap-2">
|
||||
<span className="font-semibold">{sourceLabel(s)}</span>
|
||||
@ -258,8 +308,8 @@ function ResultView({ output }: { output: JobOutput }) {
|
||||
)}
|
||||
|
||||
{m && (
|
||||
<Card title="검색 원가" hint={m.duration_ms != null ? `${(m.duration_ms / 1000).toFixed(1)}초 소요` : undefined}>
|
||||
<ul className="space-y-1.5 text-[12px]">
|
||||
<Card compact title="검색 원가" hint={m.duration_ms != null ? `${(m.duration_ms / 1000).toFixed(1)}초 소요` : undefined}>
|
||||
<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">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>
|
||||
@ -275,38 +325,52 @@ function ResultView({ output }: { output: JobOutput }) {
|
||||
</div>
|
||||
|
||||
{output.top && output.top.length > 0 && (
|
||||
<Card title="매칭된 상품" hint={`최저가순 상위 ${output.top.length}건`}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-[12px]">
|
||||
<thead>
|
||||
<tr className="border-b border-line-200 text-[11px] text-ink-400">
|
||||
<th className="pb-1.5 pr-3 font-semibold">소스</th>
|
||||
<th className="pb-1.5 pr-3 font-semibold">상품명</th>
|
||||
<th className="pb-1.5 pr-3 text-right font-semibold">가격</th>
|
||||
<th className="pb-1.5 font-semibold">링크</th>
|
||||
<Card compact title="매칭된 상품" hint={`최저가순 상위 ${output.top.length}건`}>
|
||||
<ScrollTable height="auto" dense columns={TOP_COLS}>
|
||||
{output.top.map((p, i) => {
|
||||
const src = srcColor(p.source);
|
||||
const ship = shipInfo(p.shipping_fee, p.shipping_type);
|
||||
return (
|
||||
<tr key={i} className="border-b border-line-100 hover:bg-surface-2">
|
||||
<td className="py-1.5 pr-2 align-top">
|
||||
<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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{output.top.map((p, i) => (
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</ScrollTable>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<details>
|
||||
<summary className="cursor-pointer text-[12px] font-semibold text-ink-400">원본 결과(JSON)</summary>
|
||||
<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>
|
||||
</details>
|
||||
<div>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowJson(true)}>원본 결과(JSON) 보기</Button>
|
||||
</div>
|
||||
|
||||
{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));
|
||||
return (
|
||||
<ul className="space-y-1.5">
|
||||
{rows.map((r, i) => (
|
||||
<li key={i} className="flex items-center gap-2 text-[12px]">
|
||||
<span className="w-28 shrink-0 text-ink-500">{r.label}</span>
|
||||
<div className="h-5 flex-1 overflow-hidden rounded bg-surface-2">
|
||||
<div className="h-full rounded bg-primary-600" style={{ width: `${(r.value / max) * 100}%`, minWidth: r.value > 0 ? "2px" : 0 }} aria-hidden />
|
||||
</div>
|
||||
<span className="tnum w-10 shrink-0 text-right font-semibold">{r.value}</span>
|
||||
</li>
|
||||
))}
|
||||
<ul className="space-y-1">
|
||||
{rows.map((r, i) => {
|
||||
const prev = i > 0 ? rows[i - 1].value : null;
|
||||
const drop = prev != null ? r.value - prev : null; // 이전 단계 대비 증감(보통 감소)
|
||||
const dropPct = prev && prev > 0 && drop != null && drop < 0 ? Math.round((drop / prev) * 100) : null;
|
||||
return (
|
||||
// 막대는 고정 폭(14rem)으로 짧게, 값·감소량은 우측 넓은 영역에 한 줄로.
|
||||
<li key={i} className="grid grid-cols-[6.5rem_14rem_2.25rem_1fr] items-center gap-x-2 text-[12px]">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user