fix(deploy): 배포 자동화 + 수집기 모니터링 + 이란 signal-batch 연동 (#32)
All checks were successful
Deploy KCG / deploy (push) Successful in 1m10s

Co-authored-by: htlee <htlee@gcsc.co.kr>
Co-committed-by: htlee <htlee@gcsc.co.kr>
This commit is contained in:
htlee 2026-03-18 05:46:55 +09:00 committed by claude-bot
부모 ed9a2e3233
커밋 fe1de4bf51
17개의 변경된 파일476개의 추가작업 그리고 54개의 파일을 삭제

파일 보기

@ -52,6 +52,7 @@ jobs:
env:
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
run: |
DEPLOY_DIR=/deploy/kcg-backend
mkdir -p $DEPLOY_DIR/backup
@ -66,6 +67,18 @@ jobs:
: > $DEPLOY_DIR/.env
[ -n "$GOOGLE_CLIENT_ID" ] && echo "GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}" >> $DEPLOY_DIR/.env
[ -n "$JWT_SECRET" ] && echo "JWT_SECRET=${JWT_SECRET}" >> $DEPLOY_DIR/.env
[ -n "$DB_PASSWORD" ] && echo "DB_PASSWORD=${DB_PASSWORD}" >> $DEPLOY_DIR/.env
# JAR 내부에 application-prod.yml이 있으면 외부 파일 제거
if unzip -l backend/target/kcg.jar | grep -q 'application-prod.yml$'; then
rm -f $DEPLOY_DIR/application-prod.yml
echo "JAR 내부 application-prod.yml 감지 → 외부 파일 제거"
fi
# systemd 서비스 파일 배포 (watcher가 반영)
cp deploy/kcg-backend.service $DEPLOY_DIR/kcg-backend.service
cp deploy/kcg-backend-watcher.service $DEPLOY_DIR/kcg-backend-watcher.service
cp deploy/kcg-backend-watcher.path $DEPLOY_DIR/kcg-backend-watcher.path
# JAR 교체 + 재시작 트리거
cp backend/target/kcg.jar $DEPLOY_DIR/kcg.jar

1
.gitignore vendored
파일 보기

@ -77,7 +77,6 @@ frontend/node_modules/
backend/target/
backend/.env
backend/src/main/resources/application-local.yml
backend/src/main/resources/application-prod.yml
# === Prediction ===
prediction/__pycache__/

파일 보기

@ -0,0 +1,36 @@
package gc.mda.kcg.collector;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
public class CollectorStatusController {
private final CollectorStatusTracker tracker;
@GetMapping("/collector-status")
public Map<String, Object> getStatus() {
List<CollectorStatusTracker.CollectorStatus> statuses = tracker.getAll();
return Map.of(
"collectors", statuses.stream().map(s -> Map.of(
"name", s.getName(),
"region", s.getRegion() != null ? s.getRegion() : "",
"lastSuccess", s.getLastSuccess() != null ? s.getLastSuccess().toString() : "",
"lastFailure", s.getLastFailure() != null ? s.getLastFailure().toString() : "",
"lastCount", s.getLastCount(),
"lastError", s.getLastError() != null ? s.getLastError() : "",
"totalSuccess", s.getTotalSuccess(),
"totalFailure", s.getTotalFailure(),
"totalItems", s.getTotalItems()
)).toList(),
"serverTime", java.time.Instant.now().toString()
);
}
}

파일 보기

@ -0,0 +1,62 @@
package gc.mda.kcg.collector;
import lombok.Getter;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class CollectorStatusTracker {
@Getter
public static class CollectorStatus {
private final String name;
private volatile String region;
private volatile Instant lastSuccess;
private volatile Instant lastFailure;
private volatile int lastCount;
private volatile String lastError;
private volatile long totalSuccess;
private volatile long totalFailure;
private volatile long totalItems;
public CollectorStatus(String name) {
this.name = name;
}
void recordSuccess(String region, int count) {
this.region = region;
this.lastSuccess = Instant.now();
this.lastCount = count;
this.lastError = null;
this.totalSuccess++;
this.totalItems += count;
}
void recordFailure(String region, String error) {
this.region = region;
this.lastFailure = Instant.now();
this.lastError = error;
this.totalFailure++;
}
}
private final ConcurrentHashMap<String, CollectorStatus> statuses = new ConcurrentHashMap<>();
public void recordSuccess(String name, String region, int count) {
statuses.computeIfAbsent(name, CollectorStatus::new).recordSuccess(region, count);
}
public void recordFailure(String name, String region, String error) {
statuses.computeIfAbsent(name, CollectorStatus::new).recordFailure(region, error);
}
public List<CollectorStatus> getAll() {
return statuses.values().stream()
.sorted(Comparator.comparing(CollectorStatus::getName))
.toList();
}
}

파일 보기

@ -2,6 +2,7 @@ package gc.mda.kcg.collector.aircraft;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import gc.mda.kcg.collector.CollectorStatusTracker;
import gc.mda.kcg.domain.aircraft.AircraftDto;
import gc.mda.kcg.domain.aircraft.AircraftPosition;
import gc.mda.kcg.domain.aircraft.AircraftPositionRepository;
@ -35,6 +36,7 @@ public class AirplanesLiveCollector {
private final ObjectMapper objectMapper;
private final AircraftCacheStore cacheStore;
private final AircraftPositionRepository positionRepository;
private final CollectorStatusTracker tracker;
private final GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), 4326);
@ -131,7 +133,9 @@ public class AirplanesLiveCollector {
cacheStore.mergeAndUpdate("iran");
// DB 적재
persistAll(mergeForPersistence(iranRegionBuffers, mil), "airplaneslive", "iran");
List<AircraftDto> iranMerged = mergeForPersistence(iranRegionBuffers, mil);
persistAll(iranMerged, "airplaneslive", "iran");
tracker.recordSuccess("airplaneslive-iran", "iran", cacheStore.get("iran").size());
log.debug("Airplanes.live 이란 수집 완료 — 캐시: {}", cacheStore.get("iran").size());
}
@ -156,7 +160,9 @@ public class AirplanesLiveCollector {
cacheStore.updateSource("korea", AircraftCacheStore.SOURCE_MIL, mil);
cacheStore.mergeAndUpdate("korea");
persistAll(mergeForPersistence(koreaRegionBuffers, mil), "airplaneslive", "korea");
List<AircraftDto> koreaMerged = mergeForPersistence(koreaRegionBuffers, mil);
persistAll(koreaMerged, "airplaneslive", "korea");
tracker.recordSuccess("airplaneslive-korea", "korea", cacheStore.get("korea").size());
log.debug("Airplanes.live 한국 수집 완료 — 캐시: {}", cacheStore.get("korea").size());
}

파일 보기

@ -2,6 +2,7 @@ package gc.mda.kcg.collector.aircraft;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import gc.mda.kcg.collector.CollectorStatusTracker;
import gc.mda.kcg.domain.aircraft.AircraftDto;
import gc.mda.kcg.domain.aircraft.AircraftPosition;
import gc.mda.kcg.domain.aircraft.AircraftPositionRepository;
@ -34,6 +35,7 @@ public class OpenSkyCollector {
private final ObjectMapper objectMapper;
private final AircraftCacheStore cacheStore;
private final AircraftPositionRepository positionRepository;
private final CollectorStatusTracker tracker;
private final GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), 4326);
@ -44,6 +46,9 @@ public class OpenSkyCollector {
cacheStore.updateSource("iran", AircraftCacheStore.SOURCE_OPENSKY, aircraft);
cacheStore.mergeAndUpdate("iran");
persistAll(aircraft, "opensky", "iran");
tracker.recordSuccess("opensky-iran", "iran", aircraft.size());
} else {
tracker.recordFailure("opensky-iran", "iran", "빈 응답 또는 429");
}
log.debug("OpenSky 이란 수집 완료: {} 항공기", aircraft.size());
}
@ -55,6 +60,9 @@ public class OpenSkyCollector {
cacheStore.updateSource("korea", AircraftCacheStore.SOURCE_OPENSKY, aircraft);
cacheStore.mergeAndUpdate("korea");
persistAll(aircraft, "opensky", "korea");
tracker.recordSuccess("opensky-korea", "korea", aircraft.size());
} else {
tracker.recordFailure("opensky-korea", "korea", "빈 응답 또는 429");
}
log.debug("OpenSky 한국 수집 완료: {} 항공기", aircraft.size());
}

파일 보기

@ -2,6 +2,7 @@ package gc.mda.kcg.collector.osint;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import gc.mda.kcg.collector.CollectorStatusTracker;
import gc.mda.kcg.config.AppProperties;
import gc.mda.kcg.config.CacheConfig;
import gc.mda.kcg.domain.osint.OsintDto;
@ -51,6 +52,9 @@ public class OsintCollector {
private final OsintFeedRepository osintFeedRepository;
private final AppProperties appProperties;
private final ObjectMapper objectMapper;
private final CollectorStatusTracker tracker;
private boolean nextIsIran = true;
@PostConstruct
public void init() {
@ -62,25 +66,38 @@ public class OsintCollector {
});
}
@Scheduled(initialDelay = 30_000, fixedDelay = 120_000)
public void collectIran() {
log.debug("OSINT 이란 수집 시작");
collectGdelt("iran", IRAN_KEYWORDS);
collectGoogleNews("iran", "Iran Hormuz military", "en");
refreshCache("iran");
log.debug("OSINT 이란 수집 완료");
@Scheduled(initialDelay = 30_000, fixedDelay = 10_000)
public void collectOsint() {
String region = nextIsIran ? "iran" : "korea";
String keywords = nextIsIran ? IRAN_KEYWORDS : KOREA_KEYWORDS;
nextIsIran = !nextIsIran;
log.debug("OSINT {} 수집 시작", region);
try {
int gdeltCount = collectGdelt(region, keywords);
// Google News는 GDELT 호출 5초 대기 (rate limit 대응)
try {
Thread.sleep(5_000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
@Scheduled(initialDelay = 45_000, fixedDelay = 120_000)
public void collectKorea() {
log.debug("OSINT 한국 수집 시작");
collectGdelt("korea", KOREA_KEYWORDS);
collectGoogleNews("korea", KOREA_KEYWORDS, "ko");
refreshCache("korea");
log.debug("OSINT 한국 수집 완료");
String newsQuery = "iran".equals(region) ? "Iran Hormuz military" : KOREA_KEYWORDS;
String newsLang = "iran".equals(region) ? "en" : "ko";
int newsCount = collectGoogleNews(region, newsQuery, newsLang);
refreshCache(region);
tracker.recordSuccess("osint-" + region, region, gdeltCount + newsCount);
log.debug("OSINT {} 수집 완료", region);
} catch (Exception e) {
tracker.recordFailure("osint-" + region, region, e.getMessage());
log.warn("OSINT {} 수집 실패: {}", region, e.getMessage());
}
}
private void collectGdelt(String region, String keywords) {
private int collectGdelt(String region, String keywords) {
try {
String url = String.format(
"%s?query=%s&mode=ArtList&maxrecords=30&format=json&sort=DateDesc&timespan=24h",
@ -88,11 +105,11 @@ public class OsintCollector {
encodeQuery(keywords)
);
String body = restTemplate.getForObject(url, String.class);
if (body == null || body.isBlank()) return;
if (body == null || body.isBlank()) return 0;
JsonNode root = objectMapper.readTree(body);
JsonNode articles = root.path("articles");
if (!articles.isArray()) return;
if (!articles.isArray()) return 0;
int saved = 0;
for (JsonNode article : articles) {
@ -125,12 +142,14 @@ public class OsintCollector {
saved++;
}
log.debug("GDELT {} 저장: {}건", region, saved);
return saved;
} catch (Exception e) {
log.warn("GDELT {} 수집 실패: {}", region, e.getMessage());
return 0;
}
}
private void collectGoogleNews(String region, String query, String lang) {
private int collectGoogleNews(String region, String query, String lang) {
try {
boolean isKorean = "ko".equals(lang);
String hl = isKorean ? "ko" : "en";
@ -146,7 +165,7 @@ public class OsintCollector {
);
String body = restTemplate.getForObject(url, String.class);
if (body == null || body.isBlank()) return;
if (body == null || body.isBlank()) return 0;
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
@ -182,8 +201,10 @@ public class OsintCollector {
saved++;
}
log.debug("Google News {} ({}) 저장: {}건", region, lang, saved);
return saved;
} catch (Exception e) {
log.warn("Google News {} ({}) 수집 실패: {}", region, lang, e.getMessage());
return 0;
}
}

파일 보기

@ -1,5 +1,6 @@
package gc.mda.kcg.collector.satellite;
import gc.mda.kcg.collector.CollectorStatusTracker;
import gc.mda.kcg.config.AppProperties;
import gc.mda.kcg.config.CacheConfig;
import gc.mda.kcg.domain.satellite.SatelliteDto;
@ -44,6 +45,7 @@ public class SatelliteCollector {
private final CacheManager cacheManager;
private final SatelliteTleRepository repository;
private final AppProperties appProperties;
private final CollectorStatusTracker tracker;
@PostConstruct
public void init() {
@ -88,10 +90,14 @@ public class SatelliteCollector {
if (!allEntities.isEmpty()) {
try {
repository.saveAll(allEntities);
tracker.recordSuccess("satellite", "global", allEntities.size());
log.info("위성 TLE DB 적재 완료: {} 건", allEntities.size());
} catch (Exception e) {
tracker.recordFailure("satellite", "global", e.getMessage());
log.error("위성 TLE DB 적재 실패: {}", e.getMessage());
}
} else {
tracker.recordFailure("satellite", "global", "TLE 데이터 없음");
}
loadCacheFromDb();

파일 보기

@ -0,0 +1,15 @@
spring:
datasource:
url: ${DB_URL:jdbc:postgresql://211.208.115.83:5432/kcgdb?currentSchema=kcg,public}
username: ${DB_USERNAME:kcg_app}
password: ${DB_PASSWORD}
app:
jwt:
secret: ${JWT_SECRET}
expiration-ms: ${JWT_EXPIRATION_MS:86400000}
google:
client-id: ${GOOGLE_CLIENT_ID}
auth:
allowed-domain: ${AUTH_ALLOWED_DOMAIN:gcsc.co.kr}
cors:
allowed-origins: http://localhost:5173,https://kcg.gc-si.dev

파일 보기

@ -3,7 +3,7 @@ spring:
active: ${SPRING_PROFILES_ACTIVE:local}
jpa:
hibernate:
ddl-auto: validate
ddl-auto: none
properties:
hibernate:
default_schema: kcg

파일 보기

@ -2,7 +2,7 @@
Description=Watch for KCG Backend deploy trigger
[Path]
PathModified=/deploy/kcg-backend/.deploy-trigger
PathModified=/devdata/services/kcg/backend/.deploy-trigger
Unit=kcg-backend-watcher.service
[Install]

파일 보기

@ -3,4 +3,15 @@ Description=Restart KCG Backend on deploy
[Service]
Type=oneshot
ExecStart=/bin/systemctl restart kcg-backend
ExecStart=/bin/bash -c '\
DEPLOY_DIR=/devdata/services/kcg/backend; \
SYSTEMD_DIR=/etc/systemd/system; \
CHANGED=0; \
for f in kcg-backend.service kcg-backend-watcher.service kcg-backend-watcher.path; do \
if [ -f "$DEPLOY_DIR/$f" ] && ! diff -q "$DEPLOY_DIR/$f" "$SYSTEMD_DIR/$f" >/dev/null 2>&1; then \
cp "$DEPLOY_DIR/$f" "$SYSTEMD_DIR/$f"; \
CHANGED=1; \
fi; \
done; \
[ "$CHANGED" = "1" ] && systemctl daemon-reload; \
systemctl restart kcg-backend'

파일 보기

@ -11,7 +11,6 @@ EnvironmentFile=-/devdata/services/kcg/backend/.env
ExecStart=/usr/lib/jvm/java-21-openjdk-21.0.10.0.7-1.el9.x86_64/bin/java \
-Xms2g -Xmx4g \
-Dspring.profiles.active=prod \
-Dspring.config.additional-location=file:/devdata/services/kcg/backend/ \
-jar /devdata/services/kcg/backend/kcg.jar
Restart=on-failure

파일 보기

@ -26,6 +26,7 @@ import { useTheme } from './hooks/useTheme';
import { useAuth } from './hooks/useAuth';
import { useTranslation } from 'react-i18next';
import LoginPage from './components/auth/LoginPage';
import CollectorMonitor from './components/CollectorMonitor';
import './App.css';
// MarineTraffic-style ship classification
@ -193,6 +194,8 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
ferryWatch: false,
});
const [showCollectorMonitor, setShowCollectorMonitor] = useState(false);
const replay = useReplay();
const monitor = useMonitor();
const { theme, toggleTheme } = useTheme();
@ -233,7 +236,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
return () => clearInterval(interval);
}, [appMode, refreshKey]);
// Fetch base ship data — never overwrite with empty to prevent flicker
// Fetch Iran ship data (signal-batch + sample military, 5-min cycle)
useEffect(() => {
const load = async () => {
try {
@ -246,7 +249,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
}
};
load();
const interval = setInterval(load, 15_000);
const interval = setInterval(load, 300_000);
return () => clearInterval(interval);
}, [appMode, refreshKey]);
@ -904,6 +907,14 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
<span className="count-item sat-count">{dashboardTab === 'iran' ? satPositions.length : satPositionsKorea.length} SAT</span>
</div>
<div className="header-toggles">
<button
type="button"
className="header-toggle-btn"
onClick={() => setShowCollectorMonitor(v => !v)}
title="수집기 모니터링"
>
MON
</button>
<button type="button" className="header-toggle-btn" onClick={toggleLang} title="Language">
{i18n.language === 'ko' ? 'KO' : 'EN'}
</button>
@ -1157,6 +1168,10 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
</footer>
</>
)}
{showCollectorMonitor && (
<CollectorMonitor onClose={() => setShowCollectorMonitor(false)} />
)}
</div>
);
}

파일 보기

@ -0,0 +1,181 @@
import { useState, useEffect, useCallback } from 'react';
import { fetchCollectorStatus } from '../services/collectorStatus';
import type { CollectorInfo } from '../services/collectorStatus';
interface CollectorMonitorProps {
onClose: () => void;
}
function formatRelativeTime(isoString: string): string {
if (!isoString) return '-';
const diff = Date.now() - new Date(isoString).getTime();
if (diff < 0) return '방금';
const sec = Math.floor(diff / 1000);
if (sec < 60) return `${sec}초 전`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}분 전`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}시간 전`;
return `${Math.floor(hr / 24)}일 전`;
}
function getStatusColor(info: CollectorInfo): string {
if (!info.lastSuccess) return '#ef4444';
const elapsed = Date.now() - new Date(info.lastSuccess).getTime();
if (elapsed < 5 * 60_000) return '#22c55e';
if (elapsed < 30 * 60_000) return '#eab308';
return '#ef4444';
}
const CollectorMonitor = ({ onClose }: CollectorMonitorProps) => {
const [collectors, setCollectors] = useState<CollectorInfo[]>([]);
const [serverTime, setServerTime] = useState('');
const [error, setError] = useState('');
const refresh = useCallback(async () => {
try {
const data = await fetchCollectorStatus();
setCollectors(data.collectors);
setServerTime(data.serverTime);
setError('');
} catch (e) {
setError(e instanceof Error ? e.message : '연결 실패');
}
}, []);
useEffect(() => {
refresh();
const interval = setInterval(refresh, 10_000);
return () => clearInterval(interval);
}, [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)',
}}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h3 style={{ margin: 0, fontSize: '15px', fontWeight: 600 }}>
</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
{serverTime && (
<span style={{ fontSize: '11px', opacity: 0.6 }}>
: {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',
}}
>
</button>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
color: 'var(--kcg-text, #e2e8f0)',
cursor: 'pointer',
fontSize: '18px',
lineHeight: 1,
padding: '0 4px',
}}
>
×
</button>
</div>
</div>
{error && (
<div style={{ padding: '8px 12px', background: 'rgba(239, 68, 68, 0.2)', borderRadius: '6px', marginBottom: '12px' }}>
{error}
</div>
)}
{/* Table */}
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<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>
</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),
}} />
</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>
{' / '}
<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 || ''}>
{c.lastError || '-'}
</td>
</tr>
))}
{collectors.length === 0 && !error && (
<tr>
<td colSpan={7} style={{ padding: '20px', textAlign: 'center', opacity: 0.5 }}>
...
</td>
</tr>
)}
</tbody>
</table>
</div>
);
};
export default CollectorMonitor;

파일 보기

@ -0,0 +1,26 @@
const API_BASE = '/api/kcg';
export interface CollectorInfo {
name: string;
region: string;
lastSuccess: string;
lastFailure: string;
lastCount: number;
lastError: string;
totalSuccess: number;
totalFailure: number;
totalItems: number;
}
interface CollectorStatusResponse {
collectors: CollectorInfo[];
serverTime: string;
}
export async function fetchCollectorStatus(): Promise<CollectorStatusResponse> {
const res = await fetch(`${API_BASE}/admin/collector-status`, {
headers: { Accept: 'application/json' },
});
if (!res.ok) throw new Error(`collector-status ${res.status}`);
return res.json() as Promise<CollectorStatusResponse>;
}

파일 보기

@ -26,6 +26,14 @@ const KR_BOUNDS = {
maxLng: 145,
};
// Iran / Strait of Hormuz bounding box for signal-batch
const IRAN_BOUNDS = {
minLat: 20,
maxLat: 30,
minLng: 48,
maxLng: 62,
};
// S&P API sinceSeconds — currently disabled, will be removed with S&P code
// const SINCE_SECONDS = 3600;
@ -321,37 +329,23 @@ export async function fetchTankers(): Promise<Ship[]> {
}
// ═══ Main fetch function ═══
// Tries S&P Global AIS API first, merges with sample military ships
// Tries signal-batch Iran region first, merges with sample military ships
export async function fetchShips(): Promise<Ship[]> {
// Always include sample military/scenario ships (warships have AIS off)
const sampleShips = getSampleShips();
const real = await fetchShipsFromSignalBatchIran();
// Try area-based query for all commercial vessels in Middle East
const areaShips = await fetchShipsFromSPG();
if (areaShips.length > 0) {
console.log(`S&P AIS API: ${areaShips.length} real vessels in Middle East area`);
// Keep sample military ships that aren't in AIS data
if (real.length > 0) {
console.log(`signal-batch: ${real.length} vessels in Iran/Hormuz region`);
// 샘플 군함(military)은 유지 (AIS에 안 잡힘)
const sampleMilitary = sampleShips.filter(s =>
s.category !== 'civilian' && s.category !== 'cargo' && s.category !== 'tanker' && s.category !== 'unknown'
s.category === 'warship' || s.category === 'carrier' || s.category === 'destroyer' || s.category === 'patrol' || s.category === 'submarine'
);
// Keep sample Korean commercial ships (scenario-specific stranded vessels)
const sampleKorean = sampleShips.filter(s => s.flag === 'KR' && s.category !== 'destroyer');
const sampleMMSIs = new Set([...sampleMilitary, ...sampleKorean].map(s => s.mmsi));
const sampleToKeep = [...sampleMilitary, ...sampleKorean];
// Merge: real AIS ships + sample military/Korean ships (avoid duplicates)
const merged = [
...areaShips.filter(s => !sampleMMSIs.has(s.mmsi)),
...sampleToKeep,
];
return merged;
const sampleMMSIs = new Set(sampleMilitary.map(s => s.mmsi));
return [...real.filter(s => !sampleMMSIs.has(s.mmsi)), ...sampleMilitary];
}
// Fallback to sample data only
console.warn('S&P AIS API returned no data, using sample data');
// signal-batch 실패 시 기존 샘플 데이터 사용
console.warn('signal-batch Iran returned no data, using sample data');
return sampleShips;
}
@ -721,6 +715,36 @@ function parseSignalBatchVessel(d: RecentPositionDetailDto): Ship {
};
}
async function fetchShipsFromSignalBatchIran(): Promise<Ship[]> {
try {
const body: RecentPositionDetailRequest = {
minutes: 10,
coordinates: [
[IRAN_BOUNDS.minLng, IRAN_BOUNDS.minLat],
[IRAN_BOUNDS.maxLng, IRAN_BOUNDS.minLat],
[IRAN_BOUNDS.maxLng, IRAN_BOUNDS.maxLat],
[IRAN_BOUNDS.minLng, IRAN_BOUNDS.maxLat],
[IRAN_BOUNDS.minLng, IRAN_BOUNDS.minLat],
],
};
const res = await fetch(`${SIGNAL_BATCH_BASE}/api/v1/vessels/recent-positions-detail`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`signal-batch API ${res.status}`);
const data: RecentPositionDetailDto[] = await res.json();
return data
.filter(d => d.lat != null && d.lon != null && d.lat !== 0 && d.lon !== 0)
.map(parseSignalBatchVessel);
} catch (err) {
console.warn('signal-batch API (Iran region) failed:', err);
return [];
}
}
async function fetchShipsFromSignalBatch(): Promise<Ship[]> {
try {
const body: RecentPositionDetailRequest = {