feat(lps-temp-fe): React 관리자 페이지 — 대시보드·작업큐·상품가격·크롤·비용
임시 단일 HTML 콘솔을 걷어내고 5페이지 관리자 앱으로 교체(협의: 모니터링+필수 액션, negodata 계열 경량 스택 — Vite+React+TS+Tailwind +recharts+react-query+react-router). - 대시보드: /ops 5초 폴링 스탯 타일 8종 + 임계 배너(서버 AlertConfig 기본값 미러) + 세션 내 누적 큐 추이. - 작업 큐: 상태 탭·상품 검색·페이지네이션·상세 패널(결과/원가/오류), DEAD 재큐 버튼. - 상품·가격: 가격 추이 3선 + 몰별 최저가 바 + 검증 링크 + 수동 재검색. 시리즈 색 엔터티 고정 — 네이버 #008a43 은 쿠팡 적색과의 적록색약 분리(ΔE 14.5)·3:1 대비를 검증해 톤다운한 값. - 크롤 상태: 종료사유 도넛(의미 기반 상태색)·세션당 요청 수 분포 +차단 시작 기준선(예산 튜닝 뷰)·차단 추이/이력·세션 테이블. - 비용: 시간별 원가 스택(AI vs 프록시)·건당 평균·평균 소요(축이 달라 별도 차트 — 이중축 금지). - 설정: API 주소·guard 키(localStorage → X-API-Key 자동 첨부). 서버 설정 변경 UI 없음(toml 단일 소스 원칙). - dev 는 vite 프록시(:9600)로 CORS 불필요. 5페이지 실렌더 스크린샷 검증. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
61b438d8d0
commit
6eb71eb035
3
lps-temp-fe/.gitignore
vendored
Normal file
3
lps-temp-fe/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
*.local
|
||||
33
lps-temp-fe/README.md
Normal file
33
lps-temp-fe/README.md
Normal file
@ -0,0 +1,33 @@
|
||||
# LPS 관리자 페이지
|
||||
|
||||
LPS(인터넷 최저가 검색)의 운영 지표를 보고 필수 액션(수동 재검색·DEAD 재큐)을 수행하는
|
||||
React 관리자 페이지입니다. 서버 동작 설정은 여기서 바꾸지 않습니다 — 설정 소스는 서버의
|
||||
`config.<env>.toml` 하나(2026-07-13 협의).
|
||||
|
||||
## 실행 (개발)
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # http://localhost:5174 — /v1·/healthz 는 vite 프록시로 :9600 에 전달(CORS 불필요)
|
||||
```
|
||||
|
||||
LPS API 를 먼저 띄워두세요: `cd ../lps && ./run_local_server.sh`.
|
||||
다른 호스트의 API 를 보려면 `VITE_LPS_URL=http://<host>:9600 npm run dev` 또는
|
||||
앱의 **설정** 페이지에서 API 주소를 지정합니다(guard 켜진 prod 는 API 키도 입력 — X-API-Key 자동 첨부).
|
||||
|
||||
## 페이지
|
||||
|
||||
| 페이지 | 내용 | 데이터 |
|
||||
|---|---|---|
|
||||
| 대시보드 | 큐/차단/비용/DB풀 스탯 타일 + 임계 배너 + 큐 추이(5초 폴링) | `GET /v1/lps/ops` |
|
||||
| 작업 큐 | 목록(상태·검색 필터)·상세(결과/원가/오류)·**DEAD 재큐** | `/v1/lps/jobs`, `/jobs/{id}/requeue` |
|
||||
| 상품·가격 | 가격 추이 3선(네이버·쿠팡·최종)·몰별 최저가·검증 링크·**지금 다시 검색** | `/v1/lps/products`, `/products/{code}/history`, `POST /search` |
|
||||
| 크롤 상태 | IP 세션 종료사유 도넛·요청 수 분포(+차단 시작 기준선 = 예산 튜닝 뷰)·차단 이력 | `/v1/lps/stats/ip-sessions`, `/stats/bot` |
|
||||
| 비용 | 시간별 원가 스택(AI vs 프록시)·건당 평균·평균 소요 | `/v1/lps/stats/cost` |
|
||||
|
||||
## 스택·규칙
|
||||
|
||||
- Vite + React + TS + Tailwind v4 + recharts + @tanstack/react-query + react-router (negodata 프론트와 동일 계열, 경량)
|
||||
- 색 토큰은 숫자 스케일(`src/index.css` @theme). 차트 시리즈 색은 **엔터티 고정** —
|
||||
네이버 `#008a43`(적록색약 분리 검증 통과 톤) · 쿠팡 `#f0455b` · 최종 `#4f46e5`. 순서·색 재배정 금지.
|
||||
- 빌드: `npm run build` (tsc 포함) → `dist/`
|
||||
@ -1,672 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>LPS · 최저가 검색 콘솔 (임시 FE)</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#f5f6f8; --surface:#ffffff; --surface-2:#fafbfc;
|
||||
--border:#e6e8ec; --border-2:#eef0f3;
|
||||
--text-900:#1a1d24; --text-700:#3d434f; --text-500:#6b7280; --text-400:#9aa1ac;
|
||||
--primary-600:#4f46e5; --primary-500:#635bff; --primary-50:#eef0ff;
|
||||
--naver:#03c75a; --coupang:#f0455b; --final:#4f46e5;
|
||||
--ok-600:#0f9d58; --ok-50:#e7f6ee;
|
||||
--warn-600:#c77800; --warn-50:#fdf3e3;
|
||||
--run-600:#2563eb; --run-50:#e8f0ff;
|
||||
--dead-600:#dc2626; --dead-50:#fdeaea;
|
||||
--radius:14px; --radius-sm:9px;
|
||||
--shadow:0 1px 2px rgba(16,24,40,.04), 0 4px 16px rgba(16,24,40,.06);
|
||||
--mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;
|
||||
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Apple SD Gothic Neo","Noto Sans KR",sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0}
|
||||
body{background:var(--bg);color:var(--text-900);font-family:var(--sans);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased}
|
||||
a{color:var(--primary-600);text-decoration:none}
|
||||
a:hover{text-decoration:underline}
|
||||
|
||||
/* ---- layout ---- */
|
||||
header.top{position:sticky;top:0;z-index:20;background:rgba(255,255,255,.82);backdrop-filter:saturate(180%) blur(12px);border-bottom:1px solid var(--border)}
|
||||
.top-inner{max-width:1180px;margin:0 auto;padding:12px 22px;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
|
||||
.brand{display:flex;align-items:center;gap:11px;font-weight:700;font-size:16px}
|
||||
.logo{width:30px;height:30px;border-radius:8px;background:linear-gradient(135deg,var(--primary-500),#8b7bff);display:grid;place-items:center;color:#fff;font-size:15px;font-weight:800}
|
||||
.brand small{display:block;font-weight:500;font-size:11px;color:var(--text-400);letter-spacing:.02em}
|
||||
.top-spacer{flex:1}
|
||||
.stat-badges{display:flex;gap:7px;flex-wrap:wrap}
|
||||
.badge{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border-radius:999px;font-size:12px;font-weight:600;border:1px solid var(--border);background:var(--surface-2);color:var(--text-700)}
|
||||
.badge .dot{width:7px;height:7px;border-radius:50%;background:var(--text-400)}
|
||||
.badge b{font-variant-numeric:tabular-nums}
|
||||
.badge.pending .dot{background:var(--warn-600)} .badge.running .dot{background:var(--run-600)}
|
||||
.badge.done .dot{background:var(--ok-600)} .badge.dead .dot{background:var(--dead-600)}
|
||||
.health{display:inline-flex;align-items:center;gap:7px;font-size:12px;color:var(--text-500);font-weight:600}
|
||||
.pulse{width:8px;height:8px;border-radius:50%;background:var(--dead-600)}
|
||||
.pulse.up{background:var(--ok-600);box-shadow:0 0 0 0 rgba(15,157,88,.5);animation:pulse 2s infinite}
|
||||
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(15,157,88,.45)}70%{box-shadow:0 0 0 7px rgba(15,157,88,0)}100%{box-shadow:0 0 0 0 rgba(15,157,88,0)}}
|
||||
|
||||
main{max-width:1180px;margin:0 auto;padding:22px;display:grid;grid-template-columns:400px 1fr;gap:20px;align-items:start}
|
||||
@media (max-width:940px){main{grid-template-columns:1fr}}
|
||||
|
||||
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow)}
|
||||
.card + .card{margin-top:20px}
|
||||
.card-head{padding:15px 18px;border-bottom:1px solid var(--border-2);display:flex;align-items:center;gap:10px}
|
||||
.card-head h2{margin:0;font-size:14px;font-weight:700;letter-spacing:-.01em}
|
||||
.card-head .hint{margin-left:auto;font-size:11.5px;color:var(--text-400);font-weight:500}
|
||||
.card-head .idx{width:22px;height:22px;border-radius:7px;background:var(--primary-50);color:var(--primary-600);display:grid;place-items:center;font-size:12px;font-weight:800}
|
||||
.card-body{padding:18px}
|
||||
|
||||
/* ---- form ---- */
|
||||
.field{margin-bottom:13px}
|
||||
.field:last-child{margin-bottom:0}
|
||||
.field label{display:block;font-size:12px;font-weight:600;color:var(--text-700);margin-bottom:5px}
|
||||
.field label .req{color:var(--coupang);margin-left:2px}
|
||||
.field .sub{font-weight:500;color:var(--text-400);font-size:11px}
|
||||
input,select,textarea{width:100%;padding:9px 11px;border:1px solid var(--border);border-radius:var(--radius-sm);font-size:13px;font-family:inherit;color:var(--text-900);background:var(--surface);transition:border-color .12s,box-shadow .12s}
|
||||
input:focus,select:focus,textarea:focus{outline:none;border-color:var(--primary-500);box-shadow:0 0 0 3px var(--primary-50)}
|
||||
.row2{display:grid;grid-template-columns:1fr 1fr;gap:11px}
|
||||
.btn{appearance:none;border:1px solid transparent;border-radius:var(--radius-sm);font-family:inherit;font-size:13px;font-weight:600;padding:10px 15px;cursor:pointer;transition:background .12s,border-color .12s,color .12s;display:inline-flex;align-items:center;justify-content:center;gap:7px}
|
||||
.btn-primary{background:var(--primary-600);color:#fff;width:100%}
|
||||
.btn-primary:hover{background:var(--primary-500)}
|
||||
.btn-primary:disabled{background:#c3c6cf;cursor:not-allowed}
|
||||
.btn-ghost{background:var(--surface);border-color:var(--border);color:var(--text-700)}
|
||||
.btn-ghost:hover{background:var(--surface-2)}
|
||||
.btn-sm{padding:7px 11px;font-size:12px}
|
||||
|
||||
.examples{display:flex;gap:6px;flex-wrap:wrap;margin-top:2px}
|
||||
.chip{font-size:11.5px;padding:4px 9px;border-radius:999px;border:1px solid var(--border);background:var(--surface-2);color:var(--text-700);cursor:pointer}
|
||||
.chip:hover{border-color:var(--primary-500);color:var(--primary-600);background:var(--primary-50)}
|
||||
|
||||
/* ---- job tracking ---- */
|
||||
.empty{padding:38px 20px;text-align:center;color:var(--text-400)}
|
||||
.empty .big{font-size:30px;margin-bottom:8px;opacity:.5}
|
||||
.job-meta{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:16px}
|
||||
.job-id{font-family:var(--mono);font-size:12px;background:var(--surface-2);border:1px solid var(--border-2);padding:4px 8px;border-radius:6px;color:var(--text-700)}
|
||||
.status-pill{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:999px;font-size:12px;font-weight:700}
|
||||
.status-pill.PENDING{background:var(--warn-50);color:var(--warn-600)}
|
||||
.status-pill.RUNNING{background:var(--run-50);color:var(--run-600)}
|
||||
.status-pill.DONE{background:var(--ok-50);color:var(--ok-600)}
|
||||
.status-pill.DEAD{background:var(--dead-50);color:var(--dead-600)}
|
||||
.status-pill .spin{width:11px;height:11px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:spin .7s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
|
||||
.outcome-hero{display:flex;align-items:flex-end;gap:14px;padding:16px;border-radius:var(--radius-sm);background:linear-gradient(135deg,#f7f8ff,#f0f3ff);border:1px solid var(--border-2);margin-bottom:16px}
|
||||
.outcome-hero.nf{background:linear-gradient(135deg,#fbfbfc,#f4f5f7)}
|
||||
.price-big{font-size:32px;font-weight:800;letter-spacing:-.02em;line-height:1;font-variant-numeric:tabular-nums}
|
||||
.price-big .won{font-size:18px;font-weight:700;color:var(--text-500);margin-left:2px}
|
||||
.src-tag{font-size:11px;font-weight:700;padding:3px 8px;border-radius:6px;color:#fff;letter-spacing:.02em;white-space:nowrap}
|
||||
.src-tag.naver{background:var(--naver)} .src-tag.coupang{background:var(--coupang)}
|
||||
.src-tag.gmarket{background:#00b25a} .src-tag.auction{background:#e60012} .src-tag.st11{background:#ff0038}
|
||||
.src-tag.fallback{position:relative}
|
||||
.src-tag.fallback::after{content:"크롤";position:absolute;top:-6px;right:-6px;font-size:8px;background:#1a1d24;color:#fff;padding:1px 3px;border-radius:4px;letter-spacing:0}
|
||||
.ship-tag{display:inline-flex;align-items:center;gap:4px;font-size:10.5px;font-weight:700;padding:2px 7px;border-radius:5px;border:1px solid transparent;white-space:nowrap}
|
||||
.ship-tag.rocket{background:#eef4ff;color:#2f6fed;border-color:#d5e3ff}
|
||||
.ship-tag.rocket_merchant{background:#f3eeff;color:#7a4fe0;border-color:#e3d8ff}
|
||||
.ship-tag.free{background:var(--ok-50);color:var(--ok-600)}
|
||||
.ship-tag.paid{background:#fbecec;color:var(--dead-600)}
|
||||
.ship-tag.unknown{background:var(--surface-2);color:var(--text-400);border-color:var(--border-2)}
|
||||
.outcome-label{font-size:11px;font-weight:700;color:var(--text-500);text-transform:uppercase;letter-spacing:.04em;margin-bottom:6px}
|
||||
|
||||
/* funnel / stages */
|
||||
.stages{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}
|
||||
.stage{flex:1;min-width:82px;background:var(--surface-2);border:1px solid var(--border-2);border-radius:var(--radius-sm);padding:10px;text-align:center;position:relative}
|
||||
.stage .st-name{font-size:11px;color:var(--text-500);font-weight:600;margin-bottom:4px;text-transform:capitalize}
|
||||
.stage .st-io{font-size:15px;font-weight:800;font-variant-numeric:tabular-nums}
|
||||
.stage .st-io .arr{color:var(--text-400);font-weight:500;margin:0 3px}
|
||||
.stage .st-drop{font-size:10.5px;color:var(--coupang);font-weight:600;margin-top:2px}
|
||||
|
||||
.src-counts{display:flex;gap:8px;margin-bottom:16px}
|
||||
.src-count{flex:1;border:1px solid var(--border-2);border-radius:var(--radius-sm);padding:10px 12px;background:var(--surface-2)}
|
||||
.src-count .lbl{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.03em;margin-bottom:3px}
|
||||
.src-count .lbl.naver{color:var(--naver)} .src-count .lbl.coupang{color:var(--coupang)}
|
||||
.src-count .n{font-size:17px;font-weight:800;font-variant-numeric:tabular-nums}
|
||||
.src-count .err{font-size:11px;color:var(--dead-600);font-weight:600}
|
||||
|
||||
/* 몰별 최저가 분해 */
|
||||
.mall-bars{display:flex;flex-direction:column;gap:7px;margin-bottom:16px}
|
||||
.mall-bar{display:flex;align-items:center;gap:10px}
|
||||
.mall-bar .mname{width:112px;flex-shrink:0;font-size:12px;font-weight:600;color:var(--text-700);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:center;gap:5px}
|
||||
.mall-bar .mname .sdot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
|
||||
.mall-bar .track{flex:1;height:22px;background:var(--surface-2);border-radius:6px;position:relative;overflow:hidden}
|
||||
.mall-bar .fill{position:absolute;left:0;top:0;bottom:0;border-radius:6px;opacity:.16}
|
||||
.mall-bar .mprice{position:absolute;right:8px;top:0;bottom:0;display:flex;align-items:center;font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;color:var(--text-900)}
|
||||
.mall-bar.best .mprice{color:var(--ok-600)}
|
||||
.mall-bar .badge-best{position:absolute;left:8px;top:0;bottom:0;display:flex;align-items:center;font-size:10px;font-weight:800;color:var(--ok-600)}
|
||||
|
||||
.sec-title{font-size:12px;font-weight:700;color:var(--text-700);margin:0 0 9px;display:flex;align-items:center;gap:7px}
|
||||
.sec-title .cnt{font-size:11px;color:var(--text-400);font-weight:600}
|
||||
|
||||
/* 검색 원가 계측 */
|
||||
.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:16px}
|
||||
@media (max-width:520px){.metrics{grid-template-columns:repeat(2,1fr)}}
|
||||
.metric{border:1px solid var(--border-2);border-radius:var(--radius-sm);padding:10px 11px;background:var(--surface-2)}
|
||||
.metric .mk{font-size:10.5px;font-weight:700;color:var(--text-400);text-transform:uppercase;letter-spacing:.03em;margin-bottom:4px}
|
||||
.metric .mv{font-size:16px;font-weight:800;font-variant-numeric:tabular-nums;line-height:1.1}
|
||||
.metric .mv small{font-size:11px;font-weight:600;color:var(--text-500)}
|
||||
.metric .msub{font-size:10.5px;color:var(--text-400);font-weight:600;margin-top:2px}
|
||||
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
th{text-align:left;font-size:11px;font-weight:700;color:var(--text-500);text-transform:uppercase;letter-spacing:.03em;padding:8px 10px;border-bottom:1px solid var(--border-2)}
|
||||
td{padding:9px 10px;border-bottom:1px solid var(--border-2);vertical-align:top}
|
||||
tr:last-child td{border-bottom:none}
|
||||
.price-cell{font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
.name-cell{max-width:340px}
|
||||
.name-cell a{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.rank{display:inline-grid;place-items:center;width:20px;height:20px;border-radius:6px;background:var(--surface-2);border:1px solid var(--border-2);font-size:11px;font-weight:800;color:var(--text-500)}
|
||||
.rank.top{background:var(--primary-50);color:var(--primary-600);border-color:transparent}
|
||||
|
||||
/* ---- chart ---- */
|
||||
.chart-controls{display:flex;gap:9px;align-items:flex-end;margin-bottom:16px}
|
||||
.chart-controls .field{flex:1;margin:0}
|
||||
.chart-wrap{position:relative;width:100%;overflow-x:auto}
|
||||
svg.chart{display:block;width:100%;height:auto;min-width:520px}
|
||||
.legend{display:flex;gap:16px;margin-top:12px;flex-wrap:wrap}
|
||||
.legend-item{display:flex;align-items:center;gap:7px;font-size:12px;font-weight:600;color:var(--text-700);cursor:pointer;user-select:none}
|
||||
.legend-item .swatch{width:14px;height:3px;border-radius:2px}
|
||||
.legend-item.off{opacity:.35}
|
||||
.legend-item .swatch.dashed{background:none!important;border-top:2px dashed}
|
||||
|
||||
.chart-tip{position:absolute;pointer-events:none;background:#1a1d24;color:#fff;padding:8px 10px;border-radius:8px;font-size:11.5px;box-shadow:0 6px 24px rgba(0,0,0,.2);opacity:0;transition:opacity .1s;z-index:5;min-width:130px;transform:translate(-50%,-115%)}
|
||||
.chart-tip .tt-time{color:#9aa1ac;font-size:10.5px;margin-bottom:5px}
|
||||
.chart-tip .tt-row{display:flex;justify-content:space-between;gap:12px;font-variant-numeric:tabular-nums}
|
||||
.chart-tip .tt-row .k{display:flex;align-items:center;gap:6px}
|
||||
.chart-tip .tt-dot{width:7px;height:7px;border-radius:50%}
|
||||
|
||||
/* ---- misc ---- */
|
||||
.toast-wrap{position:fixed;bottom:22px;right:22px;z-index:100;display:flex;flex-direction:column;gap:9px}
|
||||
.toast{background:#1a1d24;color:#fff;padding:12px 16px;border-radius:10px;font-size:13px;box-shadow:0 8px 30px rgba(0,0,0,.24);display:flex;align-items:center;gap:9px;animation:slideIn .25s ease;max-width:340px}
|
||||
.toast.err{background:#b91c1c} .toast.ok{background:#0f9d58}
|
||||
@keyframes slideIn{from{transform:translateX(20px);opacity:0}to{transform:translateX(0);opacity:1}}
|
||||
.muted{color:var(--text-400)}
|
||||
.spin-inline{width:13px;height:13px;border:2px solid var(--border);border-right-color:var(--primary-500);border-radius:50%;display:inline-block;animation:spin .7s linear infinite;vertical-align:-2px}
|
||||
.cfg-row{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-500)}
|
||||
.cfg-row input{width:auto;flex:1;padding:6px 9px;font-size:12px;font-family:var(--mono)}
|
||||
code{font-family:var(--mono);font-size:12px;background:var(--surface-2);padding:1px 5px;border-radius:4px;border:1px solid var(--border-2)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="top">
|
||||
<div class="top-inner">
|
||||
<div class="brand">
|
||||
<div class="logo">₩</div>
|
||||
<div>LPS 최저가 검색 콘솔
|
||||
<small>임시 프론트엔드 · API 통신 확인용</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="top-spacer"></div>
|
||||
<div class="stat-badges" id="queueBadges">
|
||||
<span class="badge pending"><span class="dot"></span>PENDING <b>–</b></span>
|
||||
<span class="badge running"><span class="dot"></span>RUNNING <b>–</b></span>
|
||||
<span class="badge done"><span class="dot"></span>DONE <b>–</b></span>
|
||||
<span class="badge dead"><span class="dot"></span>DEAD <b>–</b></span>
|
||||
</div>
|
||||
<div class="health"><span class="pulse" id="healthDot"></span><span id="healthTxt">확인 중…</span></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- ============ LEFT: 검색 요청 ============ -->
|
||||
<section>
|
||||
<div class="card">
|
||||
<div class="card-head"><span class="idx">1</span><h2>검색 요청</h2><span class="hint">POST /v1/lps/search</span></div>
|
||||
<div class="card-body">
|
||||
<div class="field">
|
||||
<label>빠른 예시</label>
|
||||
<div class="examples" id="examples"></div>
|
||||
</div>
|
||||
<form id="searchForm">
|
||||
<div class="row2">
|
||||
<div class="field">
|
||||
<label>상품 코드 <span class="req">*</span> <span class="sub">이력 키</span></label>
|
||||
<input name="product_code" required placeholder="T1" autocomplete="off"/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>우선순위 <span class="sub">job_type</span></label>
|
||||
<select name="job_type">
|
||||
<option value="new_product">new_product · 1 (최우선)</option>
|
||||
<option value="manual" selected>manual · 2 (기본)</option>
|
||||
<option value="partner">partner · 3</option>
|
||||
<option value="batch">batch · 4</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>상품명 <span class="req">*</span></label>
|
||||
<input name="product_name" required placeholder="맥심 모카골드 커피믹스" autocomplete="off"/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>규격 <span class="sub">자유 서술 — AI가 해석</span></label>
|
||||
<input name="specification" placeholder="1박스, 160개입" autocomplete="off"/>
|
||||
</div>
|
||||
<div class="row2">
|
||||
<div class="field">
|
||||
<label>모델명 <span class="sub">선택</span></label>
|
||||
<input name="model" placeholder="모카골드" autocomplete="off"/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>제조사 <span class="sub">선택</span></label>
|
||||
<input name="company" placeholder="동서식품" autocomplete="off"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>현재가 <span class="sub">있으면 가격밴드 필터 기준</span></label>
|
||||
<input name="price" inputmode="numeric" placeholder="25000" autocomplete="off"/>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" id="submitBtn">검색 요청 보내기</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>API 설정</h2><span class="hint">Base URL</span></div>
|
||||
<div class="card-body">
|
||||
<div class="cfg-row">
|
||||
<input id="baseUrl" value="http://localhost:9600"/>
|
||||
<button class="btn btn-ghost btn-sm" id="pingBtn">확인</button>
|
||||
</div>
|
||||
<p class="muted" style="margin:10px 0 0;font-size:11.5px">
|
||||
이 페이지는 <code>:5173</code>에서 서빙되어야 CORS가 통과합니다 (<code>serve.sh</code> 참고).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ RIGHT: 결과 + 그래프 ============ -->
|
||||
<section>
|
||||
<!-- 작업 추적 / 상품 정보 -->
|
||||
<div class="card">
|
||||
<div class="card-head"><span class="idx">2</span><h2>작업 추적 & 상품 정보</h2><span class="hint" id="jobHint">GET /v1/lps/jobs/{id}</span></div>
|
||||
<div class="card-body" id="jobBody">
|
||||
<div class="empty">
|
||||
<div class="big">📦</div>
|
||||
왼쪽에서 검색을 요청하면 여기에서<br/>진행 상태와 최저가 결과가 실시간으로 표시됩니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 최저가 이력 그래프 -->
|
||||
<div class="card">
|
||||
<div class="card-head"><span class="idx">3</span><h2>최저가 이력 그래프</h2><span class="hint">GET /v1/lps/products/{code}/history</span></div>
|
||||
<div class="card-body">
|
||||
<div class="chart-controls">
|
||||
<div class="field">
|
||||
<label>상품 코드</label>
|
||||
<input id="histCode" placeholder="P1" autocomplete="off"/>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm" id="loadHistBtn" style="height:37px">이력 불러오기</button>
|
||||
</div>
|
||||
<div id="chartArea">
|
||||
<div class="empty"><div class="big">📈</div>상품 코드를 입력하고 이력을 불러오세요.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="toast-wrap" id="toasts"></div>
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
const $ = (s,el=document)=>el.querySelector(s);
|
||||
const $$ = (s,el=document)=>Array.from(el.querySelectorAll(s));
|
||||
const base = ()=> $("#baseUrl").value.replace(/\/+$/,"");
|
||||
const won = n => (n==null? "—" : Number(n).toLocaleString("ko-KR"));
|
||||
const esc = s => (s==null?"":String(s)).replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c]));
|
||||
|
||||
/* 배송 뱃지: shipping_type + shipping_fee → 라벨. 네이버는 항상 '배송비 별도'. */
|
||||
function shipBadge(p){
|
||||
const t=p.shipping_type, fee=p.shipping_fee;
|
||||
if(p.source==="naver") return `<span class="ship-tag unknown" title="네이버 오픈API는 배송비 제외 상품가입니다">배송비 별도</span>`;
|
||||
const map={
|
||||
rocket:["rocket","🚀 로켓"], rocket_merchant:["rocket_merchant","🚀 판매자로켓"],
|
||||
free:["free","무료배송"], paid:["paid", fee!=null?`배송비 ${won(fee)}원`:"유료배송"],
|
||||
};
|
||||
if(t && map[t]) return `<span class="ship-tag ${map[t][0]}">${map[t][1]}</span>`;
|
||||
return `<span class="ship-tag unknown">배송비 미확인</span>`;
|
||||
}
|
||||
/* 실구매가(상품가+확정 배송비). 미확인이면 null. */
|
||||
function totalPrice(p){ return (typeof p.shipping_fee==="number") ? p.price+p.shipping_fee : null; }
|
||||
|
||||
/* 소스 메타: 표시명·색상. gmarket/auction/st11 은 네이버 폴백 크롤 소스. */
|
||||
const SRC = {
|
||||
naver: {label:"NAVER", color:"#03c75a", crawl:false},
|
||||
coupang:{label:"COUPANG",color:"#f0455b", crawl:false},
|
||||
gmarket:{label:"G마켓", color:"#00b25a", crawl:true},
|
||||
auction:{label:"옥션", color:"#e60012", crawl:true},
|
||||
st11: {label:"11번가", color:"#ff0038", crawl:true},
|
||||
};
|
||||
function srcColor(s){ return (SRC[s]||{}).color || "#6b7280"; }
|
||||
function srcTag(p){
|
||||
const m=SRC[p.source]||{label:p.source};
|
||||
return `<span class="src-tag ${p.source}${m.crawl?' fallback':''}" title="${m.crawl?'네이버 미커버 → 실사이트 크롤':''}">${esc(m.label)}</span>`;
|
||||
}
|
||||
/* 판매몰 표시명: 네이버 가격비교(mall='네이버')는 '여러 판매자 최저가 롤업'임을 명시. */
|
||||
function mallLabel(p){
|
||||
if(p.source==="coupang") return "쿠팡";
|
||||
const m=p.mall_name||(SRC[p.source]||{}).label||"기타";
|
||||
return (m==="네이버") ? "네이버 가격비교" : m;
|
||||
}
|
||||
function isCatalog(p){ return p.source==="naver" && (p.mall_name==="네이버"); }
|
||||
|
||||
/* ---------- toast ---------- */
|
||||
function toast(msg,type=""){
|
||||
const t=document.createElement("div"); t.className="toast "+type; t.textContent=msg;
|
||||
$("#toasts").appendChild(t); setTimeout(()=>{t.style.opacity="0";t.style.transition="opacity .3s";setTimeout(()=>t.remove(),300)},3200);
|
||||
}
|
||||
|
||||
/* ---------- fetch helper ---------- */
|
||||
async function api(path,opts){
|
||||
const r = await fetch(base()+path, Object.assign({headers:{"Content-Type":"application/json"}},opts));
|
||||
const txt = await r.text();
|
||||
let data; try{ data = txt? JSON.parse(txt):{} }catch(e){ throw new Error("응답 파싱 실패: "+txt.slice(0,120)) }
|
||||
if(!r.ok) throw new Error("HTTP "+r.status+" · "+(data?.result?.desc||txt.slice(0,120)));
|
||||
if(data?.result && data.result.success===false) throw new Error(data.result.desc||"API 실패");
|
||||
return data;
|
||||
}
|
||||
|
||||
/* ---------- health + queue polling ---------- */
|
||||
async function refreshHealth(){
|
||||
try{
|
||||
const t = await api("/healthz");
|
||||
$("#healthDot").className="pulse up";
|
||||
$("#healthTxt").textContent = "온라인 · "+(typeof t==="string"?t.replace("T"," ").slice(0,19):"");
|
||||
}catch(e){
|
||||
$("#healthDot").className="pulse";
|
||||
$("#healthTxt").textContent="오프라인";
|
||||
}
|
||||
}
|
||||
async function refreshQueue(){
|
||||
try{
|
||||
const d = await api("/v1/lps/queue/stats");
|
||||
const c = d.counts||{};
|
||||
const b = $$("#queueBadges .badge b");
|
||||
b[0].textContent=c.PENDING??0; b[1].textContent=c.RUNNING??0; b[2].textContent=c.DONE??0; b[3].textContent=c.DEAD??0;
|
||||
}catch(e){}
|
||||
}
|
||||
function poll(){ refreshHealth(); refreshQueue(); }
|
||||
poll(); setInterval(poll, 4000);
|
||||
$("#pingBtn").addEventListener("click",()=>{ refreshHealth(); refreshQueue(); toast("연결 확인 중…"); });
|
||||
|
||||
/* ---------- examples ---------- */
|
||||
const EXAMPLES = [
|
||||
{label:"맥심 커피", product_code:"T1", product_name:"맥심 모카골드 커피믹스", specification:"1박스, 160개입", company:"동서식품", model:"모카골드", price:"25000"},
|
||||
{label:"신라면", product_code:"T2", product_name:"농심 신라면", specification:"1박스 40개입", company:"농심", price:"22000"},
|
||||
{label:"P1 (샘플이력)", product_code:"P1", product_name:"테스트 상품 P1", specification:"", price:""},
|
||||
];
|
||||
const exWrap = $("#examples");
|
||||
EXAMPLES.forEach(ex=>{
|
||||
const c=document.createElement("button"); c.type="button"; c.className="chip"; c.textContent=ex.label;
|
||||
c.addEventListener("click",()=>{ for(const k in ex){ if(k==="label")continue; const f=$(`[name=${k}]`); if(f) f.value=ex[k]; } toast("예시 채움: "+ex.label); });
|
||||
exWrap.appendChild(c);
|
||||
});
|
||||
|
||||
/* ---------- submit search ---------- */
|
||||
let pollTimer=null;
|
||||
$("#searchForm").addEventListener("submit", async e=>{
|
||||
e.preventDefault();
|
||||
const fd=new FormData(e.target); const item={};
|
||||
fd.forEach((v,k)=> item[k]=String(v).trim());
|
||||
if(!item.product_code||!item.product_name){ toast("상품 코드와 상품명은 필수입니다","err"); return; }
|
||||
const btn=$("#submitBtn"); btn.disabled=true; btn.innerHTML='<span class="spin-inline"></span> 요청 중…';
|
||||
try{
|
||||
const d = await api("/v1/lps/search",{method:"POST",body:JSON.stringify({data:[item]})});
|
||||
const it = (d.items||[])[0]||{};
|
||||
if(it.duplicated){ toast("이미 처리 중인 활성 작업이라 중복 접수 생략됨","err"); }
|
||||
else if(it.job_id){ toast("접수 완료 · 추적 시작","ok"); $("#histCode").value=item.product_code; trackJob(it.job_id, item); }
|
||||
else { toast("접수됐지만 job_id가 없습니다","err"); }
|
||||
}catch(err){ toast(err.message,"err"); }
|
||||
finally{ btn.disabled=false; btn.textContent="검색 요청 보내기"; }
|
||||
});
|
||||
|
||||
/* ---------- job tracking (poll until DONE/DEAD) ---------- */
|
||||
function trackJob(jobId, submitted){
|
||||
if(pollTimer) clearInterval(pollTimer);
|
||||
$("#jobHint").textContent = "job "+jobId.slice(0,8)+"…";
|
||||
let tries=0;
|
||||
const tick = async ()=>{
|
||||
tries++;
|
||||
try{
|
||||
const d = await api("/v1/lps/jobs/"+jobId);
|
||||
renderJob(d, submitted);
|
||||
if(d.status==="DONE"||d.status==="DEAD"){
|
||||
clearInterval(pollTimer); pollTimer=null;
|
||||
if(d.status==="DONE" && d.output?.outcome==="found") loadHistory(submitted.product_code);
|
||||
}
|
||||
}catch(err){
|
||||
renderJobError(err.message);
|
||||
if(tries>3){ clearInterval(pollTimer); pollTimer=null; }
|
||||
}
|
||||
};
|
||||
tick(); pollTimer=setInterval(tick, 1500);
|
||||
}
|
||||
function renderJobError(msg){ $("#jobBody").innerHTML=`<div class="empty"><div class="big">⚠️</div>${esc(msg)}</div>`; }
|
||||
|
||||
function renderJob(d, submitted){
|
||||
const st=d.status||"PENDING";
|
||||
const o=d.output||null;
|
||||
const spinning=(st==="PENDING"||st==="RUNNING");
|
||||
let html = `<div class="job-meta">
|
||||
<span class="status-pill ${st}">${spinning?'<span class="spin"></span>':''}${st}</span>
|
||||
<span class="job-id">${esc(d.job_id||"")}</span>
|
||||
<span class="muted" style="font-size:12px">시도 ${d.attempts??0}/${d.max_attempts??"–"}</span>
|
||||
</div>`;
|
||||
|
||||
if(!o && spinning){
|
||||
html += `<div class="empty" style="padding:26px"><span class="spin-inline"></span> 워커가 네이버·쿠팡을 검색하고 AI가 같은 상품을 판정하는 중…
|
||||
<div class="muted" style="margin-top:8px;font-size:11.5px">쿠팡 크롤링은 수 초~수십 초 걸릴 수 있습니다.</div></div>`;
|
||||
$("#jobBody").innerHTML=html; return;
|
||||
}
|
||||
if(d.last_error && st==="DEAD"){
|
||||
html += `<div class="outcome-hero nf"><div><div class="outcome-label">작업 실패 (DEAD)</div><div class="muted" style="font-size:13px">${esc(d.last_error)}</div></div></div>`;
|
||||
}
|
||||
if(o){
|
||||
const found = o.outcome==="found";
|
||||
const low = o.lowest;
|
||||
// hero
|
||||
html += `<div class="outcome-hero ${found?'':'nf'}">
|
||||
<div style="flex:1">
|
||||
<div class="outcome-label">${found?'최저가 발견':(o.cached?'네거티브 캐시 (최근 없음)':'같은 상품 없음 · not_found')}</div>
|
||||
${ found && low ? `<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||
<div class="price-big">${won(low.price)}<span class="won">원</span></div>
|
||||
${srcTag(low)}
|
||||
${shipBadge(low)}
|
||||
</div>
|
||||
<div class="muted" style="font-size:11.5px;margin-top:5px">판매몰 <b style="color:var(--text-700)">${esc(mallLabel(low))}</b>${ isCatalog(low)?` <span title="여러 판매자 중 최저가 롤업 · 배송비 제외">· 여러 판매자 최저가</span>`:``}</div>
|
||||
${ totalPrice(low)!=null && low.shipping_fee>0 ? `<div class="muted" style="font-size:11.5px;margin-top:2px">실구매가(배송비 포함) <b style="color:var(--text-700)">${won(totalPrice(low))}원</b></div>`:``}
|
||||
<div class="muted" style="font-size:12px;margin-top:6px">${esc(low.name||"")}</div>`
|
||||
: `<div class="price-big muted" style="font-size:22px">—</div>` }
|
||||
</div>
|
||||
<div style="text-align:right">
|
||||
<div class="muted" style="font-size:11px">검색어</div>
|
||||
<div style="font-weight:600;font-size:13px">${esc(o.query||submitted?.product_name||"")}</div>
|
||||
<div class="muted" style="font-size:11px;margin-top:4px">${o.rounds_tried??0} 라운드${o.round?' · '+esc(o.round):''}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// source counts
|
||||
if(o.sources){
|
||||
html += `<div class="src-counts">`;
|
||||
for(const src of ["naver","coupang"]){
|
||||
const s=o.sources[src];
|
||||
html += `<div class="src-count"><div class="lbl ${src}">${src}</div>`;
|
||||
if(s && s.error!==undefined) html+=`<div class="err">차단/오류</div>`;
|
||||
else html += `<div class="n">${s?.count??0}<span class="muted" style="font-size:12px;font-weight:600"> 건</span></div>`;
|
||||
html+=`</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// pipeline stages
|
||||
if(o.stages && o.stages.length){
|
||||
html += `<div class="sec-title">파이프라인 단계 <span class="cnt">몇 건이 걸러졌나</span></div><div class="stages">`;
|
||||
o.stages.forEach(s=>{
|
||||
const drop=(s.in??0)-(s.out??0);
|
||||
html += `<div class="stage"><div class="st-name">${esc(s.stage)}</div>
|
||||
<div class="st-io">${s.in??0}<span class="arr">→</span>${s.out??0}</div>
|
||||
${drop>0?`<div class="st-drop">−${drop}</div>`:``}</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// 검색 원가 계측(리소스/비용/시간)
|
||||
const mt=o.metrics;
|
||||
if(mt){
|
||||
const fmtBytes=b=>{const kb=(b||0)/1024; return kb>=1024?(kb/1024).toFixed(1)+" MB":Math.round(kb)+" KB";};
|
||||
const c=mt.cost||{}; const total=c.total_usd??(mt.ai?.est_cost_usd||0);
|
||||
const fc=v=>"$"+(v||0).toFixed((v||0)<0.01?6:4);
|
||||
const srcMs=mt.source_ms||{};
|
||||
const srcTxt=Object.entries(srcMs).map(([s,ms])=>`${(SRC[s]||{}).label||s} ${(ms/1000).toFixed(1)}s`).join(" · ");
|
||||
const crawledTxt=(mt.crawl?.malls_crawled||[]).length?' · 크롤 '+mt.crawl.malls_crawled.map(s=>(SRC[s]||{}).label||s).join(','):'';
|
||||
html += `<div class="sec-title">검색 원가 <span class="cnt">이 검색 1건이 쓴 리소스·비용·시간</span></div>
|
||||
<div class="metrics">
|
||||
<div class="metric"><div class="mk">소요 시간</div><div class="mv">${(mt.duration_ms/1000).toFixed(1)}<small>s</small></div>${srcTxt?`<div class="msub" title="${esc(srcTxt)}">${esc(srcTxt.length>34?srcTxt.slice(0,34)+'…':srcTxt)}</div>`:``}</div>
|
||||
<div class="metric"><div class="mk">총 비용</div><div class="mv">${fc(total)}</div><div class="msub" title="AI(OpenAI) + DECODO(프록시 대역폭)">AI ${fc(c.ai_usd)} · DECODO ${fc(c.proxy_usd)}</div></div>
|
||||
<div class="metric"><div class="mk">AI 토큰</div><div class="mv">${won((mt.ai?.prompt_tokens||0)+(mt.ai?.completion_tokens||0))}</div><div class="msub">${mt.ai?.calls||0}회 · in ${won(mt.ai?.prompt_tokens||0)}/out ${won(mt.ai?.completion_tokens||0)}</div></div>
|
||||
<div class="metric"><div class="mk">크롤 트래픽</div><div class="mv">${fmtBytes(mt.crawl?.html_bytes)}</div><div class="msub" title="프록시 경유 ${fmtBytes(mt.crawl?.proxy_bytes)} (DECODO 과금분)">${mt.crawl?.fetches||0} fetch · 프록시 ${fmtBytes(mt.crawl?.proxy_bytes)}${crawledTxt}</div></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 몰별 최저가 분해 (네이버 payload에 이미 들어온 G마켓/옥션/11번가 등을 추가요청 0으로 노출)
|
||||
const byMall=o.by_mall||[];
|
||||
if(byMall.length>1){
|
||||
const maxP=Math.max(...byMall.map(m=>m.price));
|
||||
html += `<div class="sec-title">몰별 최저가 <span class="cnt">네이버 포함 몰 + 미커버 몰은 직접 크롤(크롤 뱃지)</span></div><div class="mall-bars">`;
|
||||
byMall.forEach((m,i)=>{
|
||||
const w=Math.max(8,Math.round(m.price/maxP*100));
|
||||
const color=srcColor(m.source);
|
||||
html += `<div class="mall-bar ${i===0?'best':''}">
|
||||
<div class="mname"><span class="sdot" style="background:${color}"></span>${esc(mallLabel(m))}</div>
|
||||
<div class="track"><div class="fill" style="width:${w}%;background:${color}"></div>
|
||||
${i===0?`<div class="badge-best">최저</div>`:``}
|
||||
<div class="mprice">${won(m.price)}원</div></div>
|
||||
</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// top-N product list
|
||||
const top=o.top||[];
|
||||
if(top.length){
|
||||
html += `<div class="sec-title">같은 상품 최저가 <span class="cnt">상위 ${top.length}개 · 가격은 상품가 기준</span></div>
|
||||
<table><thead><tr><th>#</th><th>소스 · 판매몰</th><th>상품명</th><th>배송</th><th style="text-align:right">상품가</th></tr></thead><tbody>`;
|
||||
top.forEach((p,i)=>{
|
||||
const tot=totalPrice(p);
|
||||
html += `<tr>
|
||||
<td><span class="rank ${i===0?'top':''}">${i+1}</span></td>
|
||||
<td style="white-space:nowrap">${srcTag(p)}<div class="muted" style="font-size:11px;font-weight:600;margin-top:3px">${esc(mallLabel(p))}</div></td>
|
||||
<td class="name-cell">${p.detail_url?`<a href="${esc(p.detail_url)}" target="_blank" rel="noopener">${esc(p.name)}</a>`:esc(p.name)}</td>
|
||||
<td>${shipBadge(p)}</td>
|
||||
<td class="price-cell" style="text-align:right">${won(p.price)}원${ tot!=null&&p.shipping_fee>0?`<div class="muted" style="font-size:10.5px;font-weight:600">+배송 ${won(tot)}</div>`:``}</td>
|
||||
</tr>`;
|
||||
});
|
||||
html += `</tbody></table>`;
|
||||
}
|
||||
}
|
||||
$("#jobBody").innerHTML=html;
|
||||
}
|
||||
|
||||
/* ---------- price history chart (dependency-free SVG) ---------- */
|
||||
const SERIES=[
|
||||
{key:"naver", label:"네이버", color:"var(--naver)", raw:"#03c75a", dash:false},
|
||||
{key:"coupang", label:"쿠팡", color:"var(--coupang)", raw:"#f0455b", dash:false},
|
||||
{key:"final", label:"최종최저", color:"var(--final)", raw:"#4f46e5", dash:true},
|
||||
];
|
||||
const hidden=new Set();
|
||||
let lastPoints=[];
|
||||
|
||||
$("#loadHistBtn").addEventListener("click",()=> loadHistory($("#histCode").value.trim()));
|
||||
$("#histCode").addEventListener("keydown",e=>{ if(e.key==="Enter") loadHistory(e.target.value.trim()); });
|
||||
|
||||
async function loadHistory(code){
|
||||
if(!code){ toast("상품 코드를 입력하세요","err"); return; }
|
||||
$("#histCode").value=code;
|
||||
$("#chartArea").innerHTML=`<div class="empty" style="padding:30px"><span class="spin-inline"></span> 이력 불러오는 중…</div>`;
|
||||
try{
|
||||
const d = await api(`/v1/lps/products/${encodeURIComponent(code)}/history?limit=200`);
|
||||
lastPoints = d.points||[];
|
||||
renderChart(lastPoints, code);
|
||||
}catch(err){ $("#chartArea").innerHTML=`<div class="empty"><div class="big">⚠️</div>${esc(err.message)}</div>`; }
|
||||
}
|
||||
|
||||
function renderChart(points, code){
|
||||
if(!points.length){ $("#chartArea").innerHTML=`<div class="empty"><div class="big">🗒️</div><b>${esc(code)}</b> 상품의 이력이 아직 없습니다.<div class="muted" style="margin-top:6px;font-size:12px">검색이 완료되면 스냅샷이 쌓입니다.</div></div>`; return; }
|
||||
const W=760,H=300,padL=62,padR=18,padT=18,padB=46;
|
||||
const iw=W-padL-padR, ih=H-padT-padB;
|
||||
const vals=[]; points.forEach(p=>SERIES.forEach(s=>{ if(!hidden.has(s.key)&&p[s.key]!=null) vals.push(p[s.key]); }));
|
||||
let min = vals.length?Math.min(...vals):0, max=vals.length?Math.max(...vals):1;
|
||||
if(min===max){ min=min*0.9; max=max*1.1||1; }
|
||||
const pad=(max-min)*0.12; min=Math.max(0,min-pad); max=max+pad;
|
||||
const n=points.length;
|
||||
const X = i => padL + (n===1? iw/2 : iw*i/(n-1));
|
||||
const Y = v => padT + ih*(1-(v-min)/(max-min));
|
||||
|
||||
// y ticks
|
||||
const ticks=5; let yAxis="";
|
||||
for(let t=0;t<=ticks;t++){ const v=min+(max-min)*t/ticks; const y=Y(v);
|
||||
yAxis+=`<line x1="${padL}" y1="${y}" x2="${W-padR}" y2="${y}" stroke="var(--border-2)" stroke-width="1"/>`;
|
||||
yAxis+=`<text x="${padL-8}" y="${y+4}" text-anchor="end" font-size="10" fill="var(--text-400)">${won(Math.round(v))}</text>`;
|
||||
}
|
||||
// x labels (sparse)
|
||||
let xAxis=""; const step=Math.ceil(n/6);
|
||||
points.forEach((p,i)=>{ if(i%step!==0 && i!==n-1) return; const x=X(i);
|
||||
const t=(p.triggered_at||"").replace("T"," ").slice(5,16);
|
||||
xAxis+=`<text x="${x}" y="${H-padB+18}" text-anchor="middle" font-size="9.5" fill="var(--text-400)">${esc(t)}</text>`;
|
||||
xAxis+=`<line x1="${x}" y1="${padT}" x2="${x}" y2="${padT+ih}" stroke="var(--border-2)" stroke-width="1" stroke-dasharray="2 3" opacity=".5"/>`;
|
||||
});
|
||||
// lines + dots
|
||||
let paths="",dots="";
|
||||
SERIES.forEach(s=>{
|
||||
if(hidden.has(s.key)) return;
|
||||
// build segments skipping nulls
|
||||
let seg=[];
|
||||
const flush=()=>{ if(seg.length>1){ paths+=`<polyline points="${seg.map(pt=>pt.x+","+pt.y).join(" ")}" fill="none" stroke="${s.raw}" stroke-width="${s.dash?2.5:2}" ${s.dash?'stroke-dasharray="6 4"':''} stroke-linejoin="round" stroke-linecap="round"/>`; } seg=[]; };
|
||||
points.forEach((p,i)=>{ const v=p[s.key]; if(v==null){ flush(); return; } const x=X(i),y=Y(v); seg.push({x,y});
|
||||
dots+=`<circle cx="${x}" cy="${y}" r="3" fill="#fff" stroke="${s.raw}" stroke-width="2"/>`; });
|
||||
flush();
|
||||
});
|
||||
// hover columns
|
||||
let hover="";
|
||||
points.forEach((p,i)=>{ const x=X(i);
|
||||
hover+=`<rect x="${x-(iw/n/2||14)}" y="${padT}" width="${iw/n||28}" height="${ih}" fill="transparent" data-i="${i}" class="hovcol"/>`;
|
||||
hover+=`<line class="hovline" data-i="${i}" x1="${x}" y1="${padT}" x2="${x}" y2="${padT+ih}" stroke="var(--primary-500)" stroke-width="1" opacity="0"/>`;
|
||||
});
|
||||
|
||||
$("#chartArea").innerHTML=`
|
||||
<div class="chart-wrap">
|
||||
<svg class="chart" viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet">
|
||||
${yAxis}${xAxis}${paths}${dots}${hover}
|
||||
</svg>
|
||||
<div class="chart-tip" id="chartTip"></div>
|
||||
</div>
|
||||
<div class="legend" id="legend"></div>
|
||||
<p class="muted" style="font-size:11.5px;margin:12px 0 0">스냅샷 ${n}건 · 점선(최종최저)이 네이버·쿠팡 중 낮은 값을 따라갑니다. 값이 빈 구간은 그 소스에 상품이 없던 시점입니다.</p>
|
||||
`;
|
||||
// legend
|
||||
const lg=$("#legend");
|
||||
SERIES.forEach(s=>{
|
||||
const el=document.createElement("div"); el.className="legend-item"+(hidden.has(s.key)?" off":"");
|
||||
el.innerHTML=`<span class="swatch ${s.dash?'dashed':''}" style="${s.dash?`border-color:${s.raw}`:`background:${s.raw}`}"></span>${s.label}`;
|
||||
el.addEventListener("click",()=>{ if(hidden.has(s.key))hidden.delete(s.key); else hidden.add(s.key); renderChart(lastPoints,code); });
|
||||
lg.appendChild(el);
|
||||
});
|
||||
// hover interaction
|
||||
const tip=$("#chartTip"), svg=$(".chart");
|
||||
$$(".hovcol").forEach(col=>{
|
||||
col.style.cursor="crosshair";
|
||||
col.addEventListener("mouseenter",()=>{
|
||||
const i=+col.dataset.i, p=points[i];
|
||||
$$(".hovline").forEach(l=>l.setAttribute("opacity", (+l.dataset.i===i)?".5":"0"));
|
||||
let rows="";
|
||||
SERIES.forEach(s=>{ if(hidden.has(s.key)) return; const v=p[s.key];
|
||||
rows+=`<div class="tt-row"><span class="k"><span class="tt-dot" style="background:${s.raw}"></span>${s.label}</span><span>${v==null?'—':won(v)+'원'}</span></div>`; });
|
||||
tip.innerHTML=`<div class="tt-time">${esc((p.triggered_at||'').replace('T',' ').slice(0,19))} · ${esc(p.outcome||'')}</div>${rows}`;
|
||||
// position
|
||||
const bb=svg.getBoundingClientRect(); const scale=bb.width/W; const px=(X(i))*scale; const py=padT*scale+8;
|
||||
tip.style.left=px+"px"; tip.style.top=py+"px"; tip.style.opacity="1";
|
||||
});
|
||||
col.addEventListener("mouseleave",()=>{ tip.style.opacity="0"; $$(".hovline").forEach(l=>l.setAttribute("opacity","0")); });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>LPS 관리자 — 인터넷 최저가 검색</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
2795
lps-temp-fe/package-lock.json
generated
Normal file
2795
lps-temp-fe/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
lps-temp-fe/package.json
Normal file
27
lps-temp-fe/package.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "lps-admin-fe",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router": "^7.1.0",
|
||||
"recharts": "^2.15.0",
|
||||
"tailwindcss": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "~5.6.3",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
@ -1,27 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# LPS 임시 프론트엔드 서버 (대화형).
|
||||
# CORS 때문에 반드시 :5173 에서 서빙해야 lps API(:9600) 호출이 통과한다.
|
||||
# (lps/config.local.toml 의 cors_origins = http://localhost:5173)
|
||||
#
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
PORT=5173
|
||||
echo "── LPS 임시 FE 서버 ──"
|
||||
echo " API(:9600) 는 별도로 실행돼 있어야 합니다 (lps/run_local_server.sh + worker_main.py)."
|
||||
echo ""
|
||||
read -rp "포트 [${PORT}]: " p
|
||||
PORT="${p:-$PORT}"
|
||||
|
||||
# 포트 정리
|
||||
if lsof -ti:"$PORT" >/dev/null 2>&1; then
|
||||
echo "[info] 포트 $PORT 사용 중 → 기존 프로세스 종료"
|
||||
lsof -ti:"$PORT" | xargs kill 2>/dev/null || true
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
echo "[run] http://localhost:${PORT} (Ctrl+C 로 종료)"
|
||||
# 브라우저 자동 오픈 (mac)
|
||||
( sleep 1; command -v open >/dev/null && open "http://localhost:${PORT}" ) &
|
||||
exec python3 -m http.server "$PORT" --bind 127.0.0.1
|
||||
46
lps-temp-fe/src/api/client.ts
Normal file
46
lps-temp-fe/src/api/client.ts
Normal file
@ -0,0 +1,46 @@
|
||||
/** fetch 래퍼 — base URL·X-API-Key(설정 페이지, localStorage) 자동 적용 + 봉투(result) 검사. */
|
||||
|
||||
const BASE_KEY = "lps.baseUrl";
|
||||
const API_KEY = "lps.apiKey";
|
||||
|
||||
export const settings = {
|
||||
baseUrl: () => localStorage.getItem(BASE_KEY) ?? "",
|
||||
apiKey: () => localStorage.getItem(API_KEY) ?? "",
|
||||
save(baseUrl: string, apiKey: string) {
|
||||
baseUrl ? localStorage.setItem(BASE_KEY, baseUrl.replace(/\/$/, "")) : localStorage.removeItem(BASE_KEY);
|
||||
apiKey ? localStorage.setItem(API_KEY, apiKey) : localStorage.removeItem(API_KEY);
|
||||
},
|
||||
};
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers: Record<string, string> = { ...((init?.headers as Record<string, string>) ?? {}) };
|
||||
const key = settings.apiKey();
|
||||
if (key) headers["X-API-Key"] = key;
|
||||
const res = await fetch(settings.baseUrl() + path, { ...init, headers });
|
||||
if (res.status === 401) throw new Error("인증 실패(401) — 설정에서 API 키를 확인하세요");
|
||||
if (!res.ok) throw new Error(`서버 오류 (HTTP ${res.status})`);
|
||||
const body = (await res.json()) as T & { result?: { success: boolean; desc?: string } };
|
||||
if (body?.result && body.result.success === false) {
|
||||
throw new Error(body.result.desc || "요청이 거절됐습니다");
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export const get = <T>(path: string) => request<T>(path);
|
||||
|
||||
export const post = <T>(path: string, payload?: unknown) =>
|
||||
request<T>(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: payload === undefined ? undefined : JSON.stringify(payload),
|
||||
});
|
||||
|
||||
/** healthz 는 JSON 문자열("시각")을 반환 — 도달 여부만 본다. */
|
||||
export async function healthz(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(settings.baseUrl() + "/healthz");
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
168
lps-temp-fe/src/api/types.ts
Normal file
168
lps-temp-fe/src/api/types.ts
Normal file
@ -0,0 +1,168 @@
|
||||
/** LPS API 응답 타입 — 백엔드 protocol/admin_protocol 미러링. */
|
||||
|
||||
export interface ResultEnvelope {
|
||||
success: boolean;
|
||||
code: number;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export interface OpsSnapshot {
|
||||
pending: number;
|
||||
running: number;
|
||||
done: number;
|
||||
dead: number;
|
||||
dead_1h: number;
|
||||
stuck_running: number;
|
||||
oldest_pending_sec: number;
|
||||
blocks_1h: number;
|
||||
deadline_1h: number;
|
||||
cost_1h_usd: number;
|
||||
pool_checked_out: number;
|
||||
pool_capacity: number;
|
||||
pool_pct: number;
|
||||
}
|
||||
|
||||
export type JobStatusName = "PENDING" | "RUNNING" | "DONE" | "DEAD";
|
||||
|
||||
export interface JobListItem {
|
||||
job_id: string;
|
||||
job_type: number;
|
||||
status: JobStatusName;
|
||||
priority: number;
|
||||
attempts: number;
|
||||
max_attempts: number;
|
||||
product_code?: string;
|
||||
product_name?: string;
|
||||
outcome?: string;
|
||||
final_lowest?: number;
|
||||
cost_usd?: number;
|
||||
last_error?: string;
|
||||
created_at: string;
|
||||
run_started_at?: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface JobListRes {
|
||||
result: ResultEnvelope;
|
||||
items: JobListItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface JobDetailRes {
|
||||
result: ResultEnvelope;
|
||||
job_id?: string;
|
||||
status?: JobStatusName;
|
||||
attempts?: number;
|
||||
max_attempts?: number;
|
||||
output?: Record<string, unknown>;
|
||||
last_error?: string;
|
||||
}
|
||||
|
||||
export interface RequeueRes {
|
||||
result: ResultEnvelope;
|
||||
job_id?: string;
|
||||
requeued: boolean;
|
||||
}
|
||||
|
||||
export interface ProductItem {
|
||||
product_code: string;
|
||||
display_name?: string;
|
||||
triggered_at: string;
|
||||
outcome: string;
|
||||
naver_lowest?: number;
|
||||
coupang_lowest?: number;
|
||||
final_lowest?: number;
|
||||
final_source?: string;
|
||||
searches: number;
|
||||
}
|
||||
|
||||
export interface ProductListRes {
|
||||
result: ResultEnvelope;
|
||||
items: ProductItem[];
|
||||
}
|
||||
|
||||
export interface MallEntry {
|
||||
mall?: string;
|
||||
source?: string;
|
||||
price?: number;
|
||||
shipping_fee?: number | null;
|
||||
shipping_type?: string | null;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface PricePoint {
|
||||
triggered_at: string;
|
||||
outcome: string;
|
||||
matched_count?: number;
|
||||
naver?: number;
|
||||
coupang?: number;
|
||||
final?: number;
|
||||
final_source?: string;
|
||||
naver_name?: string;
|
||||
naver_url?: string;
|
||||
coupang_name?: string;
|
||||
coupang_url?: string;
|
||||
by_mall?: MallEntry[];
|
||||
}
|
||||
|
||||
export interface PriceHistoryRes {
|
||||
result: ResultEnvelope;
|
||||
product_code?: string;
|
||||
points: PricePoint[];
|
||||
}
|
||||
|
||||
export interface SearchRes {
|
||||
result: ResultEnvelope;
|
||||
accepted: number;
|
||||
items: { product_code: string; job_id?: string; duplicated: boolean }[];
|
||||
}
|
||||
|
||||
export interface IpSessionRow {
|
||||
source: string;
|
||||
proxy_port?: number;
|
||||
requests: number;
|
||||
ok_count: number;
|
||||
blocked_count: number;
|
||||
elapsed_sec?: number;
|
||||
end_reason: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface IpSessionStatsRes {
|
||||
result: ResultEnvelope;
|
||||
by_reason: Record<string, number>;
|
||||
histogram: { requests: number; count: number }[];
|
||||
block_min_requests?: number | null;
|
||||
sessions: IpSessionRow[];
|
||||
}
|
||||
|
||||
export interface BotRow {
|
||||
source: string;
|
||||
query?: string;
|
||||
ip_request_no?: number;
|
||||
proxy_port?: number;
|
||||
elapsed_sec?: number;
|
||||
marker?: string;
|
||||
html_len?: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface BotStatsRes {
|
||||
result: ResultEnvelope;
|
||||
hourly: { bucket: string; count: number }[];
|
||||
items: BotRow[];
|
||||
}
|
||||
|
||||
export interface CostBucket {
|
||||
bucket: string;
|
||||
jobs: number;
|
||||
ai_usd: number;
|
||||
proxy_usd: number;
|
||||
total_usd: number;
|
||||
avg_ms: number;
|
||||
}
|
||||
|
||||
export interface CostStatsRes {
|
||||
result: ResultEnvelope;
|
||||
buckets: CostBucket[];
|
||||
}
|
||||
61
lps-temp-fe/src/components/Layout.tsx
Normal file
61
lps-temp-fe/src/components/Layout.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
/** 앱 레이아웃 — 좌측 네비 + 상단 헬스 배지. */
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { NavLink, Outlet } from "react-router";
|
||||
import { healthz } from "../api/client";
|
||||
|
||||
const MENU = [
|
||||
{ to: "/", label: "대시보드", end: true },
|
||||
{ to: "/jobs", label: "작업 큐" },
|
||||
{ to: "/products", label: "상품·가격" },
|
||||
{ to: "/crawl", label: "크롤 상태" },
|
||||
{ to: "/costs", label: "비용" },
|
||||
{ to: "/settings", label: "설정" },
|
||||
];
|
||||
|
||||
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 max-w-[1280px]">
|
||||
<aside className="w-[196px] shrink-0 border-r border-line-200 px-3 py-5">
|
||||
<div className="mb-6 flex items-center gap-2.5 px-2">
|
||||
<span className="grid h-8 w-8 place-items-center rounded-lg bg-gradient-to-br from-primary-600 to-[#8b7bff] text-[14px] font-extrabold text-white">L</span>
|
||||
<div>
|
||||
<div className="text-[14px] font-bold leading-tight">LPS 관리자</div>
|
||||
<div className="text-[10.5px] font-medium text-ink-400">인터넷 최저가 검색</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav aria-label="주 메뉴">
|
||||
<ul className="space-y-0.5">
|
||||
{MENU.map((m) => (
|
||||
<li key={m.to}>
|
||||
<NavLink
|
||||
to={m.to}
|
||||
end={m.end}
|
||||
className={({ isActive }) =>
|
||||
`block rounded-lg px-3 py-2 text-[13px] font-semibold transition-colors ${
|
||||
isActive ? "bg-primary-50 text-primary-700" : "text-ink-700 hover:bg-surface-2"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{m.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
<div className="mt-8 flex items-center gap-2 px-3 text-[11.5px] font-semibold text-ink-500">
|
||||
<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-6 py-5">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
lps-temp-fe/src/components/ui.tsx
Normal file
73
lps-temp-fe/src/components/ui.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
/** 공용 UI 조각 — 카드·스탯 타일·상태 배지·빈/오류 표시. 웹 표준 시맨틱 마크업 유지. */
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { JobStatusName } from "../api/types";
|
||||
|
||||
export function Card({ title, hint, children, className = "" }: {
|
||||
title?: ReactNode; hint?: ReactNode; children: ReactNode; className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className={`rounded-xl border border-line-200 bg-surface shadow-[0_1px_2px_rgba(16,24,40,.04)] ${className}`}>
|
||||
{title !== undefined && (
|
||||
<header className="flex items-center gap-2 border-b border-line-100 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>}
|
||||
</header>
|
||||
)}
|
||||
<div className="p-4">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatTile({ label, value, sub, tone = "default" }: {
|
||||
label: string; value: ReactNode; sub?: ReactNode;
|
||||
tone?: "default" | "ok" | "warn" | "run" | "dead";
|
||||
}) {
|
||||
const toneCls = {
|
||||
default: "text-ink-900",
|
||||
ok: "text-ok-600",
|
||||
warn: "text-warn-600",
|
||||
run: "text-run-600",
|
||||
dead: "text-dead-600",
|
||||
}[tone];
|
||||
return (
|
||||
<div className="rounded-xl border border-line-200 bg-surface px-4 py-3">
|
||||
<div className="text-[11.5px] font-semibold text-ink-500">{label}</div>
|
||||
<div className={`mt-0.5 text-[22px] font-bold leading-tight ${toneCls}`}>{value}</div>
|
||||
{sub && <div className="mt-0.5 text-[11px] text-ink-400">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_STYLE: Record<JobStatusName, { dot: string; bg: string; text: string; label: string }> = {
|
||||
PENDING: { dot: "bg-warn-600", bg: "bg-warn-50", text: "text-warn-700", label: "대기" },
|
||||
RUNNING: { dot: "bg-run-600", bg: "bg-run-50", text: "text-run-600", label: "처리중" },
|
||||
DONE: { dot: "bg-ok-600", bg: "bg-ok-50", text: "text-ok-600", label: "완료" },
|
||||
DEAD: { dot: "bg-dead-600", bg: "bg-dead-50", text: "text-dead-600", label: "실패" },
|
||||
};
|
||||
|
||||
export function StatusBadge({ status }: { status: JobStatusName }) {
|
||||
const s = STATUS_STYLE[status];
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-semibold ${s.bg} ${s.text}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${s.dot}`} aria-hidden />
|
||||
{s.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Empty({ children }: { children: ReactNode }) {
|
||||
return <p className="py-8 text-center text-[12.5px] text-ink-400">{children}</p>;
|
||||
}
|
||||
|
||||
export function ErrorNote({ error }: { error: unknown }) {
|
||||
return (
|
||||
<p role="alert" className="rounded-lg bg-dead-50 px-3 py-2 text-[12.5px] font-medium text-dead-600">
|
||||
{error instanceof Error ? error.message : "요청에 실패했습니다"}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function Loading() {
|
||||
return <p className="py-8 text-center text-[12.5px] text-ink-400" aria-busy="true">불러오는 중…</p>;
|
||||
}
|
||||
58
lps-temp-fe/src/index.css
Normal file
58
lps-temp-fe/src/index.css
Normal file
@ -0,0 +1,58 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* 색 토큰 — 숫자 스케일(팀 관례) + dataviz 차트 크롬. 시리즈 색은 엔터티 고정:
|
||||
네이버 #008a43(CVD 검증 통과 톤다운) · 쿠팡 #f0455b · 최종 #4f46e5. */
|
||||
@theme {
|
||||
--color-page: #f7f7f5;
|
||||
--color-surface: #fcfcfb;
|
||||
--color-surface-2: #f4f4f1;
|
||||
--color-line-200: #e6e5df;
|
||||
--color-line-100: #efeee9;
|
||||
--color-grid: #e1e0d9;
|
||||
|
||||
--color-ink-900: #171a20;
|
||||
--color-ink-700: #3d434f;
|
||||
--color-ink-500: #6b7280;
|
||||
--color-ink-400: #9aa1ac;
|
||||
|
||||
--color-primary-700: #4338ca;
|
||||
--color-primary-600: #4f46e5;
|
||||
--color-primary-100: #e5e4fb;
|
||||
--color-primary-50: #eef0ff;
|
||||
|
||||
/* 상태(큐·알림) — status 팔레트, 시리즈로 재사용 금지 */
|
||||
--color-ok-600: #0f9d58;
|
||||
--color-ok-50: #e7f6ee;
|
||||
--color-warn-700: #a36a00;
|
||||
--color-warn-600: #c77800;
|
||||
--color-warn-50: #fdf3e3;
|
||||
--color-run-600: #2563eb;
|
||||
--color-run-50: #e8f0ff;
|
||||
--color-dead-600: #d03b3b;
|
||||
--color-dead-50: #fdeaea;
|
||||
|
||||
/* 차트 시리즈(엔터티 고정) */
|
||||
--color-naver: #008a43;
|
||||
--color-coupang: #f0455b;
|
||||
--color-final: #4f46e5;
|
||||
--color-cost-ai: #2a78d6;
|
||||
--color-cost-proxy: #eda100;
|
||||
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Apple SD Gothic Neo", "Noto Sans KR", sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-page text-ink-900 font-sans text-[13.5px] antialiased;
|
||||
}
|
||||
|
||||
/* 테이블 숫자 열 정렬 */
|
||||
.tnum {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* recharts 기본 폰트 */
|
||||
.recharts-wrapper,
|
||||
.recharts-tooltip-wrapper {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
29
lps-temp-fe/src/lib/format.ts
Normal file
29
lps-temp-fe/src/lib/format.ts
Normal file
@ -0,0 +1,29 @@
|
||||
/** 표시 포맷 유틸 — 원화·달러·상대시각·소요시간. */
|
||||
|
||||
export const won = (v?: number | null) =>
|
||||
v === null || v === undefined ? "—" : `${v.toLocaleString("ko-KR")}원`;
|
||||
|
||||
export const usd = (v?: number | null, digits = 3) =>
|
||||
v === null || v === undefined ? "—" : `$${v.toFixed(digits)}`;
|
||||
|
||||
export function timeAgo(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
const sec = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (sec < 60) return `${Math.floor(sec)}초 전`;
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)}분 전`;
|
||||
if (sec < 86400) return `${Math.floor(sec / 3600)}시간 전`;
|
||||
return `${Math.floor(sec / 86400)}일 전`;
|
||||
}
|
||||
|
||||
export const hhmm = (iso: string) =>
|
||||
new Date(iso).toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
|
||||
export const dateShort = (iso: string) =>
|
||||
new Date(iso).toLocaleString("ko-KR", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
|
||||
export function durationSec(sec?: number | null): string {
|
||||
if (sec === null || sec === undefined) return "—";
|
||||
if (sec < 60) return `${sec}초`;
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)}분 ${sec % 60}초`;
|
||||
return `${Math.floor(sec / 3600)}시간 ${Math.floor((sec % 3600) / 60)}분`;
|
||||
}
|
||||
39
lps-temp-fe/src/main.tsx
Normal file
39
lps-temp-fe/src/main.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router";
|
||||
|
||||
import Layout from "./components/Layout";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import Jobs from "./pages/Jobs";
|
||||
import Products from "./pages/Products";
|
||||
import Crawl from "./pages/Crawl";
|
||||
import Costs from "./pages/Costs";
|
||||
import Settings from "./pages/Settings";
|
||||
import "./index.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
|
||||
});
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
element: <Layout />,
|
||||
children: [
|
||||
{ path: "/", element: <Dashboard /> },
|
||||
{ path: "/jobs", element: <Jobs /> },
|
||||
{ path: "/products", element: <Products /> },
|
||||
{ path: "/crawl", element: <Crawl /> },
|
||||
{ path: "/costs", element: <Costs /> },
|
||||
{ path: "/settings", element: <Settings /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
101
lps-temp-fe/src/pages/Costs.tsx
Normal file
101
lps-temp-fe/src/pages/Costs.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
/** 비용 — 시간별 검색원가 스택(AI vs 프록시) + 평균 소요 시간(별도 축이므로 별도 차트). */
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Bar, BarChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
|
||||
} from "recharts";
|
||||
import { get } from "../api/client";
|
||||
import type { CostStatsRes } from "../api/types";
|
||||
import { Card, Empty, ErrorNote, Loading, StatTile } from "../components/ui";
|
||||
import { dateShort, usd } from "../lib/format";
|
||||
|
||||
const RANGES = [
|
||||
{ h: 24, label: "24시간" },
|
||||
{ h: 48, label: "48시간" },
|
||||
{ h: 168, label: "7일" },
|
||||
];
|
||||
|
||||
export default function Costs() {
|
||||
const [hours, setHours] = useState(48);
|
||||
const cost = useQuery({
|
||||
queryKey: ["cost", hours],
|
||||
queryFn: () => get<CostStatsRes>(`/v1/lps/stats/cost?hours=${hours}`),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const buckets = (cost.data?.buckets ?? []).map((b) => ({ ...b, x: dateShort(b.bucket) }));
|
||||
const totals = buckets.reduce(
|
||||
(acc, b) => ({ jobs: acc.jobs + b.jobs, ai: acc.ai + b.ai_usd, proxy: acc.proxy + b.proxy_usd, total: acc.total + b.total_usd }),
|
||||
{ jobs: 0, ai: 0, proxy: 0, total: 0 },
|
||||
);
|
||||
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>
|
||||
|
||||
{cost.isError && <ErrorNote error={cost.error} />}
|
||||
|
||||
<div className="grid 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 프록시 대역폭">
|
||||
{cost.isPending && <Loading />}
|
||||
{cost.data && buckets.length === 0 && <Empty>기간 내 완료된 검색이 없습니다</Empty>}
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="검색 1건 평균 소요 시간" hint="시간별 · 상승하면 경합/차단 신호">
|
||||
{buckets.length === 0 ? (
|
||||
<Empty>데이터 없음</Empty>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
230
lps-temp-fe/src/pages/Crawl.tsx
Normal file
230
lps-temp-fe/src/pages/Crawl.tsx
Normal file
@ -0,0 +1,230 @@
|
||||
/** 크롤 상태 — IP 세션 종료 사유·요청 수 분포(예산 튜닝 뷰)·차단 이력. */
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis,
|
||||
} from "recharts";
|
||||
import { get } from "../api/client";
|
||||
import type { BotStatsRes, IpSessionStatsRes } from "../api/types";
|
||||
import { Card, Empty, ErrorNote, Loading } from "../components/ui";
|
||||
import { dateShort, durationSec, hhmm } from "../lib/format";
|
||||
|
||||
// 종료 사유 — 의미 기반 상태색(정상 회전=초록 계열, 차단=적색, 포트사망=주황, 중립=회색/파랑).
|
||||
const REASONS: Record<string, { label: string; color: string; desc: string }> = {
|
||||
budget: { label: "예산 선제 회전", color: "#0ca30c", desc: "정상 — 차단 전에 IP 교체(평판 보존)" },
|
||||
window: { label: "시간창 만료", color: "#2a78d6", desc: "정상 — sticky 10분 주기 교체" },
|
||||
idle: { label: "유휴 정리", color: "#898781", desc: "정상 — 한동안 검색 없어 브라우저 회수" },
|
||||
shutdown: { label: "종료", color: "#c3c2b7", desc: "정상 — 워커 재시작/종료" },
|
||||
rotate: { label: "기타 회전", color: "#9aa1ac", desc: "웜업 재시도 등" },
|
||||
proxy_error: { label: "포트 사망", color: "#ec835a", desc: "주의 — 프록시 전송 실패로 교체" },
|
||||
block: { label: "차단됨", color: "#d03b3b", desc: "위험 — 예산 안에서도 차단(예산 하향 검토)" },
|
||||
};
|
||||
|
||||
const RANGES = [
|
||||
{ h: 24, label: "24시간" },
|
||||
{ h: 168, label: "7일" },
|
||||
{ h: 720, label: "30일" },
|
||||
];
|
||||
|
||||
export default function Crawl() {
|
||||
const [hours, setHours] = useState(168);
|
||||
const ip = useQuery({
|
||||
queryKey: ["ip-sessions", hours],
|
||||
queryFn: () => get<IpSessionStatsRes>(`/v1/lps/stats/ip-sessions?hours=${hours}`),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
const bot = useQuery({
|
||||
queryKey: ["bot", hours],
|
||||
queryFn: () => get<BotStatsRes>(`/v1/lps/stats/bot?hours=${hours}`),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const reasons = Object.entries(ip.data?.by_reason ?? {})
|
||||
.map(([k, v]) => ({ key: k, ...(REASONS[k] ?? { label: k, color: "#9aa1ac", desc: "" }), value: v }))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
const totalSessions = reasons.reduce((s, r) => s + r.value, 0);
|
||||
const blockCount = ip.data?.by_reason?.block ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-[17px] font-bold tracking-tight">크롤 상태</h1>
|
||||
<div role="tablist" aria-label="기간" className="ml-auto flex rounded-lg border border-line-200 bg-surface p-0.5">
|
||||
{RANGES.map((r) => (
|
||||
<button key={r.h} role="tab" aria-selected={hours === r.h} onClick={() => setHours(r.h)}
|
||||
className={`rounded-md px-3 py-1.5 text-[12px] font-semibold ${hours === r.h ? "bg-primary-50 text-primary-700" : "text-ink-500 hover:text-ink-700"}`}>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ip.isError && <ErrorNote error={ip.error} />}
|
||||
|
||||
{ip.data && (
|
||||
blockCount === 0 ? (
|
||||
<p className="rounded-lg bg-ok-50 px-3 py-2 text-[12.5px] font-semibold text-ok-600">
|
||||
✓ 기간 내 차단된 IP 세션 0건 — 요청 예산(선제 회전)이 잘 작동하고 있습니다
|
||||
</p>
|
||||
) : (
|
||||
<p role="alert" className="rounded-lg border border-dead-600/20 bg-dead-50 px-3 py-2 text-[12.5px] font-semibold text-dead-600">
|
||||
⚠ 예산 안에서도 차단된 세션 {blockCount}건 — 최소 {ip.data.block_min_requests}회 요청에서 차단됐습니다.
|
||||
요청 예산을 그보다 낮게 유지하세요(현재 서버 설정은 toml [DecodoConfig].ip_request_budget)
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card title="IP 세션 종료 사유" hint={`총 ${totalSessions}건`}>
|
||||
{ip.isPending && <Loading />}
|
||||
{ip.data && reasons.length === 0 && <Empty>기간 내 세션 없음 — 워커가 검색을 시작하면 쌓입니다</Empty>}
|
||||
{reasons.length > 0 && (
|
||||
<div className="flex items-center gap-4">
|
||||
<ResponsiveContainer width={150} height={150}>
|
||||
<PieChart>
|
||||
<Pie data={reasons} dataKey="value" nameKey="label" innerRadius={42} outerRadius={70}
|
||||
paddingAngle={2} stroke="var(--color-surface)" strokeWidth={2} isAnimationActive={false}>
|
||||
{reasons.map((r) => <Cell key={r.key} fill={r.color} />)}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v: number, name: string) => [`${v}건`, name]} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<ul className="min-w-0 flex-1 space-y-1.5 text-[12px]">
|
||||
{reasons.map((r) => (
|
||||
<li key={r.key} className="flex items-baseline gap-2">
|
||||
<span className="mt-1 h-2 w-2 shrink-0 rounded-full" style={{ background: r.color }} aria-hidden />
|
||||
<span className="font-semibold">{r.label}</span>
|
||||
<span className="tnum ml-auto shrink-0 font-bold">{r.value}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{reasons.length > 0 && (
|
||||
<p className="mt-2 text-[11px] leading-relaxed text-ink-400">
|
||||
초록(예산 선제)·파랑(시간창)이 대부분이면 건강한 상태입니다. 빨강(차단)이 보이면 예산 하향 신호.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="세션당 요청 수 분포" hint="예산 튜닝 근거">
|
||||
{ip.isPending && <Loading />}
|
||||
{ip.data && ip.data.histogram.length === 0 && <Empty>데이터 없음</Empty>}
|
||||
{ip.data && ip.data.histogram.length > 0 && (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={170}>
|
||||
<BarChart data={ip.data.histogram} margin={{ top: 14, right: 12, bottom: 0, left: -22 }}>
|
||||
<CartesianGrid stroke="var(--color-grid)" vertical={false} />
|
||||
<XAxis dataKey="requests" tick={{ fill: "var(--color-ink-400)" }} tickLine={false}
|
||||
axisLine={{ stroke: "var(--color-line-200)" }} label={undefined} />
|
||||
<YAxis allowDecimals={false} tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={false} />
|
||||
<Tooltip formatter={(v: number) => [`${v}건`, "세션 수"]} labelFormatter={(l) => `IP당 ${l}회 요청`} />
|
||||
<Bar dataKey="count" fill="var(--color-cost-ai)" radius={[4, 4, 0, 0]} maxBarSize={36} isAnimationActive={false} />
|
||||
{ip.data.block_min_requests != null && (
|
||||
<ReferenceLine x={ip.data.block_min_requests} stroke="#d03b3b" strokeDasharray="4 3"
|
||||
label={{ value: `차단 시작 ${ip.data.block_min_requests}회`, position: "top", fill: "#d03b3b", fontSize: 11, fontWeight: 600 }} />
|
||||
)}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<p className="mt-1 text-[11px] leading-relaxed text-ink-400">
|
||||
X축 = 한 IP로 보낸 요청 수. 분포가 예산값에 몰려 있으면 정상, 붉은 기준선(차단 시작점)보다 예산이 낮아야 안전.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card title="차단 발생 추이" hint="시간대별">
|
||||
{bot.isPending && <Loading />}
|
||||
{bot.isError && <ErrorNote error={bot.error} />}
|
||||
{bot.data && bot.data.hourly.length === 0 && <Empty>기간 내 차단 없음 🎉</Empty>}
|
||||
{bot.data && bot.data.hourly.length > 0 && (
|
||||
<ResponsiveContainer width="100%" height={150}>
|
||||
<BarChart data={bot.data.hourly.map((h) => ({ ...h, x: dateShort(h.bucket) }))} margin={{ top: 6, right: 12, bottom: 0, left: -22 }}>
|
||||
<CartesianGrid stroke="var(--color-grid)" vertical={false} />
|
||||
<XAxis dataKey="x" tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={{ stroke: "var(--color-line-200)" }} minTickGap={40} />
|
||||
<YAxis allowDecimals={false} tick={{ fill: "var(--color-ink-400)" }} tickLine={false} axisLine={false} />
|
||||
<Tooltip formatter={(v: number) => [`${v}건`, "차단"]} />
|
||||
<Bar dataKey="count" fill="#d03b3b" radius={[4, 4, 0, 0]} maxBarSize={24} isAnimationActive={false} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="최근 차단 이력" hint="최근 50건">
|
||||
{bot.data && bot.data.items.length === 0 && <Empty>기간 내 차단 없음</Empty>}
|
||||
{bot.data && bot.data.items.length > 0 && (
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
<table className="w-full text-left text-[12px]">
|
||||
<thead>
|
||||
<tr className="border-b border-line-200 text-[11px] text-ink-400">
|
||||
<th className="pb-1 pr-2 font-semibold">시각</th>
|
||||
<th className="pb-1 pr-2 font-semibold">검색어</th>
|
||||
<th className="pb-1 pr-2 text-right font-semibold">요청#</th>
|
||||
<th className="pb-1 pr-2 text-right font-semibold">포트</th>
|
||||
<th className="pb-1 font-semibold">감지 근거</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bot.data.items.map((b, i) => (
|
||||
<tr key={i} className="border-b border-line-100">
|
||||
<td className="tnum py-1.5 pr-2 text-ink-500">{hhmm(b.created_at)}</td>
|
||||
<td className="max-w-32 truncate py-1.5 pr-2">{b.query || "—"}</td>
|
||||
<td className="tnum py-1.5 pr-2 text-right">{b.ip_request_no ?? "—"}</td>
|
||||
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{b.proxy_port ?? "—"}</td>
|
||||
<td className="max-w-40 truncate py-1.5 text-[11px] text-ink-500">{b.marker}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card title="최근 IP 세션" hint="최근 50건">
|
||||
{ip.data && ip.data.sessions.length === 0 && <Empty>세션 없음</Empty>}
|
||||
{ip.data && ip.data.sessions.length > 0 && (
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
<table className="w-full text-left text-[12px]">
|
||||
<thead>
|
||||
<tr className="border-b border-line-200 text-[11px] text-ink-400">
|
||||
<th className="pb-1 pr-2 font-semibold">종료 시각</th>
|
||||
<th className="pb-1 pr-2 font-semibold">소스</th>
|
||||
<th className="pb-1 pr-2 text-right font-semibold">포트</th>
|
||||
<th className="pb-1 pr-2 text-right font-semibold">요청</th>
|
||||
<th className="pb-1 pr-2 text-right font-semibold">성공/차단</th>
|
||||
<th className="pb-1 pr-2 text-right font-semibold">지속</th>
|
||||
<th className="pb-1 font-semibold">종료 사유</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ip.data.sessions.map((s, i) => {
|
||||
const r = REASONS[s.end_reason] ?? { label: s.end_reason, color: "#9aa1ac" };
|
||||
return (
|
||||
<tr key={i} className="border-b border-line-100">
|
||||
<td className="tnum py-1.5 pr-2 text-ink-500">{dateShort(s.created_at)}</td>
|
||||
<td className="py-1.5 pr-2">{s.source}</td>
|
||||
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{s.proxy_port ?? "—"}</td>
|
||||
<td className="tnum py-1.5 pr-2 text-right font-semibold">{s.requests}</td>
|
||||
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{s.ok_count}/{s.blocked_count}</td>
|
||||
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{durationSec(s.elapsed_sec)}</td>
|
||||
<td className="py-1.5">
|
||||
<span className="inline-flex items-center gap-1.5 text-[11.5px] font-semibold" style={{ color: r.color }}>
|
||||
<span className="h-1.5 w-1.5 rounded-full" style={{ background: r.color }} aria-hidden />
|
||||
{r.label}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
lps-temp-fe/src/pages/Dashboard.tsx
Normal file
101
lps-temp-fe/src/pages/Dashboard.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
/** 대시보드 — 지금 시스템이 건강한가. /ops 5초 폴링 + 임계 배너 + 큐 추이(세션 내 누적). */
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
|
||||
} from "recharts";
|
||||
import { get } from "../api/client";
|
||||
import type { OpsSnapshot } from "../api/types";
|
||||
import { Card, ErrorNote, StatTile } from "../components/ui";
|
||||
import { durationSec, usd } from "../lib/format";
|
||||
|
||||
// 알림 임계 기본값 미러 — 서버 [AlertConfig] 와 동일 기본(서버가 조정했으면 다를 수 있음, 참고용 배너).
|
||||
const TH = { dead_1h: 20, blocks_1h: 80, queue_lag_sec: 300, pool_pct: 90, deadline_1h: 5, cost_1h_usd: 1.0 };
|
||||
|
||||
interface TrendPoint { t: string; pending: number; running: number }
|
||||
|
||||
export default function Dashboard() {
|
||||
const trend = useRef<TrendPoint[]>([]);
|
||||
const ops = useQuery({
|
||||
queryKey: ["ops"],
|
||||
queryFn: () => get<OpsSnapshot>("/v1/lps/ops"),
|
||||
refetchInterval: 5_000,
|
||||
});
|
||||
|
||||
if (ops.data) {
|
||||
const now = new Date().toLocaleTimeString("ko-KR", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
const last = trend.current[trend.current.length - 1];
|
||||
if (!last || last.t !== now) {
|
||||
trend.current = [...trend.current, { t: now, pending: ops.data.pending, running: ops.data.running }].slice(-120);
|
||||
}
|
||||
}
|
||||
|
||||
const d = ops.data;
|
||||
const alerts: { key: string; msg: string }[] = [];
|
||||
if (d) {
|
||||
if (d.dead_1h >= TH.dead_1h) alerts.push({ key: "dead", msg: `최근 1시간 실패(DEAD) ${d.dead_1h}건 — 검색 실패가 쌓이고 있습니다` });
|
||||
if (d.blocks_1h >= TH.blocks_1h) alerts.push({ key: "blocks", msg: `최근 1시간 차단 ${d.blocks_1h}건 — IP 평판 악화 신호` });
|
||||
if (d.oldest_pending_sec >= TH.queue_lag_sec) alerts.push({ key: "lag", msg: `큐 지연 ${durationSec(d.oldest_pending_sec)} — 워커가 처리를 못 따라갑니다` });
|
||||
if (d.stuck_running > 0) alerts.push({ key: "stuck", msg: `멈춘 작업 ${d.stuck_running}건 — 워커 사망/행 흔적` });
|
||||
if (d.pool_pct >= TH.pool_pct) alerts.push({ key: "pool", msg: `DB 커넥션 풀 ${d.pool_pct}% 포화` });
|
||||
if (d.deadline_1h >= TH.deadline_1h) alerts.push({ key: "deadline", msg: `최근 1시간 데드라인 강제종료 ${d.deadline_1h}건 — 크롤 행 반복 신호` });
|
||||
if (d.cost_1h_usd >= TH.cost_1h_usd) alerts.push({ key: "cost", msg: `최근 1시간 검색원가 ${usd(d.cost_1h_usd, 2)} — 비용 급증 점검` });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-[17px] font-bold tracking-tight">대시보드</h1>
|
||||
|
||||
{ops.isError && <ErrorNote error={ops.error} />}
|
||||
{alerts.map((a) => (
|
||||
<p key={a.key} role="alert"
|
||||
className="rounded-lg border border-dead-600/20 bg-dead-50 px-3 py-2 text-[12.5px] font-semibold text-dead-600">
|
||||
⚠ {a.msg}
|
||||
</p>
|
||||
))}
|
||||
{d && alerts.length === 0 && (
|
||||
<p className="rounded-lg bg-ok-50 px-3 py-2 text-[12.5px] font-semibold text-ok-600">
|
||||
✓ 모든 지표가 임계 안에 있습니다
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<StatTile label="대기" value={d?.pending ?? "—"} tone="warn" sub="검색 순서를 기다리는 작업" />
|
||||
<StatTile label="처리중" value={d?.running ?? "—"} tone="run" sub="워커가 검색 중" />
|
||||
<StatTile label="완료" value={d?.done ?? "—"} tone="ok" sub="누적" />
|
||||
<StatTile label="실패 (DEAD)" value={d?.dead ?? "—"} tone={d && d.dead > 0 ? "dead" : "default"} sub={`최근 1h ${d?.dead_1h ?? "—"}건`} />
|
||||
<StatTile label="큐 지연" value={d ? durationSec(d.oldest_pending_sec) : "—"} sub="가장 오래 기다린 작업" />
|
||||
<StatTile label="차단 (1h)" value={d?.blocks_1h ?? "—"} tone={d && d.blocks_1h > 0 ? "warn" : "default"} sub="쿠팡 봇 감지" />
|
||||
<StatTile label="검색원가 (1h)" value={d ? usd(d.cost_1h_usd, 2) : "—"} sub="AI + 프록시 대역폭" />
|
||||
<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>
|
||||
|
||||
<p className="text-[11px] text-ink-400">
|
||||
임계 배너는 서버 알림 기본값 기준입니다(서버 toml 에서 조정했으면 실제 알림과 다를 수 있음).
|
||||
전체 룰은 lps/docs/operations.md 참고.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
174
lps-temp-fe/src/pages/Jobs.tsx
Normal file
174
lps-temp-fe/src/pages/Jobs.tsx
Normal file
@ -0,0 +1,174 @@
|
||||
/** 작업 큐 — 잡 목록(필터·검색·페이지네이션) + 상세 패널 + 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 { dateShort, usd, won } from "../lib/format";
|
||||
|
||||
const PAGE = 20;
|
||||
const TABS: { v: JobStatusName | ""; label: string }[] = [
|
||||
{ v: "", label: "전체" },
|
||||
{ v: "PENDING", label: "대기" },
|
||||
{ v: "RUNNING", label: "처리중" },
|
||||
{ v: "DONE", label: "완료" },
|
||||
{ v: "DEAD", label: "실패" },
|
||||
];
|
||||
|
||||
export default function Jobs() {
|
||||
const [status, setStatus] = useState<JobStatusName | "">("");
|
||||
const [q, setQ] = useState("");
|
||||
const [qInput, setQInput] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const [selected, setSelected] = useState<JobListItem | null>(null);
|
||||
|
||||
const params = new URLSearchParams({ limit: String(PAGE), offset: String(page * PAGE) });
|
||||
if (status) params.set("status", status);
|
||||
if (q) params.set("q", q);
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: ["jobs", status, q, page],
|
||||
queryFn: () => get<JobListRes>(`/v1/lps/jobs?${params}`),
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
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 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>
|
||||
<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="상품코드·상품명 검색"
|
||||
className="w-56 rounded-lg border border-line-200 bg-surface px-3 py-1.5 text-[12.5px] outline-none focus:border-primary-600" />
|
||||
<button type="submit" className="rounded-lg bg-primary-600 px-3 py-1.5 text-[12px] font-semibold text-white hover:bg-primary-700">검색</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div 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-[12.5px]">
|
||||
<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)}
|
||||
className={`cursor-pointer border-b border-line-100 hover:bg-surface-2 ${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-[11.5px] 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>
|
||||
|
||||
<JobDetail job={selected} onClose={() => setSelected(null)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JobDetail({ job, onClose }: { job: JobListItem | null; onClose: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const detail = useQuery({
|
||||
queryKey: ["job", job?.job_id],
|
||||
queryFn: () => get<JobDetailRes>(`/v1/lps/jobs/${job!.job_id}`),
|
||||
enabled: !!job,
|
||||
});
|
||||
const requeue = useMutation({
|
||||
mutationFn: () => post<RequeueRes>(`/v1/lps/jobs/${job!.job_id}/requeue`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["jobs"] }),
|
||||
});
|
||||
|
||||
if (!job) return <Card title="상세"><Empty>왼쪽 목록에서 작업을 선택하세요</Empty></Card>;
|
||||
|
||||
const out = detail.data?.output as Record<string, any> | undefined;
|
||||
return (
|
||||
<Card title="상세" hint={<button onClick={onClose} className="font-semibold text-ink-400 hover:text-ink-700">닫기</button>}>
|
||||
<dl className="space-y-2 text-[12.5px]">
|
||||
<Row k="작업 ID"><span className="tnum break-all text-[11.5px]">{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>
|
||||
|
||||
{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-[12.5px] 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>
|
||||
)}
|
||||
|
||||
{out && (
|
||||
<details className="mt-3">
|
||||
<summary className="cursor-pointer text-[11.5px] font-semibold text-ink-400">원본 결과(JSON)</summary>
|
||||
<pre className="mt-1 max-h-64 overflow-auto rounded-lg bg-surface-2 p-2 text-[10.5px] leading-relaxed">{JSON.stringify(out, null, 2)}</pre>
|
||||
</details>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ k, children }: { k: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<dt className="w-16 shrink-0 text-[11.5px] font-semibold text-ink-400">{k}</dt>
|
||||
<dd className="min-w-0 flex-1">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
187
lps-temp-fe/src/pages/Products.tsx
Normal file
187
lps-temp-fe/src/pages/Products.tsx
Normal file
@ -0,0 +1,187 @@
|
||||
/** 상품·가격 — 이력 상품 목록 → 가격 추이 3선(네이버·쿠팡·최종) + 몰별 최저가 + 재검색. */
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Bar, BarChart, CartesianGrid, Cell, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
|
||||
} from "recharts";
|
||||
import { get, post } from "../api/client";
|
||||
import type { PriceHistoryRes, ProductItem, ProductListRes, SearchRes } from "../api/types";
|
||||
import { Card, Empty, ErrorNote, Loading } from "../components/ui";
|
||||
import { dateShort, timeAgo, won } from "../lib/format";
|
||||
|
||||
// 시리즈 색은 엔터티 고정(네이버 초록은 CVD 분리 검증 통과 톤) — 순서·색 재배정 금지.
|
||||
const SERIES = [
|
||||
{ key: "naver", label: "네이버", color: "var(--color-naver)" },
|
||||
{ key: "coupang", label: "쿠팡", color: "var(--color-coupang)" },
|
||||
{ key: "final", label: "최종 최저가", color: "var(--color-final)" },
|
||||
] as const;
|
||||
|
||||
export default function Products() {
|
||||
const [q, setQ] = useState("");
|
||||
const [qInput, setQInput] = useState("");
|
||||
const [selected, setSelected] = useState<ProductItem | null>(null);
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: ["products", q],
|
||||
queryFn: () => get<ProductListRes>(`/v1/lps/products?limit=100${q ? `&q=${encodeURIComponent(q)}` : ""}`),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-[17px] font-bold tracking-tight">상품·가격</h1>
|
||||
|
||||
<form className="flex gap-1.5" onSubmit={(e) => { e.preventDefault(); setQ(qInput.trim()); }}>
|
||||
<input value={qInput} onChange={(e) => setQInput(e.target.value)} placeholder="상품코드 검색"
|
||||
className="w-64 rounded-lg border border-line-200 bg-surface px-3 py-1.5 text-[12.5px] outline-none focus:border-primary-600" />
|
||||
<button type="submit" className="rounded-lg bg-primary-600 px-3 py-1.5 text-[12px] font-semibold text-white hover:bg-primary-700">검색</button>
|
||||
</form>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[400px_1fr]">
|
||||
<Card 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">
|
||||
{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" : ""}`}>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate text-[12.5px] font-semibold">{p.display_name || p.product_code}</span>
|
||||
<span className="tnum shrink-0 text-[12.5px] font-bold">{won(p.final_lowest)}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex justify-between text-[11px] text-ink-400">
|
||||
<span>{p.product_code} · 검색 {p.searches}회</span>
|
||||
<span>{timeAgo(p.triggered_at)}{p.outcome === "not_found" ? " · 못 찾음" : ""}</span>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<ProductDetail product={selected} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductDetail({ product }: { product: ProductItem | null }) {
|
||||
const qc = useQueryClient();
|
||||
const history = useQuery({
|
||||
queryKey: ["history", product?.product_code],
|
||||
queryFn: () => get<PriceHistoryRes>(`/v1/lps/products/${encodeURIComponent(product!.product_code)}/history?limit=200`),
|
||||
enabled: !!product,
|
||||
});
|
||||
const research = useMutation({
|
||||
mutationFn: () => post<SearchRes>("/v1/lps/search", {
|
||||
data: [{ product_code: product!.product_code, product_name: product!.display_name || product!.product_code, job_type: "manual" }],
|
||||
}),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["jobs"] }),
|
||||
});
|
||||
|
||||
if (!product) return <Card title="가격 추이"><Empty>왼쪽에서 상품을 선택하세요</Empty></Card>;
|
||||
|
||||
const points = (history.data?.points ?? []).map((p) => ({ ...p, x: dateShort(p.triggered_at) }));
|
||||
const latest = history.data?.points.at(-1);
|
||||
const malls = (latest?.by_mall ?? []).filter((m) => m.price != null)
|
||||
.sort((a, b) => (a.price ?? 0) - (b.price ?? 0)).slice(0, 8);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card
|
||||
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-[11.5px] font-semibold text-white hover:bg-primary-700 disabled:opacity-50">
|
||||
{research.isPending ? "요청 중…" : "지금 다시 검색"}
|
||||
</button>
|
||||
}>
|
||||
{research.isSuccess && (
|
||||
<p className="mb-2 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} />
|
||||
))}
|
||||
</LineChart>
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{latest && (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card title="몰별 최저가 (최근 검색)" hint={dateShort(latest.triggered_at)}>
|
||||
{malls.length === 0 ? (
|
||||
<Empty>몰별 데이터 없음</Empty>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={Math.max(120, malls.length * 34)}>
|
||||
<BarChart data={malls} layout="vertical" margin={{ top: 0, right: 56, bottom: 0, left: 8 }}>
|
||||
<XAxis type="number" hide domain={[0, "dataMax"]} />
|
||||
<YAxis type="category" dataKey="mall" width={88} tick={{ fill: "var(--color-ink-700)", fontSize: 11.5 }} tickLine={false} axisLine={false} />
|
||||
<Tooltip formatter={(v: number) => [won(v), "최저가"]} />
|
||||
<Bar dataKey="price" barSize={14} radius={[0, 4, 4, 0]}
|
||||
label={{ position: "right", fill: "var(--color-ink-500)", fontSize: 11, formatter: (v: number) => v.toLocaleString("ko-KR") }}>
|
||||
{malls.map((m, i) => (
|
||||
<Cell key={i} fill={m.source === "naver" ? "var(--color-naver)" : m.source === "coupang" ? "var(--color-coupang)" : "var(--color-ink-400)"} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="검증 링크 (최근 검색)">
|
||||
<ul className="space-y-2 text-[12.5px]">
|
||||
{latest.naver_url && (
|
||||
<li>
|
||||
<a href={latest.naver_url} target="_blank" rel="noreferrer" className="font-semibold text-primary-600 hover:underline">
|
||||
네이버 — {won(latest.naver)} ↗
|
||||
</a>
|
||||
<div className="truncate text-[11px] text-ink-400">{latest.naver_name}</div>
|
||||
</li>
|
||||
)}
|
||||
{latest.coupang_url && (
|
||||
<li>
|
||||
<a href={latest.coupang_url} target="_blank" rel="noreferrer" className="font-semibold text-primary-600 hover:underline">
|
||||
쿠팡 — {won(latest.coupang)} ↗
|
||||
</a>
|
||||
<div className="truncate text-[11px] text-ink-400">{latest.coupang_name}</div>
|
||||
</li>
|
||||
)}
|
||||
{!latest.naver_url && !latest.coupang_url && <Empty>링크 없음</Empty>}
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
lps-temp-fe/src/pages/Settings.tsx
Normal file
52
lps-temp-fe/src/pages/Settings.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
/** 설정 — API base URL·guard 키(localStorage). prod 에서 X-API-Key 헤더로 자동 첨부. */
|
||||
|
||||
import { useState } from "react";
|
||||
import { settings } from "../api/client";
|
||||
import { Card } from "../components/ui";
|
||||
|
||||
export default function Settings() {
|
||||
const [baseUrl, setBaseUrl] = useState(settings.baseUrl());
|
||||
const [apiKey, setApiKey] = useState(settings.apiKey());
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="max-w-xl space-y-4">
|
||||
<h1 className="text-[17px] font-bold tracking-tight">설정</h1>
|
||||
<Card title="API 연결">
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
settings.save(baseUrl.trim(), apiKey.trim());
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label htmlFor="base" className="mb-1 block text-[12px] font-semibold text-ink-700">API 주소</label>
|
||||
<input id="base" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder="비우면 현재 주소 기준 (개발: vite 프록시 → :9600)"
|
||||
className="w-full rounded-lg border border-line-200 bg-surface px-3 py-2 text-[12.5px] outline-none focus:border-primary-600" />
|
||||
<p className="mt-1 text-[11px] text-ink-400">예: https://lps.example.com — dev 서버를 직접 보려면 해당 주소 입력</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="key" className="mb-1 block text-[12px] font-semibold text-ink-700">API 키 (X-API-Key)</label>
|
||||
<input id="key" type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="개발(개방 모드)은 비워둠 — prod 만 필요"
|
||||
autoComplete="off"
|
||||
className="w-full rounded-lg border border-line-200 bg-surface px-3 py-2 text-[12.5px] 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-[12.5px] font-semibold text-white hover:bg-primary-700">
|
||||
저장
|
||||
</button>
|
||||
{saved && <span className="ml-2 text-[12px] font-medium text-ok-600">저장했습니다</span>}
|
||||
</form>
|
||||
</Card>
|
||||
<p className="text-[11px] leading-relaxed text-ink-400">
|
||||
지표 임계·요청 예산 등 서버 동작 설정은 이 화면에서 바꾸지 않습니다 — 설정 소스는 서버의
|
||||
config.<env>.toml 하나이며(2026-07-13 협의), 변경은 toml 수정 + 재시작으로 합니다.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
lps-temp-fe/tsconfig.json
Normal file
18
lps-temp-fe/tsconfig.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
1
lps-temp-fe/tsconfig.tsbuildinfo
Normal file
1
lps-temp-fe/tsconfig.tsbuildinfo
Normal file
@ -0,0 +1 @@
|
||||
{"root":["./src/main.tsx","./src/api/client.ts","./src/api/types.ts","./src/components/layout.tsx","./src/components/ui.tsx","./src/lib/format.ts","./src/pages/costs.tsx","./src/pages/crawl.tsx","./src/pages/dashboard.tsx","./src/pages/jobs.tsx","./src/pages/products.tsx","./src/pages/settings.tsx"],"version":"5.6.3"}
|
||||
19
lps-temp-fe/vite.config.ts
Normal file
19
lps-temp-fe/vite.config.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// dev 서버는 /v1·/healthz·/readyz 를 LPS API(:9600)로 프록시한다 — CORS 설정 불필요.
|
||||
// 다른 API 호스트를 보려면 VITE_LPS_URL 로 대상 변경(또는 앱 내 설정에서 base URL 지정).
|
||||
const target = process.env.VITE_LPS_URL || "http://localhost:9600";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
"/v1": { target, changeOrigin: true },
|
||||
"/healthz": { target, changeOrigin: true },
|
||||
"/readyz": { target, changeOrigin: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user