feat(frontend): 포탈·운영·관리자 콘솔 및 매뉴얼·거래 타임라인·전문탭·서비스 제어 UI

- 콘솔을 포탈/운영/관리자 3뷰로 분리(Portal·AdminPanel·AdminLogin·auth·util), ADMIN JWT 로그인 게이트
- 상단 우측 '서비스 매뉴얼'(public/manual.html) 링크, 로그인 화면 불필요 계좌조회 에러 제거
- 거래 조회: 서비스별 처리시각 5단계 타임라인 + pacs.008/pacs.002 전문 탭(/notifications 프록시)
- 관리자: 서비스 제어 패널(기동/중지/재기동/로그, 4초 폴링, 인프라 상태 포함)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
rtgs
2026-07-16 13:41:35 +09:00
parent 1180013506
commit c96664e52d
8 changed files with 933 additions and 167 deletions

334
frontend/public/manual.html Normal file
View File

@@ -0,0 +1,334 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RTGS 포탈 · 서비스 매뉴얼</title>
<style>
:root {
--gucci: #6b4fbb; --chanel: #0b63c4; --hermes: #c77d0a;
--dior: #8a4fd0; --prada: #b8860b; --lv: #1a7f37; --seq: #444;
--ink: #222; --muted: #666; --line: #e2e2e2; --bg: #f4f7fb;
}
* { box-sizing: border-box; }
body {
font-family: system-ui, "Segoe UI", "Malgun Gothic", sans-serif;
color: var(--ink); line-height: 1.65; margin: 0;
background: #fafbfc;
}
.wrap { max-width: 960px; margin: 0 auto; padding: 32px 24px 80px; }
header.doc {
background: linear-gradient(135deg, #1a2a44, #2c4468);
color: #fff; border-radius: 12px; padding: 28px 30px; margin-bottom: 8px;
}
header.doc h1 { margin: 0 0 6px; font-size: 26px; }
header.doc p { margin: 0; color: #cdd8ea; font-size: 14px; }
.toc {
background: #fff; border: 1px solid var(--line); border-radius: 10px;
padding: 16px 22px; margin: 20px 0 8px; font-size: 14px;
}
.toc b { display: block; margin-bottom: 8px; color: var(--muted); font-size: 12px; letter-spacing: .04em; text-transform: uppercase; }
.toc a { color: var(--chanel); text-decoration: none; margin-right: 16px; white-space: nowrap; line-height: 2; }
.toc a:hover { text-decoration: underline; }
h2 {
font-size: 20px; margin: 40px 0 14px; padding-bottom: 8px;
border-bottom: 2px solid #eceff3;
}
h3 { font-size: 16px; margin: 22px 0 8px; }
p, li { font-size: 15px; }
code {
background: #eef1f5; padding: 1px 6px; border-radius: 4px;
font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-size: 13px;
}
.lead { color: var(--muted); font-size: 15px; }
/* 상태 범례 */
.legend { display: flex; gap: 20px; flex-wrap: wrap; background: var(--bg); border-radius: 10px; padding: 14px 18px; margin: 12px 0; }
.legend div { font-size: 14px; }
/* 흐름도 */
.flow { display: flex; align-items: stretch; gap: 4px; flex-wrap: wrap; margin: 14px 0; }
.flow .node {
flex: 1; min-width: 92px; border: 2px solid #ccd3dd; border-radius: 8px;
background: #fff; text-align: center; padding: 10px 6px; font-size: 13px;
}
.flow .node small { display: block; color: var(--muted); font-size: 11px; margin-top: 2px; }
.flow .arrow { align-self: center; color: #99a; font-size: 18px; flex: 0 0 auto; }
/* 서비스 카드 */
.svc {
border: 1px solid var(--line); border-top: 5px solid var(--bar, #888);
border-radius: 10px; padding: 20px 22px; margin: 18px 0;
background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.04);
}
.svc h3 { margin-top: 0; font-size: 18px; display: flex; align-items: center; gap: 8px; }
.svc .tag {
font-size: 11px; font-weight: 600; color: #fff; background: var(--bar, #888);
padding: 2px 9px; border-radius: 20px; margin-left: auto;
}
.svc .sub { color: var(--muted); font-size: 13px; margin: -4px 0 12px; }
.meta-row { display: flex; flex-wrap: wrap; gap: 8px 18px; font-size: 13px; color: #555; margin-bottom: 10px; }
.meta-row span b { color: var(--ink); }
table { border-collapse: collapse; width: 100%; margin: 10px 0; font-size: 14px; }
th, td { border: 1px solid #e6e9ee; padding: 7px 11px; text-align: left; vertical-align: top; }
th { background: #f4f7fb; font-weight: 600; }
td code { white-space: nowrap; }
.metrics th:first-child, .metrics td:first-child { width: 34%; }
.note { background: #fffbe9; border: 1px solid #f0e2a8; border-radius: 8px; padding: 12px 16px; font-size: 14px; margin: 14px 0; }
.btn-chip { display: inline-block; border: 1px solid #ccc; border-radius: 6px; padding: 2px 10px; font-size: 13px; background: #fff; margin: 2px 4px 2px 0; }
footer { margin-top: 50px; padding-top: 18px; border-top: 1px solid var(--line); color: #999; font-size: 13px; }
</style>
</head>
<body>
<div class="wrap">
<header class="doc">
<h1>🏛 RTGS 포탈 서비스 매뉴얼</h1>
<p>소액 RTGS(실시간총액결제) 프로토타입 · 7개 서비스 안내 · Active-Active-Active 복원력 설계</p>
</header>
<nav class="toc">
<b>목차</b>
<a href="#intro">1. 포탈 개요</a>
<a href="#read">2. 화면 읽는 법</a>
<a href="#flow">3. 업무 흐름</a>
<a href="#services">4. 서비스별 설명</a>
<a href="#status">5. 거래 상태(TxSts)</a>
<a href="#ports">6. 포트 일람</a>
<a href="#glossary">7. 용어</a>
</nav>
<h2 id="intro">1. 포탈 개요</h2>
<p class="lead">
본 포탈은 소액 <b>RTGS(Real-Time Gross Settlement, 실시간총액결제)</b> 프로토타입의 운영·관리 콘솔입니다.
거래는 <b>단일 전역 순번기</b>가 입구에서 순번을 부여하고, 각 센터가 그 순번대로 동일하게 재생(결정론적 처리)하며,
<b>원장(PostgreSQL)</b>이 잔액을 강한 일관성으로 확정합니다. 3센터 <b>Active-Active-Active</b> 구성에서
정족수(2/3)와 펜싱으로 split-brain을 차단하는 것이 설계의 핵심입니다.
</p>
<p>
시스템은 명품 브랜드를 코드네임으로 쓰는 <b>7개 서비스</b>로 구성됩니다. 포탈 화면에는 이 중
주요 6개가 카드로 표시되고, 전역 순번기(Sequencer)는 업무 흐름도의 진입 노드로 나타납니다.
각 서비스가 무슨 일을 하고, 카드에 표시되는 숫자가 무엇을 뜻하는지 이 문서에서 설명합니다.
</p>
<h2 id="read">2. 화면 읽는 법</h2>
<h3>상태 표시등</h3>
<div class="legend">
<div>🟢 <b>UP</b> — 정상 가동(최근 하트비트 수신)</div>
<div>🟡 <b>STALE</b> — 하트비트 지연(응답이 오래됨, 점검 필요)</div>
<div>🔴 <b>DOWN</b> — 중단(하트비트 없음/도달 불가)</div>
</div>
<p>
상단 바의 <code>● n/7 UP</code>은 7개 서비스 중 정상 가동 중인 수를 나타냅니다.
각 서비스는 DB의 <code>service_heartbeat</code>에 주기적으로 살아있음을 기록하고(헤드리스 포함),
관리 서비스가 이를 읽어 UP/STALE/DOWN을 판정합니다.
</p>
<h3>자동 갱신 · 새로고침</h3>
<p>
<span class="btn-chip">자동갱신 5s</span> 체크 시 5초마다 상태·지표를 다시 불러옵니다.
<span class="btn-chip">새로고침</span>으로 즉시 갱신할 수도 있습니다.
카드의 처리 건수는 <b>DB 집계(원장)</b> 기준이라 관리자 콘솔에서 초기화하면 자연히 0으로 돌아갑니다.
</p>
<h2 id="flow">3. 업무 흐름</h2>
<p>한 건의 이체는 아래 순서로 처리됩니다. 각 노드는 담당 서비스이며, 포탈 흐름도에서 색 테두리로 실시간 상태를 함께 보여줍니다.</p>
<div class="flow">
<div class="node">🔢 Sequencer<small>전역순번</small></div>
<div class="arrow"></div>
<div class="node">🛡️ Gucci<small>관문 인증</small></div>
<div class="arrow"></div>
<div class="node">📥 Chanel<small>접수</small></div>
<div class="arrow"></div>
<div class="node">🔄 Dior<small>동기화</small></div>
<div class="arrow"></div>
<div class="node">💰 Hermes<small>결제</small></div>
<div class="arrow"></div>
<div class="node">✅ Prada<small>처리동기화</small></div>
<div class="arrow"></div>
<div class="node">📥 Chanel<small>결과통보</small></div>
</div>
<p class="lead">
전역순번(Sequencer) 부여 → 관문 인증(Gucci) → 접수(Chanel) → 저널/센터 동기화(Dior) →
결제·원장(Hermes) → 처리 동기화·완결(Prada) → 결과통보(Chanel).
</p>
<h2 id="services">4. 서비스별 설명</h2>
<!-- Sequencer -->
<div class="svc" style="--bar: var(--seq)">
<h3>🔢 Sequencer <span class="tag">전역 순번기 · Headless</span></h3>
<div class="sub">전역순번 부여 · 결정론적 값 확정 → 저널 발행</div>
<div class="meta-row"><span>논리포트 <b>8090</b></span><span>유형 <b>Headless JVM</b></span><span>Kafka: <code>rtgs.inbound</code><code>rtgs.journal</code></span></div>
<p>
시스템의 <b>단일 진입 순서 결정자</b>입니다. 접수된 요청에 전역순번(<code>global_seq</code>)을 부여해
단일 파티션 저널에 발행하면, 모든 센터가 이 순서를 그대로 재생해 동일한 결과를 만듭니다(결정론적 처리).
재기동 시 <code>MAX(global_seq)</code>에서 이어서 발번하므로 순번 유실이 없습니다.
</p>
<p class="lead">※ 순서 결정만 담당(상태 O). 분산·라우팅은 Gucci/GSLB(상태 X)가 맡아 역할을 분리합니다.
포탈에서는 별도 카드 없이 흐름도의 진입 노드로 표시됩니다.</p>
</div>
<!-- Gucci -->
<div class="svc" style="--bar: var(--gucci)">
<h3>🛡️ Gucci <span class="tag">게이트웨이 · 기관 인증</span></h3>
<div class="sub">외부 경계 관문 — 로그인 · JWT · API인증 · 유량제어 · 재전송차단 · 결과 콜백송부</div>
<div class="meta-row"><span>논리포트 <b>8095</b></span><span>유형 <b>Web(Tomcat)</b></span><span>코드네임 <b>Global User Communication Control Interface</b></span></div>
<p>
참가기관과 시스템 사이의 <b>대문</b>입니다. 로그인 후 JWT(HS256)를 발급하고, 모든 요청의 인증·인가
(역할 + 기관 바인딩)를 검사합니다. 기관별 유량제어로 코어·순번기를 보호하고(백프레셔),
nonce+timestamp로 재전송을 차단합니다. 처리 결과는 기관 콜백 URL로 재시도·ACK 방식으로 송부합니다.
</p>
<table class="metrics">
<tr><th>카드 지표</th><th>의미</th></tr>
<tr><td>코어(샤넬)</td><td>Gucci가 바라본 코어(Chanel) 연결 상태</td></tr>
<tr><td>등록기관</td><td>당좌계좌가 등록된 참가기관 수</td></tr>
</table>
<div>바로가기 <span class="btn-chip">감사로그(Kibana)</span> — 인증·유량·재전송 등 감사 이벤트를 Kibana에서 조회</div>
</div>
<!-- Chanel -->
<div class="svc" style="--bar: var(--chanel)">
<h3>📥 Chanel <span class="tag">접수</span></h3>
<div class="sub">pacs.008 접수 · XSD검증 · 경량화 · 원전문 저장 · 결과통보(pacs.002)</div>
<div class="meta-row"><span>논리포트 <b>8091</b></span><span>유형 <b>Web(Tomcat)</b></span><span>코드네임 <b>Contact Hub · Entry Liaison</b></span></div>
<p>
참가기관의 결제의뢰 전문(<b>ISO 20022 pacs.008</b>)을 받는 <b>접수 창구</b>입니다.
공식 XSD로 검증(XXE 차단)하고 원전문을 SHA-256 해시와 함께 저장한 뒤, 경량 명령문으로 만들어
입구 토픽에 발행합니다. 처리가 끝나면 <b>pacs.002</b> 결과통보 전문을 생성해 아웃박스에 적재합니다.
</p>
<table class="metrics">
<tr><th>카드 지표</th><th>의미</th></tr>
<tr><td>누적 접수</td><td>지금까지 접수된 거래 건수(원장 집계)</td></tr>
<tr><td>접수율</td><td>초당 접수 건수(TPS, 최근 갱신 구간 기준)</td></tr>
</table>
<div>바로가기 <span class="btn-chip">이체 신청/조회</span> — 운영 콘솔에서 pacs.008 신청 및 거래 상태 조회</div>
</div>
<!-- Dior -->
<div class="svc" style="--bar: var(--dior)">
<h3>🔄 Dior <span class="tag">센터간 접수 동기화</span></h3>
<div class="sub">저널 소비 → 원장 PDNG 기록(접수 동기화)</div>
<div class="meta-row"><span>논리포트 <b>8092</b></span><span>유형 <b>Headless JVM</b></span><span>코드네임 <b>Data Integration · Relay</b></span></div>
<p>
전역순번 저널을 소비해 원장에 거래를 <b>PDNG(대기)</b> 상태로 기록합니다. 결제(Hermes)와 병행해
저널을 읽으면서 <b>모든 센터의 접수 상태를 동일하게 맞추는</b> 동기화 역할을 합니다.
</p>
<table class="metrics">
<tr><th>카드 지표</th><th>의미</th></tr>
<tr><td>메모리</td><td>프로세스 사용 메모리(MB) — 런타임 자원 지표</td></tr>
<tr><td>가동</td><td>프로세스 가동 시간(초)</td></tr>
</table>
<div>바로가기 <span class="btn-chip">원장 보기</span> — 관리자 콘솔의 원장/거래 현황</div>
</div>
<!-- Hermes -->
<div class="svc" style="--bar: var(--hermes)">
<h3>💰 Hermes <span class="tag">결제 · 원장 엔진</span></h3>
<div class="sub">저널 순차소비 · 순서교정 · 선(先)저널 · 당좌 차/대변(강한 일관성) · ACSP</div>
<div class="meta-row"><span>논리포트 <b>8093</b></span><span>유형 <b>Headless JVM</b></span><span>코드네임 <b>Rapid Memory Execution · Settlement</b></span></div>
<p>
실제 <b>잔액을 움직이는 결제 엔진</b>입니다. 저널을 순번대로(동시성 1) 소비하며 순서를 교정하고,
잔액 연산 전에 <b>선저널(write-ahead)</b>로 순번을 확정한 뒤 송신·수신 기관 당좌계좌를 차/대변 처리합니다
(PostgreSQL 강한 일관성 트랜잭션 → 이중지급 0·총액 보존). 정산이 반영되면 <b>ACSP</b> 결과를 발행합니다.
선저널 덕분에 엔진이 죽었다 살아나도 Kafka 오프셋 재생으로 무손실 승계됩니다.
</p>
<table class="metrics">
<tr><th>카드 지표</th><th>의미</th></tr>
<tr><td>결제완료(ACCC)</td><td>최종 완결된 거래 건수(원장 집계)</td></tr>
<tr><td>평균 처리</td><td>정산 1건당 평균 소요시간(ms) — 런타임 지표</td></tr>
<tr><td>미결(대기)</td><td>접수됐으나 아직 완결·반려되지 않은 건수(전체 ACCC RJCT)</td></tr>
</table>
<div>바로가기 <span class="btn-chip">원장 보기</span> — 관리자 콘솔의 원장/잔액</div>
</div>
<!-- Prada -->
<div class="svc" style="--bar: var(--prada)">
<h3>✅ Prada <span class="tag">센터간 처리 동기화 · 완결</span></h3>
<div class="sub">결과 소비 · 완결 규율(과반) · ACCC 확정 · 조회사본 · 결과통보 발행</div>
<div class="meta-row"><span>논리포트 <b>8094</b></span><span>유형 <b>Headless JVM</b></span><span>코드네임 <b>Persistent Repository · Aggregation</b></span></div>
<p>
정산 결과를 소비해 <b>완결 규율</b>을 적용합니다. 과반(정족수) 센터가 동일 결과를 확정하면
최종 상태 <b>ACCC(입금처리완료)</b>로 확정하고 조회 전용 사본을 남깁니다. 완결이 커밋되면
결과통보(<code>rtgs.notify</code>)를 발행해 Chanel→Gucci를 통한 기관 통보로 이어집니다.
</p>
<table class="metrics">
<tr><th>카드 지표</th><th>의미</th></tr>
<tr><td>완결(ACCC)</td><td>최종 완결된 거래 건수</td></tr>
<tr><td>반려(RJCT)</td><td>검증 실패·잔액 부족 등으로 반려된 건수</td></tr>
</table>
<div>바로가기 <span class="btn-chip">완결/조회사본</span> — 관리자 콘솔의 완결 현황</div>
</div>
<!-- Louis Vuitton -->
<div class="svc" style="--bar: var(--lv)">
<h3>🛠️ Louis Vuitton <span class="tag">관리자 · 운영</span></h3>
<div class="sub">시스템관리 — DB초기화 · 코드 · 사용자권한 · 대사 대시보드</div>
<div class="meta-row"><span>논리포트 <b>8099</b></span><span>유형 <b>Web(Tomcat)</b></span><span>코드네임 <b>Leading Operations Unified Info Systems</b></span></div>
<p>
시스템 <b>관리·운영</b> 서비스입니다. DB 초기화, 상태코드·기관·사용자 권한 관리, 헬스(하트비트) 집계,
원장=저널=조회사본 <b>대사(對査) 대시보드</b>를 제공합니다. 스키마는 Flyway로 형상 관리하며 기동 시 자동 적용합니다.
(관리자 콘솔은 ADMIN JWT 필수, 테스트 계정 <code>a / 1</code>)
</p>
<table class="metrics">
<tr><th>카드 지표</th><th>의미</th></tr>
<tr><td>총 거래</td><td>원장에 기록된 전체 거래 건수</td></tr>
<tr><td>최대순번</td><td>부여된 전역순번의 최댓값(<code>MAX(global_seq)</code>)</td></tr>
</table>
<div>바로가기 <span class="btn-chip">관리자 콘솔</span> <span class="btn-chip">로그(Kibana)</span></div>
</div>
<h2 id="status">5. 거래 상태(TxSts) 전이</h2>
<p>한 건의 거래는 아래 상태를 따라 진행됩니다.</p>
<table>
<tr><th>상태</th><th>한글</th><th>담당</th><th>설명</th></tr>
<tr><td><code>RCVD</code></td><td>접수</td><td>Chanel</td><td>전문 접수·검증 통과</td></tr>
<tr><td><code>ACTC</code></td><td>순번</td><td>Sequencer</td><td>전역순번 부여</td></tr>
<tr><td><code>PDNG</code></td><td>대기</td><td>Dior</td><td>원장에 대기 기록(접수 동기화)</td></tr>
<tr><td><code>ACSP</code></td><td>정산반영</td><td>Hermes</td><td>당좌 차/대변 반영</td></tr>
<tr><td><code>ACCC</code></td><td>입금처리완료</td><td>Prada</td><td>과반 확정 · 최종 완결</td></tr>
<tr><td><code>RJCT</code></td><td>반려</td><td>Chanel/Hermes</td><td>XSD·업무규칙 실패 또는 잔액부족·미등록기관</td></tr>
</table>
<div class="note">
정상 흐름: <code>RCVD → ACTC → PDNG → ACSP → ACCC</code>.
검증·잔액 문제 시 <code>RJCT</code>로 종료됩니다.
</div>
<h2 id="ports">6. 포트 일람</h2>
<table>
<tr><th>서비스</th><th>포트</th><th>유형</th></tr>
<tr><td>🔢 Sequencer</td><td><code>8090</code></td><td>Headless JVM (논리)</td></tr>
<tr><td>📥 Chanel</td><td><code>8091</code></td><td>Web (Tomcat)</td></tr>
<tr><td>🔄 Dior</td><td><code>8092</code></td><td>Headless JVM (논리)</td></tr>
<tr><td>💰 Hermes</td><td><code>8093</code></td><td>Headless JVM (논리)</td></tr>
<tr><td>✅ Prada</td><td><code>8094</code></td><td>Headless JVM (논리)</td></tr>
<tr><td>🛡️ Gucci</td><td><code>8095</code></td><td>Web (Tomcat)</td></tr>
<tr><td>🛠️ Louis Vuitton</td><td><code>8099</code></td><td>Web (Tomcat)</td></tr>
<tr><td>운영/관리 콘솔 (Frontend)</td><td><code>5174</code></td><td>React + Vite</td></tr>
<tr><td>PostgreSQL (원장)</td><td><code>5433</code></td><td>RDBMS</td></tr>
<tr><td>Kafka (입구/저널/결과/통보)</td><td><code>9092</code></td><td>KRaft</td></tr>
<tr><td>Elasticsearch / Logstash / Kibana</td><td><code>9200 / 5000 / 5601</code></td><td>ELK 로그</td></tr>
</table>
<h2 id="glossary">7. 용어</h2>
<table>
<tr><th>약어</th><th>의미</th></tr>
<tr><td>RTGS</td><td>Real-Time Gross Settlement — 실시간총액결제</td></tr>
<tr><td>A-A-A</td><td>Active-Active-Active — 3센터 동시 가동</td></tr>
<tr><td>BMI</td><td>Business Message Identifier — 거래식별자 22자리(영업일8+기관4+일련10)</td></tr>
<tr><td>저널</td><td>전역순번이 부여된 처리 명단(Kafka 단일 파티션)</td></tr>
<tr><td>정족수</td><td>Quorum(N/2+1). 3센터=2</td></tr>
<tr><td>선저널</td><td>Write-ahead journal — 잔액연산 전 순번 확정(무손실 승계)</td></tr>
<tr><td>pacs.008 / pacs.002</td><td>ISO 20022 결제의뢰 / 결과통보 전문</td></tr>
<tr><td>TPS</td><td>Transactions Per Second — 초당 처리 건수</td></tr>
</table>
<footer>
RTGS 프로토타입 · 운영/관리 콘솔 서비스 매뉴얼 · 작성 루비(AI) · 검토 양희정 팀장(한국은행 RTGS시스템팀)<br>
상세 설계는 <code>docs/RTGS 아키텍처 설계서.md</code> 및 워크플로우 문서를 참조하세요.
</footer>
</div>
</body>
</html>

View File

@@ -0,0 +1,65 @@
import { useState } from 'react'
import { useAuth, login, changePassword, getUser } from './auth'
import { box } from './util'
/**
* 관리자 로그인 게이트. 인증 전이면 로그인 폼, 초기암호변경 필요 시 변경 폼,
* 그 외에는 children(보호 대상 화면)을 렌더한다. 관리자 패널·포탈이 공유.
*/
export default function LoginGate({ children }: { children: React.ReactNode }) {
const { token, logout } = useAuth()
const [form, setForm] = useState({ username: '', secret: '' })
const [mustChange, setMustChange] = useState(false)
const [newSecret, setNewSecret] = useState('')
const [err, setErr] = useState<string | null>(null)
const [msg, setMsg] = useState<string | null>(null)
const doLogin = async () => {
setErr(null)
const r = await login(form.username, form.secret)
if (!r.ok) { setErr(r.error!); return }
setMustChange(!!r.mustChange) // 성공 시 token이 전역 세팅 → useAuth 재렌더
}
const doChange = async () => {
setErr(null)
if (newSecret.length < 1) { setErr('새 비밀번호를 입력하세요'); return }
const r = await changePassword(getUser(), form.secret, newSecret)
if (!r.ok) { setErr(r.error!); return }
setMsg('비밀번호가 변경되었습니다.'); setMustChange(false)
}
if (!token) {
return (
<section style={{ ...box, maxWidth: 380, margin: '40px auto', background: '#f7fbff' }}>
<h2 style={{ marginTop: 0 }}>🔐 </h2>
<p style={{ color: '#666', fontSize: 13 }}>(LouisVuitton)· <b>ADMIN</b> .</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<input placeholder="아이디" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} style={{ padding: 8 }} />
<input placeholder="비밀번호" type="password" value={form.secret}
onChange={(e) => setForm({ ...form, secret: e.target.value })}
onKeyDown={(e) => { if (e.key === 'Enter') doLogin() }} style={{ padding: 8 }} />
<button onClick={doLogin} style={{ padding: '10px', background: '#2d6cdf', color: '#fff', border: 0, borderRadius: 6, cursor: 'pointer' }}></button>
</div>
{err && <p style={{ color: '#c00', marginTop: 12 }}>{err}</p>}
</section>
)
}
if (mustChange) {
return (
<section style={{ ...box, maxWidth: 380, margin: '40px auto', background: '#fff8f0' }}>
<h2 style={{ marginTop: 0 }}>🔑 </h2>
<p style={{ color: '#666', fontSize: 13 }}> .</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<input placeholder="새 비밀번호" type="password" value={newSecret}
onChange={(e) => setNewSecret(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') doChange() }} style={{ padding: 8 }} />
<button onClick={doChange} style={{ padding: '10px', background: '#c0392b', color: '#fff', border: 0, borderRadius: 6, cursor: 'pointer' }}></button>
<button onClick={logout} style={{ fontSize: 12 }}></button>
</div>
{msg && <p style={{ color: '#1a7f37', marginTop: 12 }}>{msg}</p>}
{err && <p style={{ color: '#c00', marginTop: 12 }}>{err}</p>}
</section>
)
}
return <>{children}</>
}

View File

@@ -1,52 +1,10 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useAuth, jget, jsend } from './auth'
const won = (n: number) => n.toLocaleString('ko-KR') + '원' import LoginGate from './AdminLogin'
function fmtTs(ms?: unknown): string { import { won, box, cell, th, MiniTable, buildPacs008, Row } from './util'
if (typeof ms !== 'number' || !ms) return '-'
const d = new Date(ms); const p = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
}
const box: React.CSSProperties = { border: '1px solid #e2e2e2', borderRadius: 8, padding: 16, marginTop: 20 }
const cell: React.CSSProperties = { padding: '5px 9px', borderBottom: '1px solid #eee', fontSize: 13 }
const th: React.CSSProperties = { ...cell, borderBottom: '2px solid #ccc', textAlign: 'left', background: '#fafafa' }
type Row = Record<string, any>
/** 정식 pacs.008.001.08 전문 생성(테스트 실행용). */
function buildPacs008(bmi: string, from: string, to: string, amt: number): string {
const now = new Date().toISOString().replace(/\.\d+Z$/, '')
return `<?xml version="1.0" encoding="UTF-8"?><Document xmlns="urn:iso:std:iso:20022:tech:xsd:pacs.008.001.08">` +
`<FIToFICstmrCdtTrf><GrpHdr><MsgId>${bmi}</MsgId><CreDtTm>${now}</CreDtTm><NbOfTxs>1</NbOfTxs>` +
`<SttlmInf><SttlmMtd>CLRG</SttlmMtd></SttlmInf></GrpHdr><CdtTrfTxInf><PmtId><EndToEndId>${bmi}</EndToEndId></PmtId>` +
`<IntrBkSttlmAmt Ccy="KRW">${amt}</IntrBkSttlmAmt><ChrgBr>SLEV</ChrgBr>` +
`<Dbtr><Nm>BANK-${from}</Nm></Dbtr><DbtrAcct><Id><Othr><Id>ACC-${from}</Id></Othr></Id></DbtrAcct>` +
`<DbtrAgt><FinInstnId><ClrSysMmbId><MmbId>${from}</MmbId></ClrSysMmbId></FinInstnId></DbtrAgt>` +
`<CdtrAgt><FinInstnId><ClrSysMmbId><MmbId>${to}</MmbId></ClrSysMmbId></FinInstnId></CdtrAgt>` +
`<Cdtr><Nm>BANK-${to}</Nm></Cdtr><CdtrAcct><Id><Othr><Id>ACC-${to}</Id></Othr></Id></CdtrAcct>` +
`</CdtTrfTxInf></FIToFICstmrCdtTrf></Document>`
}
function MiniTable({ title, cols, rows, timeCols = [] }: { title: string; cols: [string, string][]; rows: Row[]; timeCols?: string[] }) {
return (
<div style={{ marginTop: 10 }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>{title} <span style={{ color: '#888', fontSize: 12 }}>({rows.length})</span></div>
<div style={{ overflowX: 'auto' }}>
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
<thead><tr>{cols.map(([, ko]) => <th key={ko} style={th}>{ko}</th>)}</tr></thead>
<tbody>
{rows.map((r, i) => (
<tr key={i}>{cols.map(([k]) => <td key={k} style={{ ...cell, fontFamily: /code|seq|bmi|balance|amount/.test(k) ? 'monospace' : undefined }}>
{timeCols.includes(k) ? fmtTs(r[k]) : String(r[k] ?? '-')}
</td>)}</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
export default function AdminPanel() { export default function AdminPanel() {
const { token, user, logout } = useAuth()
const [summary, setSummary] = useState<any>(null) const [summary, setSummary] = useState<any>(null)
const [transfers, setTransfers] = useState<Row[]>([]) const [transfers, setTransfers] = useState<Row[]>([])
const [journal, setJournal] = useState<Row[]>([]) const [journal, setJournal] = useState<Row[]>([])
@@ -54,92 +12,74 @@ export default function AdminPanel() {
const [insts, setInsts] = useState<Row[]>([]) const [insts, setInsts] = useState<Row[]>([])
const [codes, setCodes] = useState<Row[]>([]) const [codes, setCodes] = useState<Row[]>([])
const [users, setUsers] = useState<Row[]>([]) const [users, setUsers] = useState<Row[]>([])
const [health, setHealth] = useState<Row[]>([]) const [services, setServices] = useState<any>(null)
const [svcLog, setSvcLog] = useState<{ id: string; lines: string[] } | null>(null)
const [busySvc, setBusySvc] = useState<string | null>(null)
const [msg, setMsg] = useState<string | null>(null) const [msg, setMsg] = useState<string | null>(null)
const [err, setErr] = useState<string | null>(null) const [err, setErr] = useState<string | null>(null)
// 인증(A1): Gucci 로그인으로 발급받은 ADMIN JWT
const [token, setToken] = useState<string | null>(() => localStorage.getItem('rtgs_admin_token'))
const [loginUser, setLoginUser] = useState<string>(() => localStorage.getItem('rtgs_admin_user') || '')
const [loginForm, setLoginForm] = useState({ username: '', secret: '' })
const [mustChange, setMustChange] = useState(false)
const [newSecret, setNewSecret] = useState('')
// 폼 상태 // 폼 상태
const [inst, setInst] = useState({ code: '', name: '', balance: 1000000000 }) const [inst, setInst] = useState({ code: '', name: '', balance: 1000000000 })
const [user, setUser] = useState({ username: '', displayName: '', role: 'ORG_S', orgCode: '' }) const [user2, setUser2] = useState({ username: '', displayName: '', role: 'ORG_S', orgCode: '' })
// 테스트 실행(B6) // 테스트 실행(B6)
const [test, setTest] = useState({ bmiStart: '2026071099000000000001', count: 50 }) const [test, setTest] = useState({ bmiStart: '2026071099000000000001', count: 50 })
const [testRun, setTestRun] = useState<{ running: boolean; done: number; sent: number; rcvd: number; rjct: number; err: number } | null>(null) const [testRun, setTestRun] = useState<{ running: boolean; done: number; sent: number; rcvd: number; rjct: number; err: number } | null>(null)
const authHeaders = (json = false): Record<string, string> => {
const h: Record<string, string> = {}
if (json) h['Content-Type'] = 'application/json'
if (token) h['Authorization'] = `Bearer ${token}`
return h
}
const logout = () => { localStorage.removeItem('rtgs_admin_token'); localStorage.removeItem('rtgs_admin_user'); setToken(null); setMustChange(false) }
const check401 = (r: Response) => { if (r.status === 401) { logout(); throw new Error('세션 만료 또는 권한 없음 — 다시 로그인하세요') } }
const j = async (u: string) => { const r = await fetch(u, { headers: authHeaders() }); check401(r); return r.json() }
const post = async (url: string, body?: any, method = 'POST') => {
const r = await fetch(url, { method, headers: authHeaders(!!body), body: body ? JSON.stringify(body) : undefined })
check401(r); return r.json()
}
const doLogin = async () => {
setErr(null)
try {
const resp = await fetch('/gucci/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(loginForm) })
if (!resp.ok) { setErr('로그인 실패 — 아이디/비밀번호를 확인하세요'); return }
const r = await resp.json()
if (r.role !== 'ADMIN') { setErr('관리자(ADMIN) 권한이 필요합니다'); return }
localStorage.setItem('rtgs_admin_token', r.token); localStorage.setItem('rtgs_admin_user', loginForm.username)
setLoginUser(loginForm.username); setToken(r.token); setMustChange(!!r.mustChangePassword)
} catch (e: any) { setErr('로그인 실패: ' + e.message) }
}
const doChangePassword = async () => {
setErr(null)
if (newSecret.length < 1) { setErr('새 비밀번호를 입력하세요'); return }
try {
const resp = await fetch('/gucci/auth/change-password', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: loginUser, oldSecret: loginForm.secret, newSecret }),
})
if (!resp.ok) { setErr('비밀번호 변경 실패'); return }
setMsg('비밀번호가 변경되었습니다.'); setMustChange(false); setNewSecret('')
} catch (e: any) { setErr('변경 실패: ' + e.message) }
}
const loadAll = async () => { const loadAll = async () => {
setErr(null) setErr(null)
try { try {
const [s, t, jn, v, ins, cd, us, hl] = await Promise.all([ const [s, t, jn, v, ins, cd, us] = await Promise.all([
j('/admin/summary'), j('/admin/ledger/transfers?limit=15'), j('/admin/ledger/journal?limit=15'), jget('/admin/summary'), jget('/admin/ledger/transfers?limit=15'), jget('/admin/ledger/journal?limit=15'),
j('/admin/ledger/views?limit=15'), j('/admin/institutions'), j('/admin/status-codes'), j('/admin/users'), j('/admin/health'), jget('/admin/ledger/views?limit=15'), jget('/admin/institutions'), jget('/admin/status-codes'), jget('/admin/users'),
]) ])
setSummary(s); setTransfers(t); setJournal(jn); setViews(v); setInsts(ins); setCodes(cd); setUsers(us); setHealth(hl) setSummary(s); setTransfers(t); setJournal(jn); setViews(v); setInsts(ins); setCodes(cd); setUsers(us)
} catch (e: any) { setErr('관리자 데이터 로드 실패: ' + e.message) } } catch (e: any) { setErr('관리자 데이터 로드 실패: ' + e.message) }
} }
useEffect(() => { if (token && !mustChange) loadAll() }, [token, mustChange]) const loadServices = async () => { try { setServices(await jget('/admin/services')) } catch { /* 폴링 실패 무시 */ } }
useEffect(() => { if (token) loadAll() }, [token])
useEffect(() => {
if (!token) return
loadServices()
const t = setInterval(loadServices, 4000)
return () => clearInterval(t)
}, [token])
// 서비스 제어(기동/중지/재기동)
const svcAction = async (id: string, action: 'start' | 'stop' | 'restart', name: string) => {
if ((action === 'stop' || action === 'restart') && !confirm(`${name} ${action === 'stop' ? '중지' : '재기동'}할까요?`)) return
setBusySvc(id + action); setMsg(null); setErr(null)
try {
const r = await jsend(`/admin/services/${id}/${action}`)
if (r.ok === false) setErr(r.message || '제어 실패'); else setMsg(r.message || '완료')
} catch (e: any) { setErr('제어 실패: ' + e.message) }
finally { setBusySvc(null); setTimeout(loadServices, 900) }
}
const showLog = async (id: string) => {
try { const r = await jget(`/admin/services/${id}/log?lines=150`); setSvcLog({ id, lines: r.lines || [] }) }
catch (e: any) { setErr('로그 조회 실패: ' + e.message) }
}
const ctlBtn = (bg: string, disabled: boolean): React.CSSProperties => ({
padding: '4px 10px', background: disabled ? '#ccc' : bg, color: '#fff', border: 0, borderRadius: 5,
cursor: disabled ? 'default' : 'pointer', fontSize: 12,
})
const doReset = async () => { const doReset = async () => {
if (!confirm('정말 초기화할까요?\n거래/저널/조회사본/원전문을 모두 비우고 계좌 잔액을 각 10억으로 되돌립니다.')) return if (!confirm('정말 초기화할까요?\n거래/저널/조회사본/원전문을 모두 비우고 계좌 잔액을 각 10억으로 되돌립니다.')) return
setMsg(null); setErr(null) setMsg(null); setErr(null)
try { const r = await post('/admin/reset'); setMsg(`초기화 완료: 계좌 ${r.accountsReset}개 리셋, 원장 비움`); await loadAll() } try { const r = await jsend('/admin/reset'); setMsg(`초기화 완료: 계좌 ${r.accountsReset}개 리셋, 원장 비움`); await loadAll() }
catch (e: any) { setErr('초기화 실패: ' + e.message) } catch (e: any) { setErr('초기화 실패: ' + e.message) }
} }
const saveInst = async () => { const saveInst = async () => {
if (!/^[0-9]{4}$/.test(inst.code)) { setErr('기관코드는 숫자 4자리'); return } if (!/^[0-9]{4}$/.test(inst.code)) { setErr('기관코드는 숫자 4자리'); return }
try { await post('/admin/institutions', inst); setMsg(`기관 저장: ${inst.code}`); await loadAll() } catch (e: any) { setErr('' + e.message) } try { await jsend('/admin/institutions', inst); setMsg(`기관 저장: ${inst.code}`); await loadAll() } catch (e: any) { setErr('' + e.message) }
} }
const delInst = async (code: string) => { if (confirm(`기관 ${code} 삭제?`)) { await post('/admin/institutions/' + code, undefined, 'DELETE'); await loadAll() } } const delInst = async (code: string) => { if (confirm(`기관 ${code} 삭제?`)) { await jsend('/admin/institutions/' + code, undefined, 'DELETE'); await loadAll() } }
const saveUser = async () => { const saveUser = async () => {
if (!user.username) { setErr('사용자ID 필수'); return } if (!user2.username) { setErr('사용자ID 필수'); return }
try { await post('/admin/users', { ...user, orgCode: user.orgCode || null }); setMsg(`사용자 저장: ${user.username}`); await loadAll() } catch (e: any) { setErr('' + e.message) } try { await jsend('/admin/users', { ...user2, orgCode: user2.orgCode || null }); setMsg(`사용자 저장: ${user2.username}`); await loadAll() } catch (e: any) { setErr('' + e.message) }
} }
const delUser = async (u: string) => { if (confirm(`사용자 ${u} 삭제?`)) { await post('/admin/users/' + u, undefined, 'DELETE'); await loadAll() } } const delUser = async (u: string) => { if (confirm(`사용자 ${u} 삭제?`)) { await jsend('/admin/users/' + u, undefined, 'DELETE'); await loadAll() } }
// B6: 테스트 이체 대량 생성(송/수신 랜덤, 금액 1,000~1,000,000 랜덤, BMI 시작번호부터 순번 증가) // B6: 테스트 이체 대량 생성(송/수신 랜덤, 금액 1,000~1,000,000 랜덤, BMI 시작번호부터 순번 증가)
const BANKS_FALLBACK = ['1001', '1002', '1003', '1004', '1005', '1006', '1007', '1008', '1009', '1010'] const BANKS_FALLBACK = ['1001', '1002', '1003', '1004', '1005', '1006', '1007', '1008', '1009', '1010']
@@ -177,56 +117,62 @@ export default function AdminPanel() {
const totalOk = summary && summary.totalBalance === (summary.accounts * 1000000000) const totalOk = summary && summary.totalBalance === (summary.accounts * 1000000000)
// --- A1: 로그인 게이트 ---
if (!token) {
return (
<section style={{ ...box, maxWidth: 380, margin: '40px auto', background: '#f7fbff' }}>
<h2 style={{ marginTop: 0 }}>🔐 </h2>
<p style={{ color: '#666', fontSize: 13 }}>(LouisVuitton) <b>ADMIN</b> .<br /> : <code>a / 1</code></p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<input placeholder="아이디" value={loginForm.username} onChange={(e) => setLoginForm({ ...loginForm, username: e.target.value })} style={{ padding: 8 }} />
<input placeholder="비밀번호" type="password" value={loginForm.secret}
onChange={(e) => setLoginForm({ ...loginForm, secret: e.target.value })}
onKeyDown={(e) => { if (e.key === 'Enter') doLogin() }} style={{ padding: 8 }} />
<button onClick={doLogin} style={{ padding: '10px', background: '#2d6cdf', color: '#fff', border: 0, borderRadius: 6, cursor: 'pointer' }}></button>
</div>
{err && <p style={{ color: '#c00', marginTop: 12 }}>{err}</p>}
</section>
)
}
if (mustChange) {
return (
<section style={{ ...box, maxWidth: 380, margin: '40px auto', background: '#fff8f0' }}>
<h2 style={{ marginTop: 0 }}>🔑 </h2>
<p style={{ color: '#666', fontSize: 13 }}> .</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<input placeholder="새 비밀번호" type="password" value={newSecret}
onChange={(e) => setNewSecret(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') doChangePassword() }} style={{ padding: 8 }} />
<button onClick={doChangePassword} style={{ padding: '10px', background: '#c0392b', color: '#fff', border: 0, borderRadius: 6, cursor: 'pointer' }}></button>
<button onClick={logout} style={{ fontSize: 12 }}></button>
</div>
{err && <p style={{ color: '#c00', marginTop: 12 }}>{err}</p>}
</section>
)
}
return ( return (
<LoginGate>
<div> <div>
<div style={{ textAlign: 'right', color: '#666', fontSize: 13, marginTop: 8 }}> <div style={{ textAlign: 'right', color: '#666', fontSize: 13, marginTop: 8 }}>
👤 <b>{loginUser}</b> (ADMIN) <button onClick={logout} style={{ fontSize: 12, marginLeft: 8 }}></button> 👤 <b>{user}</b> (ADMIN) <button onClick={logout} style={{ fontSize: 12, marginLeft: 8 }}></button>
</div> </div>
{/* 서비스 상태(B5 하트비트) */} {/* 서비스 제어 (기동/중지/재기동 · 하트비트 상태 · 4초 갱신) */}
<section style={{ ...box, background: '#f7fff7' }}> <section style={{ ...box, background: '#f7fff7' }}>
<h2 style={{ marginTop: 0 }}>🩺 <span style={{ fontSize: 12, color: '#888' }}>( · )</span></h2> <h2 style={{ marginTop: 0 }}>🖥 <span style={{ fontSize: 12, color: '#888' }}>(·· · · 4 )</span></h2>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}> {services?.controlEnabled === false && <p style={{ color: '#b58900', margin: '4px 0' }}> (rtgs.control.enabled=false) </p>}
{health.length === 0 && <span style={{ color: '#888' }}> </span>} {!services && <span style={{ color: '#888' }}> </span>}
{health.map((h) => ( {services && (
<span key={String(h.service) + h.center} style={{ padding: '4px 10px', borderRadius: 6, fontSize: 13, background: h.status === 'UP' ? '#e6f4ea' : '#fde8e8', border: '1px solid ' + (h.status === 'UP' ? '#8bd3a0' : '#f0a0a0') }}> <table style={{ borderCollapse: 'collapse', width: '100%' }}>
{h.status === 'UP' ? '🟢' : '🔴'} <b>{String(h.service)}</b> <span style={{ color: '#666' }}>({String(h.center)}·pid {String(h.pid)}·{Math.round(Number(h.ageMs) / 1000)}s)</span> <thead><tr>
</span> <th style={th}></th><th style={th}></th><th style={th}></th><th style={th}>PID</th><th style={th}></th>
))} </tr></thead>
</div> <tbody>
{(services.services ?? []).map((s: any) => (
<tr key={s.id}>
<td style={cell}>
{s.status === 'UP' ? '🟢 UP' : '🔴 DOWN'}
{s.status === 'UP' && s.ageMs != null && <span style={{ color: '#888', fontSize: 11 }}> · {Math.round(s.ageMs / 1000)}s</span>}
</td>
<td style={{ ...cell, fontWeight: 600 }}>{s.name}</td>
<td style={{ ...cell, fontFamily: 'monospace' }}>:{s.port}</td>
<td style={{ ...cell, fontFamily: 'monospace', color: '#888' }}>{s.pid ?? '-'}</td>
<td style={cell}>
{s.controllable ? (
<span style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<button disabled={s.status === 'UP' || busySvc !== null} onClick={() => svcAction(s.id, 'start', s.name)} style={ctlBtn('#1a7f37', s.status === 'UP' || busySvc !== null)}></button>
<button disabled={s.status !== 'UP' || busySvc !== null} onClick={() => svcAction(s.id, 'stop', s.name)} style={ctlBtn('#c0392b', s.status !== 'UP' || busySvc !== null)}></button>
<button disabled={s.status !== 'UP' || busySvc !== null} onClick={() => svcAction(s.id, 'restart', s.name)} style={ctlBtn('#2d6cdf', s.status !== 'UP' || busySvc !== null)}></button>
<button disabled={busySvc !== null} onClick={() => showLog(s.id)} style={ctlBtn('#666', busySvc !== null)}></button>
</span>
) : <span style={{ color: '#999', fontSize: 12 }}> ( )</span>}
</td>
</tr>
))}
{(services.infra ?? []).map((f: any) => (
<tr key={f.id} style={{ background: '#fafafa' }}>
<td style={cell}>{f.reachable ? '🟢 UP' : '🔴 DOWN'}</td>
<td style={{ ...cell, fontWeight: 600 }}>{f.name} <span style={{ fontSize: 11, color: '#999' }}>()</span></td>
<td style={{ ...cell, fontFamily: 'monospace' }}>:{f.port}</td>
<td style={cell}>-</td>
<td style={{ ...cell, color: '#999', fontSize: 12 }}> ( )</td>
</tr>
))}
</tbody>
</table>
)}
{svcLog && (
<details open style={{ marginTop: 10 }}>
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>📜 {svcLog.id}.log ( {svcLog.lines.length}) <button onClick={() => setSvcLog(null)} style={{ fontSize: 11, marginLeft: 8 }}></button></summary>
<pre style={{ background: '#0d1117', color: '#c9d1d9', padding: 12, marginTop: 8, maxHeight: 300, overflow: 'auto', fontSize: 12 }}>{svcLog.lines.join('\n') || '(로그 없음)'}</pre>
</details>
)}
</section> </section>
{/* 대사 대시보드 */} {/* 대사 대시보드 */}
@@ -283,12 +229,12 @@ export default function AdminPanel() {
<section style={box}> <section style={box}>
<h2 style={{ marginTop: 0 }}>👤 <span style={{ fontSize: 12, color: '#888' }}>( · ADMIN )</span></h2> <h2 style={{ marginTop: 0 }}>👤 <span style={{ fontSize: 12, color: '#888' }}>( · ADMIN )</span></h2>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'flex-end', marginBottom: 8 }}> <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'flex-end', marginBottom: 8 }}>
<label>ID<br /><input value={user.username} onChange={(e) => setUser({ ...user, username: e.target.value })} style={{ padding: 6, width: 120 }} /></label> <label>ID<br /><input value={user2.username} onChange={(e) => setUser2({ ...user2, username: e.target.value })} style={{ padding: 6, width: 120 }} /></label>
<label><br /><input value={user.displayName} onChange={(e) => setUser({ ...user, displayName: e.target.value })} style={{ padding: 6, width: 140 }} /></label> <label><br /><input value={user2.displayName} onChange={(e) => setUser2({ ...user2, displayName: e.target.value })} style={{ padding: 6, width: 140 }} /></label>
<label><br /><select value={user.role} onChange={(e) => setUser({ ...user, role: e.target.value })} style={{ padding: 6 }}> <label><br /><select value={user2.role} onChange={(e) => setUser2({ ...user2, role: e.target.value })} style={{ padding: 6 }}>
<option value="ADMIN">ADMIN()</option><option value="ORG_S">ORG_S()</option><option value="ORG_R">ORG_R()</option> <option value="ADMIN">ADMIN()</option><option value="ORG_S">ORG_S()</option><option value="ORG_R">ORG_R()</option>
</select></label> </select></label>
<label><br /><input value={user.orgCode} onChange={(e) => setUser({ ...user, orgCode: e.target.value })} placeholder="1001" style={{ padding: 6, width: 90, fontFamily: 'monospace' }} /></label> <label><br /><input value={user2.orgCode} onChange={(e) => setUser2({ ...user2, orgCode: e.target.value })} placeholder="1001" style={{ padding: 6, width: 90, fontFamily: 'monospace' }} /></label>
<button onClick={saveUser} style={{ padding: '8px 16px' }}>/</button> <button onClick={saveUser} style={{ padding: '8px 16px' }}>/</button>
</div> </div>
<table style={{ borderCollapse: 'collapse', width: '100%' }}> <table style={{ borderCollapse: 'collapse', width: '100%' }}>
@@ -322,5 +268,6 @@ export default function AdminPanel() {
{msg && <p style={{ color: '#1a7f37', marginTop: 12 }}>{msg}</p>} {msg && <p style={{ color: '#1a7f37', marginTop: 12 }}>{msg}</p>}
{err && <p style={{ color: '#c00', marginTop: 12 }}>{err}</p>} {err && <p style={{ color: '#c00', marginTop: 12 }}>{err}</p>}
</div> </div>
</LoginGate>
) )
} }

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import AdminPanel from './AdminPanel' import AdminPanel from './AdminPanel'
import Portal from './Portal'
type Account = { code: string; name: string; balance: number } type Account = { code: string; name: string; balance: number }
type Inquiry = Record<string, unknown> type Inquiry = Record<string, unknown>
@@ -26,6 +27,16 @@ const STATUS_KO: Record<string, string> = {
RCVD: '접수', ACTC: '승인', PDNG: '대기', ACSP: '예약', ACCC: '입금처리완료', RJCT: '반려', IN_FLIGHT: '처리중', RCVD: '접수', ACTC: '승인', PDNG: '대기', ACSP: '예약', ACCC: '입금처리완료', RJCT: '반려', IN_FLIGHT: '처리중',
} }
const TIME_FIELDS = new Set(['updatedAt', 'createdAt']) const TIME_FIELDS = new Set(['updatedAt', 'createdAt'])
// 서비스별 처리시각 타임라인(한 거래가 거치는 5개 서비스). inquiry 응답의 epoch(ms) 필드와 매핑.
const TIMELINE_STAGES: { key: string; label: string; svc: string; emoji: string }[] = [
{ key: 'rcvdAt', label: '접수', svc: 'Chanel', emoji: '📥' },
{ key: 'actcAt', label: '순번', svc: 'Sequencer', emoji: '🔢' },
{ key: 'pdngAt', label: '기록', svc: 'Dior', emoji: '🔄' },
{ key: 'acspAt', label: '정산', svc: 'Hermes', emoji: '💰' },
{ key: 'acccAt', label: '완결', svc: 'Prada', emoji: '✅' },
]
// 타임라인으로 별도 표기하므로 상세표에서는 숨김.
const TIMELINE_KEYS = new Set(TIMELINE_STAGES.map((s) => s.key))
const statusColor = (s?: string) => const statusColor = (s?: string) =>
s === 'ACCC' ? '#1a7f37' : s === 'RJCT' ? '#c00' : s ? '#8a6d00' : '#555' s === 'ACCC' ? '#1a7f37' : s === 'RJCT' ? '#c00' : s ? '#8a6d00' : '#555'
@@ -51,8 +62,10 @@ export default function App() {
const [bmi, setBmi] = useState('') const [bmi, setBmi] = useState('')
const [inquiry, setInquiry] = useState<Inquiry | null>(null) const [inquiry, setInquiry] = useState<Inquiry | null>(null)
const [rawXml, setRawXml] = useState<string | null>(null) const [rawXml, setRawXml] = useState<string | null>(null)
const [notifs, setNotifs] = useState<Record<string, unknown>[]>([])
const [msgTab, setMsgTab] = useState<'008' | '002'>('008')
const [view, setView] = useState<'ops' | 'admin'>('ops') const [view, setView] = useState<'portal' | 'ops' | 'admin'>('portal')
const loadAccounts = async () => { const loadAccounts = async () => {
setErr(null) setErr(null)
@@ -109,22 +122,25 @@ export default function App() {
} }
const doInquiry = async () => { const doInquiry = async () => {
setErr(null); setInquiry(null); setRawXml(null) setErr(null); setInquiry(null); setRawXml(null); setNotifs([]); setMsgTab('008')
const key = bmi.trim() const key = bmi.trim()
if (!key) return if (!key) return
try { try {
const [inq, raw] = await Promise.all([ const [inq, raw, nts] = await Promise.all([
fetch('/inquiry/' + encodeURIComponent(key)).then((r) => r.json()), fetch('/inquiry/' + encodeURIComponent(key)).then((r) => r.json()),
fetch('/rawmessage/' + encodeURIComponent(key)).then((r) => r.json()).catch(() => null), fetch('/rawmessage/' + encodeURIComponent(key)).then((r) => r.json()).catch(() => null),
fetch('/notifications/' + encodeURIComponent(key)).then((r) => r.json()).catch(() => []),
]) ])
setInquiry(inq) setInquiry(inq)
setRawXml(raw?.found ? raw.rawXml : null) setRawXml(raw?.found ? raw.rawXml : null)
setNotifs(Array.isArray(nts) ? nts : [])
} catch (e: any) { } catch (e: any) {
setErr('거래 조회 실패: ' + e.message) setErr('거래 조회 실패: ' + e.message)
} }
} }
useEffect(() => { loadAccounts(); loadLabels() }, []) // 계좌·라벨은 운영 콘솔에서만 쓰이므로 해당 탭 진입 시 로드(로그인/포탈 화면에서 불필요한 Chanel 호출·에러 방지)
useEffect(() => { if (view === 'ops') { loadAccounts(); loadLabels() } }, [view])
const box: React.CSSProperties = { border: '1px solid #e2e2e2', borderRadius: 8, padding: 16, marginTop: 20 } const box: React.CSSProperties = { border: '1px solid #e2e2e2', borderRadius: 8, padding: 16, marginTop: 20 }
const cell: React.CSSProperties = { padding: '6px 10px', borderBottom: '1px solid #eee' } const cell: React.CSSProperties = { padding: '6px 10px', borderBottom: '1px solid #eee' }
@@ -140,16 +156,25 @@ export default function App() {
} }
return ( return (
<div style={{ fontFamily: 'system-ui, sans-serif', maxWidth: 900, margin: '2rem auto', padding: '0 1rem' }}> <div style={{ fontFamily: 'system-ui, sans-serif', margin: '1.5rem', padding: '0 0.5rem' }}>
<h1>RTGS <span style={{ fontSize: 14, color: '#888' }}>(DC1 · :5174)</span></h1> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<h1 style={{ margin: 0 }}>RTGS <span style={{ fontSize: 14, color: '#888' }}>(DC1 · :5174)</span></h1>
<a href="/manual.html" target="_blank" rel="noopener noreferrer"
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '8px 14px', border: '1px solid #ccc', borderRadius: 6, background: '#fff', color: '#333', textDecoration: 'none', fontSize: 14, fontWeight: 600 }}>
📖
</a>
</div>
<div style={{ display: 'flex', gap: 8, margin: '12px 0' }}> <div style={{ display: 'flex', gap: 8, margin: '12px 0' }}>
<button onClick={() => setView('portal')}
style={{ padding: '8px 16px', border: '1px solid #ccc', borderRadius: 6, cursor: 'pointer', background: view === 'portal' ? '#0b63c4' : '#fff', color: view === 'portal' ? '#fff' : '#333' }}>🏛 </button>
<button onClick={() => setView('ops')} <button onClick={() => setView('ops')}
style={{ padding: '8px 16px', border: '1px solid #ccc', borderRadius: 6, cursor: 'pointer', background: view === 'ops' ? '#0b63c4' : '#fff', color: view === 'ops' ? '#fff' : '#333' }}> </button> style={{ padding: '8px 16px', border: '1px solid #ccc', borderRadius: 6, cursor: 'pointer', background: view === 'ops' ? '#0b63c4' : '#fff', color: view === 'ops' ? '#fff' : '#333' }}> </button>
<button onClick={() => setView('admin')} <button onClick={() => setView('admin')}
style={{ padding: '8px 16px', border: '1px solid #ccc', borderRadius: 6, cursor: 'pointer', background: view === 'admin' ? '#0b63c4' : '#fff', color: view === 'admin' ? '#fff' : '#333' }}>🛠 (Louis Vuitton)</button> style={{ padding: '8px 16px', border: '1px solid #ccc', borderRadius: 6, cursor: 'pointer', background: view === 'admin' ? '#0b63c4' : '#fff', color: view === 'admin' ? '#fff' : '#333' }}>🛠 (Louis Vuitton)</button>
</div> </div>
{view === 'portal' && <Portal onNavigate={setView} />}
{view === 'admin' && <AdminPanel />} {view === 'admin' && <AdminPanel />}
{view === 'ops' && (<> {view === 'ops' && (<>
@@ -208,7 +233,7 @@ export default function App() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{Object.entries(inquiry).filter(([k]) => k !== 'note').map(([k, v]) => ( {Object.entries(inquiry).filter(([k]) => k !== 'note' && !TIMELINE_KEYS.has(k)).map(([k, v]) => (
<tr key={k}> <tr key={k}>
<td style={{ ...cell, fontWeight: 600 }}>{labels[k] ?? k}</td> <td style={{ ...cell, fontWeight: 600 }}>{labels[k] ?? k}</td>
<td style={{ ...cell, color: '#888', fontFamily: 'monospace' }}>{k}</td> <td style={{ ...cell, color: '#888', fontFamily: 'monospace' }}>{k}</td>
@@ -219,13 +244,69 @@ export default function App() {
</table> </table>
)} )}
{rawXml && ( {/* 서비스별 처리시각 타임라인(접수→순번→기록→정산→완결) */}
<details style={{ marginTop: 12 }} open> {inquiry && !(inquiry as any).note && (
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>📄 ISO20022 pacs.008 </summary> <div style={{ marginTop: 16 }}>
<pre style={{ background: '#f5f5f5', padding: 12, marginTop: 8, overflowX: 'auto', fontSize: 13 }}>{prettyXml(rawXml)}</pre> <div style={{ fontWeight: 600, marginBottom: 8 }}>🕘 </div>
</details> <table style={{ borderCollapse: 'collapse', width: '100%' }}>
<thead>
<tr style={{ borderBottom: '2px solid #ccc', textAlign: 'left' }}>
<th style={{ ...cell, width: 90 }}></th>
<th style={{ ...cell, width: 130 }}></th>
<th style={cell}> (YYYYMMDDHH24MISS)</th>
</tr>
</thead>
<tbody>
{TIMELINE_STAGES.map((s) => {
const v = (inquiry as any)[s.key]
const has = typeof v === 'number' && v > 0
return (
<tr key={s.key} style={{ color: has ? undefined : '#bbb' }}>
<td style={{ ...cell, fontWeight: 600 }}>{s.emoji} {s.label}</td>
<td style={cell}>{s.svc}</td>
<td style={{ ...cell, fontFamily: 'monospace' }}>{has ? fmtTs(v) : '—'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
{/* 관련 전문 탭: pacs.008(원문) / pacs.002(결과통보) */}
{inquiry && !(inquiry as any).note && (
<div style={{ marginTop: 16 }}>
<div style={{ display: 'flex', gap: 6, borderBottom: '1px solid #ddd' }}>
{([['008', '📄 pacs.008 (원문)'], ['002', `📨 pacs.002 (결과통보${notifs.length ? ` ${notifs.length}` : ''})`]] as const).map(([id, label]) => (
<button key={id} onClick={() => setMsgTab(id)}
style={{ padding: '8px 14px', border: 0, borderBottom: msgTab === id ? '3px solid #0b63c4' : '3px solid transparent',
background: 'transparent', cursor: 'pointer', fontWeight: msgTab === id ? 700 : 400, color: msgTab === id ? '#0b63c4' : '#555' }}>
{label}
</button>
))}
</div>
{msgTab === '008' && (rawXml
? <pre style={{ background: '#f5f5f5', padding: 12, marginTop: 8, overflowX: 'auto', fontSize: 13 }}>{prettyXml(rawXml)}</pre>
: <p style={{ color: '#888', marginTop: 8 }}>( pacs.008 )</p>)}
{msgTab === '002' && (notifs.length > 0
? notifs.map((n, i) => (
<div key={i} style={{ marginTop: 10, border: '1px solid #e2e2e2', borderRadius: 6, overflow: 'hidden' }}>
<div style={{ background: '#f4f7fb', padding: '8px 12px', fontSize: 13, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<span> <b>{String(n.toOrg ?? '-')}</b></span>
<span> <b>{n.role === 'APPLICANT' ? '신청기관' : n.role === 'BENEFICIARY' ? '수취기관' : String(n.role ?? '-')}</b></span>
<span> <b style={{ color: statusColor(String(n.finalStatus)) }}>{String(n.finalStatus ?? '-')}</b></span>
<span> {n.delivered ? '✅ 완료' : '⏳ 대기'}</span>
</div>
{n.pacs002
? <pre style={{ background: '#f5f5f5', padding: 12, margin: 0, overflowX: 'auto', fontSize: 13 }}>{prettyXml(String(n.pacs002))}</pre>
: <p style={{ color: '#888', padding: 12, margin: 0 }}>(pacs.002 )</p>}
</div>
))
: <p style={{ color: '#888', marginTop: 8 }}>( )</p>)}
</div>
)} )}
{inquiry && rawXml === null && <p style={{ color: '#888', marginTop: 8 }}>( )</p>}
</section> </section>
{/* 계좌 잔액 */} {/* 계좌 잔액 */}

211
frontend/src/Portal.tsx Normal file
View File

@@ -0,0 +1,211 @@
import { useEffect, useRef, useState } from 'react'
import { useAuth, jget } from './auth'
import LoginGate from './AdminLogin'
import { box } from './util'
type View = 'portal' | 'ops' | 'admin'
type Health = { service: string; center: string; pid: number; lastSeen: number; ageMs: number; status: string }
type SvcMetric = { service: string; port: number; reachable: boolean; metrics: Record<string, any> }
/** 6개 주 서비스 카드 메타(순번기는 흐름도 진입 노드로만 표기). 이름은 영문, 역할 설명은 한글 유지. */
const SERVICES: { id: string; name: string; role: string; emoji: string; color: string }[] = [
{ id: 'gucci', name: 'Gucci', role: '게이트웨이 · 기관 인증', emoji: '🛡️', color: '#6b4fbb' },
{ id: 'chanel', name: 'Chanel', role: '접수', emoji: '📥', color: '#0b63c4' },
{ id: 'hermes', name: 'Hermes', role: '결제 · 원장 엔진', emoji: '💰', color: '#c77d0a' },
{ id: 'dior', name: 'Dior', role: '센터간 접수 동기화', emoji: '🔄', color: '#8a4fd0' },
{ id: 'prada', name: 'Prada', role: '센터간 처리 동기화 · 완결', emoji: '✅', color: '#b8860b' },
{ id: 'louisvuitton', name: 'Louis Vuitton', role: '관리자 · 운영', emoji: '🛠️', color: '#1a7f37' },
]
/** 업무 흐름도 노드(순번기→관문→접수→동기화→결제→처리동기화→통보). 서비스명 영문, 단계 설명 한글. */
const FLOW: { id: string; label: string }[] = [
{ id: 'sequencer', label: 'Sequencer' }, { id: 'gucci', label: 'Gucci(관문)' }, { id: 'chanel', label: 'Chanel(접수)' },
{ id: 'dior', label: 'Dior(동기화)' }, { id: 'hermes', label: 'Hermes(결제)' }, { id: 'prada', label: 'Prada(처리동기화)' },
{ id: 'chanel', label: '결과통보' },
]
const ALL_IDS = ['sequencer', 'gucci', 'chanel', 'dior', 'hermes', 'prada', 'louisvuitton']
const stColor = (s: string) => (s === 'UP' ? '#1a7f37' : s === 'STALE' ? '#b58900' : '#c0392b')
const stDot = (s: string) => (s === 'UP' ? '🟢' : s === 'STALE' ? '🟡' : '🔴')
export default function Portal({ onNavigate }: { onNavigate?: (v: View) => void }) {
return <LoginGate><PortalInner onNavigate={onNavigate} /></LoginGate>
}
function PortalInner({ onNavigate }: { onNavigate?: (v: View) => void }) {
const { token } = useAuth()
const [health, setHealth] = useState<Health[]>([])
const [metrics, setMetrics] = useState<SvcMetric[]>([])
const [summary, setSummary] = useState<any>(null)
const [core, setCore] = useState<string>('?') // Gucci가 본 코어(Chanel) 상태
const [err, setErr] = useState<string | null>(null)
const [auto, setAuto] = useState(true)
const [at, setAt] = useState<number>(0)
const prevTx = useRef<{ n: number; at: number } | null>(null)
const [tps, setTps] = useState(0)
const load = async () => {
setErr(null)
try {
const [h, m, s, c] = await Promise.all([
jget('/admin/health'),
jget('/admin/metrics/summary'),
jget('/admin/summary'),
jget('/gucci/health/centers').catch(() => null),
])
setHealth(h); setMetrics(m?.services ?? []); setSummary(s)
setCore(c?.centers?.[0]?.['core(chanel)'] ?? '?')
const now = Date.now()
const txn = Number(s?.transfers ?? 0)
if (prevTx.current) {
const dt = (now - prevTx.current.at) / 1000
if (dt > 0) setTps(Math.max(0, Math.round(((txn - prevTx.current.n) / dt) * 10) / 10))
}
prevTx.current = { n: txn, at: now }
setAt(now)
} catch (e: any) { setErr('포탈 데이터 로드 실패: ' + e.message) }
}
useEffect(() => { if (token) load() }, [token])
useEffect(() => {
if (!auto || !token) return
const t = setInterval(load, 5000)
return () => clearInterval(t)
}, [auto, token])
const hMap: Record<string, Health> = Object.fromEntries(health.map((h) => [h.service, h]))
const mMap: Record<string, SvcMetric> = Object.fromEntries(metrics.map((m) => [m.service, m]))
const statusOf = (id: string): string => {
const h = hMap[id]
if (h) return h.status
if (mMap[id]?.reachable) return 'UP'
return 'DOWN'
}
const upCount = ALL_IDS.filter((id) => statusOf(id) === 'UP').length
/** transfer 상태별 건수(DB 집계 /admin/summary.byStatus). 초기화 시 자연히 0. */
const statusCount = (st: string): number => {
const row = (summary?.byStatus ?? []).find((r: any) => r.status === st)
return row ? Number(row.cnt) : 0
}
/** 서비스별 지표 라인. 처리 건수는 DB 집계 기반(초기화 반영), 성능/자원은 런타임 지표. */
const metricLines = (id: string): { label: string; value: string }[] => {
const m = mMap[id]?.metrics ?? {}
const acccN = statusCount('ACCC')
switch (id) {
case 'gucci':
return [{ label: '코어(샤넬)', value: core }, { label: '등록기관', value: String(summary?.accounts ?? '-') }]
case 'chanel':
return [{ label: '누적 접수', value: String(summary?.transfers ?? '-') }, { label: '접수율', value: `${tps} 건/s` }]
case 'hermes':
return [
{ label: '결제완료(ACCC)', value: String(acccN) },
{ label: '평균 처리', value: `${m.settleMeanMs ?? 0} ms` },
{ label: '미결(대기)', value: String(Math.max(0, Number(summary?.transfers ?? 0) - acccN - statusCount('RJCT'))) },
]
case 'prada':
return [{ label: '완결(ACCC)', value: String(acccN) }, { label: '반려(RJCT)', value: String(statusCount('RJCT')) }]
case 'dior':
return [{ label: '메모리', value: m.memUsedMb != null ? `${m.memUsedMb} MB` : '-' }, { label: '가동', value: m.uptimeSec != null ? `${m.uptimeSec}s` : '-' }]
case 'louisvuitton':
return [{ label: '총 거래', value: String(summary?.transfers ?? '-') }, { label: '최대순번', value: String(summary?.maxSeq ?? '-') }]
default:
return []
}
}
/** 서비스별 바로가기 버튼. */
const actions = (id: string): { label: string; onClick: () => void }[] => {
switch (id) {
case 'gucci':
return [{ label: '감사로그(Kibana)', onClick: () => window.open('http://localhost:5601', '_blank') }]
case 'chanel':
return [{ label: '이체 신청/조회', onClick: () => onNavigate?.('ops') }]
case 'hermes':
return [{ label: '원장 보기', onClick: () => onNavigate?.('admin') }]
case 'dior':
return [{ label: '원장 보기', onClick: () => onNavigate?.('admin') }]
case 'prada':
return [{ label: '완결/조회사본', onClick: () => onNavigate?.('admin') }]
case 'louisvuitton':
return [
{ label: '관리자 콘솔', onClick: () => onNavigate?.('admin') },
{ label: '로그(Kibana)', onClick: () => window.open('http://localhost:5601', '_blank') },
]
default:
return []
}
}
const btn: React.CSSProperties = { padding: '6px 12px', border: '1px solid #ccc', borderRadius: 6, cursor: 'pointer', background: '#fff', fontSize: 13 }
return (
<div>
{/* 상단 바 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap', marginTop: 8, padding: '10px 14px', background: '#f4f7fb', borderRadius: 8 }}>
<b style={{ fontSize: 15 }}>🏛 RTGS </b>
<span style={{ color: '#666' }}>DC1</span>
<span style={{ color: stColor(upCount === ALL_IDS.length ? 'UP' : upCount === 0 ? 'DOWN' : 'STALE'), fontWeight: 600 }}>
{upCount}/{ALL_IDS.length} UP
</span>
<button onClick={load} style={btn}></button>
<label style={{ fontSize: 13, color: '#444' }}>
<input type="checkbox" checked={auto} onChange={(e) => setAuto(e.target.checked)} /> 5s
</label>
{at > 0 && <span style={{ color: '#999', fontSize: 12 }}> {new Date(at).toLocaleTimeString('ko-KR')}</span>}
</div>
{/* 업무 흐름도 — 창 폭에 맞춰 결과통보까지 한 화면에(가로 스크롤 없음). */}
<section style={{ ...box }}>
<div style={{ fontWeight: 600, marginBottom: 10 }}> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, width: '100%' }}>
{FLOW.map((n, i) => {
const s = statusOf(n.id)
return (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 4, flex: 1, minWidth: 0 }}>
<div style={{ flex: 1, minWidth: 0, padding: '8px 6px', borderRadius: 8, border: `2px solid ${stColor(s)}`, background: '#fff', textAlign: 'center' }}>
<div style={{ fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{stDot(s)} {n.label}</div>
</div>
{i < FLOW.length - 1 && <span style={{ color: '#999', fontSize: 18, flex: '0 0 auto' }}></span>}
</div>
)
})}
</div>
<div style={{ color: '#888', fontSize: 12, marginTop: 8 }}>(Sequencer) (Gucci) (Chanel) / (Dior) ·(Hermes) ·(Prada) (Chanel)</div>
</section>
{/* 서비스 카드 6장 */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 14, marginTop: 20 }}>
{SERVICES.map((svc) => {
const s = statusOf(svc.id)
const h = hMap[svc.id]
return (
<div key={svc.id} style={{ border: `1px solid #e2e2e2`, borderTop: `4px solid ${svc.color}`, borderRadius: 8, padding: 14 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ fontSize: 16, fontWeight: 700 }}>{svc.emoji} {svc.name}</div>
<span style={{ fontSize: 12, fontWeight: 600, color: stColor(s) }}>{stDot(s)} {s}</span>
</div>
<div style={{ color: '#666', fontSize: 13, margin: '2px 0 6px' }}>{svc.role}</div>
<div style={{ color: '#999', fontSize: 11, fontFamily: 'monospace' }}>
:{mMap[svc.id]?.port ?? '-'}{h ? ` · pid ${h.pid} · ${Math.round(h.ageMs / 1000)}s` : ' · 하트비트 없음'}
</div>
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 3 }}>
{metricLines(svc.id).map((ml, i) => (
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13 }}>
<span style={{ color: '#777' }}>{ml.label}</span><b style={{ fontFamily: 'monospace' }}>{ml.value}</b>
</div>
))}
</div>
<div style={{ marginTop: 12, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{actions(svc.id).map((a, i) => <button key={i} onClick={a.onClick} style={btn}>{a.label}</button>)}
</div>
</div>
)
})}
</div>
{err && <p style={{ color: '#c00', marginTop: 16 }}>{err}</p>}
</div>
)
}

76
frontend/src/auth.ts Normal file
View File

@@ -0,0 +1,76 @@
// 관리자(ADMIN JWT) 인증 공용 모듈 — 관리자 패널·포탈이 공유.
// 토큰은 Gucci(/gucci/auth/login)에서 발급받아 localStorage에 보관하고, /admin/* 호출에 Bearer로 첨부한다.
import { useEffect, useState } from 'react'
const TOKEN_KEY = 'rtgs_admin_token'
const USER_KEY = 'rtgs_admin_user'
type Listener = () => void
const listeners = new Set<Listener>()
const emit = () => listeners.forEach((l) => l())
export const getToken = () => localStorage.getItem(TOKEN_KEY)
export const getUser = () => localStorage.getItem(USER_KEY) || ''
export function setSession(token: string, user: string) {
localStorage.setItem(TOKEN_KEY, token); localStorage.setItem(USER_KEY, user); emit()
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY); emit()
}
export function authHeader(json = false): Record<string, string> {
const h: Record<string, string> = {}
if (json) h['Content-Type'] = 'application/json'
const t = getToken(); if (t) h['Authorization'] = `Bearer ${t}`
return h
}
/** 401이면 토큰을 비우고 예외. */
export async function authedFetch(url: string, init: RequestInit = {}): Promise<Response> {
const r = await fetch(url, init)
if (r.status === 401) { clearToken(); throw new Error('세션 만료 또는 권한 없음 — 다시 로그인하세요') }
return r
}
export async function jget(url: string): Promise<any> {
const r = await authedFetch(url, { headers: authHeader() }); return r.json()
}
export async function jsend(url: string, body?: any, method = 'POST'): Promise<any> {
const r = await authedFetch(url, { method, headers: authHeader(!!body), body: body ? JSON.stringify(body) : undefined })
return r.json()
}
/** 로그인 → ADMIN 검증 → 세션 저장. 성공 시 {ok:true, mustChange}. */
export async function login(username: string, secret: string): Promise<{ ok: boolean; mustChange?: boolean; error?: string }> {
try {
const resp = await fetch('/gucci/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, secret }),
})
if (!resp.ok) return { ok: false, error: '로그인 실패 — 아이디/비밀번호를 확인하세요' }
const r = await resp.json()
if (r.role !== 'ADMIN') return { ok: false, error: '관리자(ADMIN) 권한이 필요합니다' }
setSession(r.token, username)
return { ok: true, mustChange: !!r.mustChangePassword }
} catch (e: any) { return { ok: false, error: '로그인 실패: ' + e.message } }
}
export async function changePassword(username: string, oldSecret: string, newSecret: string): Promise<{ ok: boolean; error?: string }> {
try {
const resp = await fetch('/gucci/auth/change-password', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, oldSecret, newSecret }),
})
if (!resp.ok) return { ok: false, error: '비밀번호 변경 실패' }
return { ok: true }
} catch (e: any) { return { ok: false, error: '변경 실패: ' + e.message } }
}
/** 토큰 상태를 구독하는 훅(여러 컴포넌트 동기화). */
export function useAuth() {
const [token, setToken] = useState<string | null>(() => getToken())
useEffect(() => {
const l = () => setToken(getToken())
listeners.add(l)
return () => { listeners.delete(l) }
}, [])
return { token, user: getUser(), logout: clearToken }
}

50
frontend/src/util.tsx Normal file
View File

@@ -0,0 +1,50 @@
// 운영 콘솔/관리자/포탈 공용 포맷·스타일·테이블·전문생성 유틸.
export const won = (n: number) => n.toLocaleString('ko-KR') + '원'
/** epoch(ms) -> YYYYMMDDHH24MISS (14자리). */
export function fmtTs(ms?: unknown): string {
if (typeof ms !== 'number' || !ms) return '-'
const d = new Date(ms); const p = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
}
export const box: React.CSSProperties = { border: '1px solid #e2e2e2', borderRadius: 8, padding: 16, marginTop: 20 }
export const cell: React.CSSProperties = { padding: '5px 9px', borderBottom: '1px solid #eee', fontSize: 13 }
export const th: React.CSSProperties = { ...cell, borderBottom: '2px solid #ccc', textAlign: 'left', background: '#fafafa' }
export type Row = Record<string, any>
/** 정식 pacs.008.001.08 전문 생성(테스트 실행용). */
export function buildPacs008(bmi: string, from: string, to: string, amt: number): string {
const now = new Date().toISOString().replace(/\.\d+Z$/, '')
return `<?xml version="1.0" encoding="UTF-8"?><Document xmlns="urn:iso:std:iso:20022:tech:xsd:pacs.008.001.08">` +
`<FIToFICstmrCdtTrf><GrpHdr><MsgId>${bmi}</MsgId><CreDtTm>${now}</CreDtTm><NbOfTxs>1</NbOfTxs>` +
`<SttlmInf><SttlmMtd>CLRG</SttlmMtd></SttlmInf></GrpHdr><CdtTrfTxInf><PmtId><EndToEndId>${bmi}</EndToEndId></PmtId>` +
`<IntrBkSttlmAmt Ccy="KRW">${amt}</IntrBkSttlmAmt><ChrgBr>SLEV</ChrgBr>` +
`<Dbtr><Nm>BANK-${from}</Nm></Dbtr><DbtrAcct><Id><Othr><Id>ACC-${from}</Id></Othr></Id></DbtrAcct>` +
`<DbtrAgt><FinInstnId><ClrSysMmbId><MmbId>${from}</MmbId></ClrSysMmbId></FinInstnId></DbtrAgt>` +
`<CdtrAgt><FinInstnId><ClrSysMmbId><MmbId>${to}</MmbId></ClrSysMmbId></FinInstnId></CdtrAgt>` +
`<Cdtr><Nm>BANK-${to}</Nm></Cdtr><CdtrAcct><Id><Othr><Id>ACC-${to}</Id></Othr></Id></CdtrAcct>` +
`</CdtTrfTxInf></FIToFICstmrCdtTrf></Document>`
}
export function MiniTable({ title, cols, rows, timeCols = [] }: { title: string; cols: [string, string][]; rows: Row[]; timeCols?: string[] }) {
return (
<div style={{ marginTop: 10 }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>{title} <span style={{ color: '#888', fontSize: 12 }}>({rows.length})</span></div>
<div style={{ overflowX: 'auto' }}>
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
<thead><tr>{cols.map(([, ko]) => <th key={ko} style={th}>{ko}</th>)}</tr></thead>
<tbody>
{rows.map((r, i) => (
<tr key={i}>{cols.map(([k]) => <td key={k} style={{ ...cell, fontFamily: /code|seq|bmi|balance|amount/.test(k) ? 'monospace' : undefined }}>
{timeCols.includes(k) ? fmtTs(r[k]) : String(r[k] ?? '-')}
</td>)}</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -12,8 +12,10 @@ export default defineConfig({
'/pay': { target: 'http://localhost:8091', changeOrigin: true }, '/pay': { target: 'http://localhost:8091', changeOrigin: true },
'/meta': { target: 'http://localhost:8091', changeOrigin: true }, '/meta': { target: 'http://localhost:8091', changeOrigin: true },
'/rawmessage': { target: 'http://localhost:8091', changeOrigin: true }, '/rawmessage': { target: 'http://localhost:8091', changeOrigin: true },
'/notifications': { target: 'http://localhost:8091', changeOrigin: true },
'/ledger': { target: 'http://localhost:8091', changeOrigin: true }, '/ledger': { target: 'http://localhost:8091', changeOrigin: true },
'/admin': { target: 'http://localhost:8099', changeOrigin: true }, '/admin': { target: 'http://localhost:8099', changeOrigin: true },
'/console': { target: 'http://localhost:8099', changeOrigin: true },
'/gucci': { target: 'http://localhost:8095', changeOrigin: true }, '/gucci': { target: 'http://localhost:8095', changeOrigin: true },
}, },
}, },