fix: hotfix 동기화 — history/detail candidate_count 안전 처리 #225

병합
htlee hotfix/sync-candidate-count 에서 develop 로 69 commits 를 머지했습니다 2026-04-04 11:05:43 +09:00
7개의 변경된 파일266개의 추가작업 그리고 188개의 파일을 삭제
Showing only changes of commit 5ff400f982 - Show all commits

파일 보기

@ -1317,6 +1317,48 @@
text-decoration: none;
}
/* ═══ 범용 팝업 스타일 ═══ */
.popup-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 4px 4px 0 0;
margin: -10px -10px 8px -10px;
color: #fff;
font-weight: 700;
font-size: 13px;
}
.popup-body {
font-family: 'Courier New', monospace;
font-size: 12px;
}
.popup-body-sm {
font-family: 'Courier New', monospace;
font-size: 11px;
min-width: 220px;
}
.popup-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3px 12px;
font-size: 11px;
}
.popup-label {
color: var(--kcg-muted, #888);
}
.popup-desc {
margin-top: 6px;
font-size: 11px;
color: var(--kcg-text-secondary, #ccc);
line-height: 1.4;
}
.ship-popup-link:hover {
text-decoration: underline;
}
@ -2234,3 +2276,147 @@
color: var(--kcg-border-light);
margin-right: 6px;
}
/* ===== CollectorMonitor ===== */
.collector-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 10000;
background: var(--kcg-panel-bg, rgba(15, 23, 42, 0.95));
border: 1px solid var(--kcg-border, rgba(100, 116, 139, 0.3));
border-radius: 12px;
padding: 20px;
min-width: 600px;
max-width: 800px;
max-height: 80vh;
overflow: auto;
color: var(--kcg-text, #e2e8f0);
font-family: monospace;
font-size: 13px;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5);
}
.collector-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.collector-title {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.collector-header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.collector-server-time {
font-size: 11px;
opacity: 0.6;
}
.collector-refresh-btn {
background: none;
border: 1px solid var(--kcg-border, rgba(100, 116, 139, 0.3));
color: var(--kcg-text, #e2e8f0);
border-radius: 4px;
padding: 2px 8px;
cursor: pointer;
font-size: 12px;
}
.collector-close-btn {
background: none;
border: none;
color: var(--kcg-text, #e2e8f0);
cursor: pointer;
font-size: 18px;
line-height: 1;
padding: 0 4px;
}
.collector-error {
padding: 8px 12px;
background: rgba(239, 68, 68, 0.2);
border-radius: 6px;
margin-bottom: 12px;
}
.collector-table {
width: 100%;
border-collapse: collapse;
}
.collector-table thead tr {
border-bottom: 1px solid var(--kcg-border, rgba(100, 116, 139, 0.3));
}
.collector-th {
text-align: left;
padding: 6px 8px;
font-size: 11px;
opacity: 0.7;
}
.collector-th-right {
text-align: right;
padding: 6px 8px;
font-size: 11px;
opacity: 0.7;
}
.collector-table tbody tr {
border-bottom: 1px solid var(--kcg-border, rgba(100, 116, 139, 0.15));
}
.collector-td {
padding: 8px;
}
.collector-td-right {
padding: 8px;
text-align: right;
}
.collector-td-name {
padding: 8px;
font-weight: 500;
}
.collector-status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
}
.collector-success-count {
color: #22c55e;
}
.collector-td-last-success {
padding: 8px;
opacity: 0.8;
}
.collector-td-error {
padding: 8px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
}
.collector-td-empty {
padding: 20px;
text-align: center;
opacity: 0.5;
}

파일 보기

@ -91,28 +91,18 @@ const LoginPage = ({ onGoogleLogin, onDevLogin }: LoginPageProps) => {
const googleBtnRef = useGoogleIdentity(handleGoogleCredential);
return (
<div
className="flex min-h-screen items-center justify-center"
style={{ backgroundColor: 'var(--kcg-bg)' }}
>
<div
className="flex w-full max-w-sm flex-col items-center gap-6 rounded-xl border p-8"
style={{
backgroundColor: 'var(--kcg-card)',
borderColor: 'var(--kcg-border)',
}}
>
<div className="flex min-h-screen items-center justify-center bg-kcg-bg">
<div className="flex w-full max-w-sm flex-col items-center gap-6 rounded-xl border border-kcg-border bg-kcg-card p-8">
{/* Title */}
<div className="flex flex-col items-center gap-2">
<div className="relative inline-block">
<img src="/kcg.svg" alt="KCG" style={{ width: 120, height: 120 }} />
<span
className="absolute font-black tracking-widest"
className="absolute font-black tracking-widest text-kcg-danger"
style={{
bottom: 2,
right: -8,
fontSize: 14,
color: 'var(--kcg-danger)',
opacity: 0.85,
textShadow: '0 0 4px rgba(0,0,0,0.6)',
}}
@ -120,29 +110,17 @@ const LoginPage = ({ onGoogleLogin, onDevLogin }: LoginPageProps) => {
DEMO
</span>
</div>
<h1
className="text-xl font-bold"
style={{ color: 'var(--kcg-text)' }}
>
<h1 className="text-xl font-bold text-kcg-text">
{t('auth.title')}
</h1>
<p
className="text-sm"
style={{ color: 'var(--kcg-muted)' }}
>
<p className="text-sm text-kcg-muted">
{t('auth.subtitle')}
</p>
</div>
{/* Error */}
{error && (
<div
className="w-full rounded-lg px-4 py-2 text-center text-sm"
style={{
backgroundColor: 'var(--kcg-danger-bg)',
color: 'var(--kcg-danger)',
}}
>
<div className="w-full rounded-lg bg-kcg-danger-bg px-4 py-2 text-center text-sm text-kcg-danger">
{error}
</div>
)}
@ -151,10 +129,7 @@ const LoginPage = ({ onGoogleLogin, onDevLogin }: LoginPageProps) => {
{GOOGLE_CLIENT_ID && (
<>
<div ref={googleBtnRef} />
<p
className="text-xs"
style={{ color: 'var(--kcg-dim)' }}
>
<p className="text-xs text-kcg-dim">
{t('auth.domainNotice')}
</p>
</>
@ -163,32 +138,15 @@ const LoginPage = ({ onGoogleLogin, onDevLogin }: LoginPageProps) => {
{/* Dev Login */}
{IS_DEV && (
<>
<div
className="w-full border-t pt-4 text-center"
style={{ borderColor: 'var(--kcg-border)' }}
>
<span
className="text-xs font-mono tracking-wider"
style={{ color: 'var(--kcg-dim)' }}
>
<div className="w-full border-t border-kcg-border pt-4 text-center">
<span className="text-xs font-mono tracking-wider text-kcg-dim">
{t('auth.devNotice')}
</span>
</div>
<button
type="button"
onClick={onDevLogin}
className="w-full cursor-pointer rounded-lg border-2 px-4 py-3 text-sm font-bold transition-colors"
style={{
borderColor: 'var(--kcg-danger)',
color: 'var(--kcg-danger)',
backgroundColor: 'transparent',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--kcg-danger-bg)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
}}
className="w-full cursor-pointer rounded-lg border-2 border-kcg-danger bg-transparent px-4 py-3 text-sm font-bold text-kcg-danger transition-colors hover:bg-kcg-danger-bg"
>
{t('auth.devLogin')}
</button>

파일 보기

@ -50,61 +50,27 @@ const CollectorMonitor = ({ onClose }: CollectorMonitorProps) => {
}, [refresh]);
return (
<div style={{
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
zIndex: 10000,
background: 'var(--kcg-panel-bg, rgba(15, 23, 42, 0.95))',
border: '1px solid var(--kcg-border, rgba(100, 116, 139, 0.3))',
borderRadius: '12px',
padding: '20px',
minWidth: '600px',
maxWidth: '800px',
maxHeight: '80vh',
overflow: 'auto',
color: 'var(--kcg-text, #e2e8f0)',
fontFamily: 'monospace',
fontSize: '13px',
boxShadow: '0 25px 50px rgba(0, 0, 0, 0.5)',
}}>
<div className='collector-modal'>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h3 style={{ margin: 0, fontSize: '15px', fontWeight: 600 }}>
<div className='collector-header'>
<h3 className='collector-title'>
</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div className='collector-header-actions'>
{serverTime && (
<span style={{ fontSize: '11px', opacity: 0.6 }}>
<span className='collector-server-time'>
: {new Date(serverTime).toLocaleTimeString('ko-KR')}
</span>
)}
<button
onClick={refresh}
style={{
background: 'none',
border: '1px solid var(--kcg-border, rgba(100, 116, 139, 0.3))',
color: 'var(--kcg-text, #e2e8f0)',
borderRadius: '4px',
padding: '2px 8px',
cursor: 'pointer',
fontSize: '12px',
}}
className='collector-refresh-btn'
>
</button>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
color: 'var(--kcg-text, #e2e8f0)',
cursor: 'pointer',
fontSize: '18px',
lineHeight: 1,
padding: '0 4px',
}}
className='collector-close-btn'
>
×
</button>
@ -112,62 +78,57 @@ const CollectorMonitor = ({ onClose }: CollectorMonitorProps) => {
</div>
{error && (
<div style={{ padding: '8px 12px', background: 'rgba(239, 68, 68, 0.2)', borderRadius: '6px', marginBottom: '12px' }}>
<div className='collector-error'>
{error}
</div>
)}
{/* Table */}
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<table className='collector-table'>
<thead>
<tr style={{ borderBottom: '1px solid var(--kcg-border, rgba(100, 116, 139, 0.3))' }}>
<th style={{ textAlign: 'left', padding: '6px 8px', fontSize: '11px', opacity: 0.7 }}></th>
<th style={{ textAlign: 'left', padding: '6px 8px', fontSize: '11px', opacity: 0.7 }}></th>
<th style={{ textAlign: 'right', padding: '6px 8px', fontSize: '11px', opacity: 0.7 }}> </th>
<th style={{ textAlign: 'right', padding: '6px 8px', fontSize: '11px', opacity: 0.7 }}>/</th>
<th style={{ textAlign: 'right', padding: '6px 8px', fontSize: '11px', opacity: 0.7 }}> </th>
<th style={{ textAlign: 'left', padding: '6px 8px', fontSize: '11px', opacity: 0.7 }}> </th>
<th style={{ textAlign: 'left', padding: '6px 8px', fontSize: '11px', opacity: 0.7 }}></th>
<tr>
<th className='collector-th'></th>
<th className='collector-th'></th>
<th className='collector-th-right'> </th>
<th className='collector-th-right'>/</th>
<th className='collector-th-right'> </th>
<th className='collector-th'> </th>
<th className='collector-th'></th>
</tr>
</thead>
<tbody>
{collectors.map((c) => (
<tr key={c.name} style={{ borderBottom: '1px solid var(--kcg-border, rgba(100, 116, 139, 0.15))' }}>
<td style={{ padding: '8px' }}>
<span style={{
display: 'inline-block',
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: getStatusColor(c),
}} />
<tr key={c.name}>
<td className='collector-td'>
<span
className='collector-status-dot'
style={{ backgroundColor: getStatusColor(c) }}
/>
</td>
<td style={{ padding: '8px', fontWeight: 500 }}>{c.name}</td>
<td style={{ padding: '8px', textAlign: 'right' }}>{c.lastCount}</td>
<td style={{ padding: '8px', textAlign: 'right' }}>
<span style={{ color: '#22c55e' }}>{c.totalSuccess}</span>
<td className='collector-td-name'>{c.name}</td>
<td className='collector-td-right'>{c.lastCount}</td>
<td className='collector-td-right'>
<span className='collector-success-count'>{c.totalSuccess}</span>
{' / '}
<span style={{ color: c.totalFailure > 0 ? '#ef4444' : 'inherit' }}>{c.totalFailure}</span>
</td>
<td style={{ padding: '8px', textAlign: 'right' }}>{c.totalItems.toLocaleString()}</td>
<td style={{ padding: '8px', opacity: 0.8 }}>{formatRelativeTime(c.lastSuccess)}</td>
<td style={{
padding: '8px',
maxWidth: '200px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: c.lastError ? '#ef4444' : 'inherit',
opacity: c.lastError ? 1 : 0.4,
fontSize: '11px',
}} title={c.lastError || ''}>
<td className='collector-td-right'>{c.totalItems.toLocaleString()}</td>
<td className='collector-td-last-success'>{formatRelativeTime(c.lastSuccess)}</td>
<td
className='collector-td-error'
style={{
color: c.lastError ? '#ef4444' : 'inherit',
opacity: c.lastError ? 1 : 0.4,
}}
title={c.lastError || ''}
>
{c.lastError || '-'}
</td>
</tr>
))}
{collectors.length === 0 && !error && (
<tr>
<td colSpan={7} style={{ padding: '20px', textAlign: 'center', opacity: 0.5 }}>
<td colSpan={7} className='collector-td-empty'>
...
</td>
</tr>

파일 보기

@ -91,16 +91,11 @@ function AirportMarker({ airport }: { airport: Airport }) {
<Popup longitude={airport.lng} latitude={airport.lat}
onClose={() => setShowPopup(false)} closeOnClick={false}
anchor="bottom" offset={[0, -size / 2]} maxWidth="280px" className="gl-popup">
<div style={{ minWidth: 220, fontFamily: 'monospace', fontSize: 12 }}>
<div style={{
background: isUS ? '#1e3a5f' : isMil ? '#991b1b' : '#92400e',
color: '#fff', padding: '6px 10px', borderRadius: '4px 4px 0 0',
margin: '-10px -10px 8px -10px',
display: 'flex', alignItems: 'center', gap: 8,
}}>
<div className="popup-body" style={{ minWidth: 220 }}>
<div className="popup-header" style={{ background: isUS ? '#1e3a5f' : isMil ? '#991b1b' : '#92400e' }}>
{isUS ? <span style={{ fontSize: 16 }}>{'\u{1F1FA}\u{1F1F8}'}</span>
: flag ? <span style={{ fontSize: 16 }}>{flag}</span> : null}
<strong style={{ fontSize: 13, flex: 1 }}>{airport.name}</strong>
<strong style={{ flex: 1 }}>{airport.name}</strong>
</div>
{airport.nameKo && (
<div style={{ fontSize: 12, color: '#ccc', marginBottom: 6 }}>{airport.nameKo}</div>
@ -113,11 +108,11 @@ function AirportMarker({ airport }: { airport: Airport }) {
{isUS ? 'US Military Base' : TYPE_LABELS[airport.type]}
</span>
</div>
<div style={{ fontSize: 11, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2px 12px' }}>
{airport.iata && <div><span style={{ color: '#888' }}>IATA : </span><strong>{airport.iata}</strong></div>}
<div><span style={{ color: '#888' }}>ICAO : </span><strong>{airport.icao}</strong></div>
{airport.city && <div><span style={{ color: '#888' }}>City : </span>{airport.city}</div>}
<div><span style={{ color: '#888' }}>Country : </span>{airport.country}</div>
<div className="popup-grid" style={{ gap: '2px 12px' }}>
{airport.iata && <div><span className="popup-label">IATA : </span><strong>{airport.iata}</strong></div>}
<div><span className="popup-label">ICAO : </span><strong>{airport.icao}</strong></div>
{airport.city && <div><span className="popup-label">City : </span>{airport.city}</div>}
<div><span className="popup-label">Country : </span>{airport.country}</div>
</div>
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
{airport.lat.toFixed(4)}°{airport.lat >= 0 ? 'N' : 'S'}, {airport.lng.toFixed(4)}°{airport.lng >= 0 ? 'E' : 'W'}

파일 보기

@ -121,14 +121,8 @@ export function InfraLayer({ facilities }: Props) {
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelectedId(null)} closeOnClick={false}
anchor="bottom" maxWidth="280px" className="gl-popup">
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 200 }}>
<div style={{
background: getStyle(selected).color, color: '#000',
padding: '4px 8px', borderRadius: '4px 4px 0 0',
margin: '-10px -10px 8px -10px',
fontWeight: 700, fontSize: 13,
display: 'flex', alignItems: 'center', gap: 6,
}}>
<div className="popup-body-sm" style={{ minWidth: 200 }}>
<div className="popup-header" style={{ background: getStyle(selected).color, color: '#000', gap: 6, padding: '4px 8px' }}>
<span>{getStyle(selected).icon}</span>
{selected.name}
</div>
@ -146,18 +140,18 @@ export function InfraLayer({ facilities }: Props) {
{selected.type === 'plant' ? '발전소' : '변전소'}
</span>
</div>
<div style={{ fontSize: 11, display: 'flex', flexDirection: 'column', gap: 2 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{selected.output && (
<div><span style={{ color: '#888' }}>: </span><strong>{selected.output}</strong></div>
<div><span className="popup-label">: </span><strong>{selected.output}</strong></div>
)}
{selected.voltage && (
<div><span style={{ color: '#888' }}>: </span><strong>{formatVoltage(selected.voltage)}</strong></div>
<div><span className="popup-label">: </span><strong>{formatVoltage(selected.voltage)}</strong></div>
)}
{selected.operator && (
<div><span style={{ color: '#888' }}>: </span>{selected.operator}</div>
<div><span className="popup-label">: </span>{selected.operator}</div>
)}
{selected.source && (
<div><span style={{ color: '#888' }}>: </span>{selected.source}</div>
<div><span className="popup-label">: </span>{selected.source}</div>
)}
<div style={{ fontSize: 9, color: '#666', marginTop: 2 }}>
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E

파일 보기

@ -86,14 +86,8 @@ export function SubmarineCableLayer() {
<Popup longitude={selectedPoint.lng} latitude={selectedPoint.lat}
onClose={() => setSelectedPoint(null)} closeOnClick={false}
anchor="bottom" maxWidth="260px" className="gl-popup">
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 180 }}>
<div style={{
background: '#00e5ff', color: '#000',
padding: '4px 8px', borderRadius: '4px 4px 0 0',
margin: '-10px -10px 8px -10px',
fontWeight: 700, fontSize: 13,
display: 'flex', alignItems: 'center', gap: 6,
}}>
<div className="popup-body-sm" style={{ minWidth: 180 }}>
<div className="popup-header" style={{ background: '#00e5ff', color: '#000', gap: 6, padding: '4px 8px' }}>
<span>📡</span> {selectedPoint.name}
</div>
<div style={{ fontSize: 10, color: '#aaa', marginBottom: 4 }}>
@ -122,28 +116,23 @@ export function SubmarineCableLayer() {
latitude={selectedCable.route[0][1]}
onClose={() => setSelectedCable(null)} closeOnClick={false}
anchor="bottom" maxWidth="280px" className="gl-popup">
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 200 }}>
<div style={{
background: selectedCable.color, color: '#000',
padding: '4px 8px', borderRadius: '4px 4px 0 0',
margin: '-10px -10px 8px -10px',
fontWeight: 700, fontSize: 13,
}}>
<div className="popup-body-sm" style={{ minWidth: 200 }}>
<div className="popup-header" style={{ background: selectedCable.color, color: '#000', padding: '4px 8px' }}>
🔌 {selectedCable.name}
</div>
<div style={{ fontSize: 11, display: 'flex', flexDirection: 'column', gap: 3 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<div>
<span style={{ color: '#888' }}>: </span>
<span className="popup-label">: </span>
<span style={{ color: '#ddd' }}>{selectedCable.landingPoints.join(' → ')}</span>
</div>
{selectedCable.rfsYear && (
<div><span style={{ color: '#888' }}>: </span>{selectedCable.rfsYear}</div>
<div><span className="popup-label">: </span>{selectedCable.rfsYear}</div>
)}
{selectedCable.length && (
<div><span style={{ color: '#888' }}> : </span>{selectedCable.length}</div>
<div><span className="popup-label"> : </span>{selectedCable.length}</div>
)}
{selectedCable.owners && (
<div><span style={{ color: '#888' }}>: </span>{selectedCable.owners}</div>
<div><span className="popup-label">: </span>{selectedCable.owners}</div>
)}
</div>
</div>

파일 보기

@ -120,13 +120,8 @@ export function DamagedShipLayer({ currentTime }: Props) {
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelectedId(null)} closeOnClick={false}
anchor="bottom" maxWidth="320px" className="gl-popup">
<div style={{ minWidth: 260, fontFamily: 'monospace', fontSize: 12 }}>
<div style={{
background: DAMAGE_COLORS[selected.damage], color: '#fff',
padding: '6px 10px', borderRadius: '4px 4px 0 0',
margin: '-10px -10px 8px -10px',
display: 'flex', alignItems: 'center', gap: 8,
}}>
<div className="popup-body" style={{ minWidth: 260 }}>
<div className="popup-header" style={{ background: DAMAGE_COLORS[selected.damage] }}>
{FLAG_EMOJI[selected.flag] && <span style={{ fontSize: 16 }}>{FLAG_EMOJI[selected.flag]}</span>}
<strong style={{ flex: 1 }}>{selected.name}</strong>
<span style={{
@ -134,13 +129,13 @@ export function DamagedShipLayer({ currentTime }: Props) {
borderRadius: 3, fontSize: 10,
}}>{DAMAGE_LABELS[selected.damage]}</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '3px 12px', fontSize: 11 }}>
<div><span style={{ color: '#888' }}> : </span>{selected.type}</div>
<div><span style={{ color: '#888' }}> : </span>{selected.flag}</div>
<div><span style={{ color: '#888' }}> : </span>{selected.cause}</div>
<div><span style={{ color: '#888' }}> : </span>{formatKST(selected.damagedAt)}</div>
<div className="popup-grid">
<div><span className="popup-label"> : </span>{selected.type}</div>
<div><span className="popup-label"> : </span>{selected.flag}</div>
<div><span className="popup-label"> : </span>{selected.cause}</div>
<div><span className="popup-label"> : </span>{formatKST(selected.damagedAt)}</div>
</div>
<div style={{ marginTop: 6, fontSize: 11, color: '#ccc', lineHeight: 1.4 }}>
<div className="popup-desc">
{selected.description}
</div>
</div>