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:
65
frontend/src/AdminLogin.tsx
Normal file
65
frontend/src/AdminLogin.tsx
Normal 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}</>
|
||||
}
|
||||
@@ -1,52 +1,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const won = (n: number) => n.toLocaleString('ko-KR') + '원'
|
||||
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())}`
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
import { useAuth, jget, jsend } from './auth'
|
||||
import LoginGate from './AdminLogin'
|
||||
import { won, box, cell, th, MiniTable, buildPacs008, Row } from './util'
|
||||
|
||||
export default function AdminPanel() {
|
||||
const { token, user, logout } = useAuth()
|
||||
const [summary, setSummary] = useState<any>(null)
|
||||
const [transfers, setTransfers] = useState<Row[]>([])
|
||||
const [journal, setJournal] = useState<Row[]>([])
|
||||
@@ -54,92 +12,74 @@ export default function AdminPanel() {
|
||||
const [insts, setInsts] = useState<Row[]>([])
|
||||
const [codes, setCodes] = 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 [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 [user, setUser] = useState({ username: '', displayName: '', role: 'ORG_S', orgCode: '' })
|
||||
const [user2, setUser2] = useState({ username: '', displayName: '', role: 'ORG_S', orgCode: '' })
|
||||
|
||||
// 테스트 실행(B6)
|
||||
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 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 () => {
|
||||
setErr(null)
|
||||
try {
|
||||
const [s, t, jn, v, ins, cd, us, hl] = await Promise.all([
|
||||
j('/admin/summary'), j('/admin/ledger/transfers?limit=15'), j('/admin/ledger/journal?limit=15'),
|
||||
j('/admin/ledger/views?limit=15'), j('/admin/institutions'), j('/admin/status-codes'), j('/admin/users'), j('/admin/health'),
|
||||
const [s, t, jn, v, ins, cd, us] = await Promise.all([
|
||||
jget('/admin/summary'), jget('/admin/ledger/transfers?limit=15'), jget('/admin/ledger/journal?limit=15'),
|
||||
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) }
|
||||
}
|
||||
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 () => {
|
||||
if (!confirm('정말 초기화할까요?\n거래/저널/조회사본/원전문을 모두 비우고 계좌 잔액을 각 10억으로 되돌립니다.')) return
|
||||
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) }
|
||||
}
|
||||
const saveInst = async () => {
|
||||
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 () => {
|
||||
if (!user.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) }
|
||||
if (!user2.username) { setErr('사용자ID 필수'); return }
|
||||
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 시작번호부터 순번 증가)
|
||||
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)
|
||||
|
||||
// --- 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 (
|
||||
<LoginGate>
|
||||
<div>
|
||||
<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>
|
||||
{/* 서비스 상태(B5 하트비트) */}
|
||||
{/* 서비스 제어 (기동/중지/재기동 · 하트비트 상태 · 4초 갱신) */}
|
||||
<section style={{ ...box, background: '#f7fff7' }}>
|
||||
<h2 style={{ marginTop: 0 }}>🩺 서비스 상태 <span style={{ fontSize: 12, color: '#888' }}>(하트비트 기반 · 헤드리스 포함)</span></h2>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
{health.length === 0 && <span style={{ color: '#888' }}>하트비트 수집 대기…</span>}
|
||||
{health.map((h) => (
|
||||
<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') }}>
|
||||
{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>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<h2 style={{ marginTop: 0 }}>🖥️ 서비스 제어 <span style={{ fontSize: 12, color: '#888' }}>(기동·중지·재기동 · 하트비트 기반 · 4초 자동 갱신)</span></h2>
|
||||
{services?.controlEnabled === false && <p style={{ color: '#b58900', margin: '4px 0' }}>⚠ 제어 비활성화(rtgs.control.enabled=false) — 상태 조회만 가능</p>}
|
||||
{!services && <span style={{ color: '#888' }}>상태 수집 대기…</span>}
|
||||
{services && (
|
||||
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
|
||||
<thead><tr>
|
||||
<th style={th}>상태</th><th style={th}>서비스</th><th style={th}>포트</th><th style={th}>PID</th><th style={th}>제어</th>
|
||||
</tr></thead>
|
||||
<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>
|
||||
|
||||
{/* 대사 대시보드 */}
|
||||
@@ -283,12 +229,12 @@ export default function AdminPanel() {
|
||||
<section style={box}>
|
||||
<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 }}>
|
||||
<label>사용자ID<br /><input value={user.username} onChange={(e) => setUser({ ...user, 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 /><select value={user.role} onChange={(e) => setUser({ ...user, role: e.target.value })} style={{ padding: 6 }}>
|
||||
<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={user2.displayName} onChange={(e) => setUser2({ ...user2, displayName: e.target.value })} style={{ padding: 6, width: 140 }} /></label>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
|
||||
@@ -322,5 +268,6 @@ export default function AdminPanel() {
|
||||
{msg && <p style={{ color: '#1a7f37', marginTop: 12 }}>{msg}</p>}
|
||||
{err && <p style={{ color: '#c00', marginTop: 12 }}>{err}</p>}
|
||||
</div>
|
||||
</LoginGate>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import AdminPanel from './AdminPanel'
|
||||
import Portal from './Portal'
|
||||
|
||||
type Account = { code: string; name: string; balance: number }
|
||||
type Inquiry = Record<string, unknown>
|
||||
@@ -26,6 +27,16 @@ const STATUS_KO: Record<string, string> = {
|
||||
RCVD: '접수', ACTC: '승인', PDNG: '대기', ACSP: '예약', ACCC: '입금처리완료', RJCT: '반려', IN_FLIGHT: '처리중',
|
||||
}
|
||||
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) =>
|
||||
s === 'ACCC' ? '#1a7f37' : s === 'RJCT' ? '#c00' : s ? '#8a6d00' : '#555'
|
||||
|
||||
@@ -51,8 +62,10 @@ export default function App() {
|
||||
const [bmi, setBmi] = useState('')
|
||||
const [inquiry, setInquiry] = useState<Inquiry | 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 () => {
|
||||
setErr(null)
|
||||
@@ -109,22 +122,25 @@ export default function App() {
|
||||
}
|
||||
|
||||
const doInquiry = async () => {
|
||||
setErr(null); setInquiry(null); setRawXml(null)
|
||||
setErr(null); setInquiry(null); setRawXml(null); setNotifs([]); setMsgTab('008')
|
||||
const key = bmi.trim()
|
||||
if (!key) return
|
||||
try {
|
||||
const [inq, raw] = await Promise.all([
|
||||
const [inq, raw, nts] = await Promise.all([
|
||||
fetch('/inquiry/' + encodeURIComponent(key)).then((r) => r.json()),
|
||||
fetch('/rawmessage/' + encodeURIComponent(key)).then((r) => r.json()).catch(() => null),
|
||||
fetch('/notifications/' + encodeURIComponent(key)).then((r) => r.json()).catch(() => []),
|
||||
])
|
||||
setInquiry(inq)
|
||||
setRawXml(raw?.found ? raw.rawXml : null)
|
||||
setNotifs(Array.isArray(nts) ? nts : [])
|
||||
} catch (e: any) {
|
||||
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 cell: React.CSSProperties = { padding: '6px 10px', borderBottom: '1px solid #eee' }
|
||||
@@ -140,16 +156,25 @@ export default function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: 'system-ui, sans-serif', maxWidth: 900, margin: '2rem auto', padding: '0 1rem' }}>
|
||||
<h1>RTGS 콘솔 <span style={{ fontSize: 14, color: '#888' }}>(DC1 · :5174)</span></h1>
|
||||
<div style={{ fontFamily: 'system-ui, sans-serif', margin: '1.5rem', padding: '0 0.5rem' }}>
|
||||
<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' }}>
|
||||
<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')}
|
||||
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')}
|
||||
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>
|
||||
|
||||
{view === 'portal' && <Portal onNavigate={setView} />}
|
||||
{view === 'admin' && <AdminPanel />}
|
||||
|
||||
{view === 'ops' && (<>
|
||||
@@ -208,7 +233,7 @@ export default function App() {
|
||||
</tr>
|
||||
</thead>
|
||||
<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}>
|
||||
<td style={{ ...cell, fontWeight: 600 }}>{labels[k] ?? k}</td>
|
||||
<td style={{ ...cell, color: '#888', fontFamily: 'monospace' }}>{k}</td>
|
||||
@@ -219,13 +244,69 @@ export default function App() {
|
||||
</table>
|
||||
)}
|
||||
|
||||
{rawXml && (
|
||||
<details style={{ marginTop: 12 }} open>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>📄 원문 ISO20022 pacs.008 전문</summary>
|
||||
<pre style={{ background: '#f5f5f5', padding: 12, marginTop: 8, overflowX: 'auto', fontSize: 13 }}>{prettyXml(rawXml)}</pre>
|
||||
</details>
|
||||
{/* 서비스별 처리시각 타임라인(접수→순번→기록→정산→완결) */}
|
||||
{inquiry && !(inquiry as any).note && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>🕘 서비스별 처리시각</div>
|
||||
<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>
|
||||
|
||||
{/* 계좌 잔액 */}
|
||||
|
||||
211
frontend/src/Portal.tsx
Normal file
211
frontend/src/Portal.tsx
Normal 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
76
frontend/src/auth.ts
Normal 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
50
frontend/src/util.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user