diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 67c1678..089ed48 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -34,7 +34,7 @@ jobs: - name: Install JDK 21 + Maven run: | apt-get update -qq - apt-get install -y -qq wget apt-transport-https gpg maven > /dev/null 2>&1 + apt-get install -y -qq wget apt-transport-https gpg maven openssh-client > /dev/null 2>&1 wget -qO - https://packages.adoptium.net/artifactory/api/gpg/key/public | gpg --dearmor -o /usr/share/keyrings/adoptium.gpg echo "deb [signed-by=/usr/share/keyrings/adoptium.gpg] https://packages.adoptium.net/artifactory/deb bookworm main" > /etc/apt/sources.list.d/adoptium.list apt-get update -qq @@ -48,10 +48,11 @@ jobs: working-directory: backend run: mvn -B clean package -DskipTests - - name: Deploy backend + - name: Deploy backend files 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 @@ -62,12 +63,68 @@ jobs: ls -t $DEPLOY_DIR/backup/*.jar | tail -n +6 | xargs -r rm fi - # Secrets → 환경변수 파일 (빈 값은 제외) + # Secrets → 환경변수 파일 : > $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 교체 + 재시작 트리거 + # 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 서비스 파일 배포 + cp deploy/kcg-backend.service $DEPLOY_DIR/kcg-backend.service + + # JAR 교체 cp backend/target/kcg.jar $DEPLOY_DIR/kcg.jar - date '+%s' > $DEPLOY_DIR/.deploy-trigger - echo "Backend deployed at $(date '+%Y-%m-%d %H:%M:%S')" + echo "Backend files deployed at $(date '+%Y-%m-%d %H:%M:%S')" + + - name: Restart backend via SSH + env: + DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + run: | + mkdir -p ~/.ssh + echo "$DEPLOY_KEY" > ~/.ssh/id_deploy + chmod 600 ~/.ssh/id_deploy + # Docker 컨테이너 → 호스트: bridge gateway(172.17.0.1) 경유 + DOCKER_HOST_IP=172.17.0.1 + ssh-keyscan $DOCKER_HOST_IP >> ~/.ssh/known_hosts 2>/dev/null || true + + SSH_CMD="ssh -i ~/.ssh/id_deploy -o StrictHostKeyChecking=no root@$DOCKER_HOST_IP" + + $SSH_CMD bash -s << 'RESTART' + set -e + DEPLOY_DIR=/devdata/services/kcg/backend + SYSTEMD_DIR=/etc/systemd/system + + # systemd 서비스 파일 갱신 + CHANGED=0 + if [ -f "$DEPLOY_DIR/kcg-backend.service" ] && ! diff -q "$DEPLOY_DIR/kcg-backend.service" "$SYSTEMD_DIR/kcg-backend.service" >/dev/null 2>&1; then + cp "$DEPLOY_DIR/kcg-backend.service" "$SYSTEMD_DIR/kcg-backend.service" + CHANGED=1 + fi + [ "$CHANGED" = "1" ] && systemctl daemon-reload + + # 백엔드 재시작 + echo "--- Restarting kcg-backend ---" + systemctl restart kcg-backend + + # 기동 확인 (최대 30초) + for i in $(seq 1 30); do + if curl -sf http://localhost:8080/api/aircraft > /dev/null 2>&1; then + echo "Backend started successfully (${i}s)" + exit 0 + fi + sleep 1 + done + echo "WARNING: Startup timeout. Recent logs:" + journalctl -u kcg-backend --no-pager -n 20 + exit 1 + RESTART + + - name: Cleanup + if: always() + run: rm -f ~/.ssh/id_deploy diff --git a/.gitignore b/.gitignore index 83d80c2..49616a5 100644 --- a/.gitignore +++ b/.gitignore @@ -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__/ diff --git a/backend/src/main/java/gc/mda/kcg/collector/CollectorStatusController.java b/backend/src/main/java/gc/mda/kcg/collector/CollectorStatusController.java new file mode 100644 index 0000000..ef05bfa --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/collector/CollectorStatusController.java @@ -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 getStatus() { + List 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() + ); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/collector/CollectorStatusTracker.java b/backend/src/main/java/gc/mda/kcg/collector/CollectorStatusTracker.java new file mode 100644 index 0000000..8d64a05 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/collector/CollectorStatusTracker.java @@ -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 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 getAll() { + return statuses.values().stream() + .sorted(Comparator.comparing(CollectorStatus::getName)) + .toList(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/collector/aircraft/AirplanesLiveCollector.java b/backend/src/main/java/gc/mda/kcg/collector/aircraft/AirplanesLiveCollector.java index 2064798..bf3c4c2 100644 --- a/backend/src/main/java/gc/mda/kcg/collector/aircraft/AirplanesLiveCollector.java +++ b/backend/src/main/java/gc/mda/kcg/collector/aircraft/AirplanesLiveCollector.java @@ -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 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 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()); } diff --git a/backend/src/main/java/gc/mda/kcg/collector/aircraft/OpenSkyCollector.java b/backend/src/main/java/gc/mda/kcg/collector/aircraft/OpenSkyCollector.java index 5ddab10..aa9a773 100644 --- a/backend/src/main/java/gc/mda/kcg/collector/aircraft/OpenSkyCollector.java +++ b/backend/src/main/java/gc/mda/kcg/collector/aircraft/OpenSkyCollector.java @@ -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()); } diff --git a/backend/src/main/java/gc/mda/kcg/collector/osint/OsintCollector.java b/backend/src/main/java/gc/mda/kcg/collector/osint/OsintCollector.java index c7ee6f9..f9eb073 100644 --- a/backend/src/main/java/gc/mda/kcg/collector/osint/OsintCollector.java +++ b/backend/src/main/java/gc/mda/kcg/collector/osint/OsintCollector.java @@ -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; + } + + 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()); + } } - @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 한국 수집 완료"); - } - - 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×pan=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; } } diff --git a/backend/src/main/java/gc/mda/kcg/collector/satellite/SatelliteCollector.java b/backend/src/main/java/gc/mda/kcg/collector/satellite/SatelliteCollector.java index b35cddd..9acdcac 100644 --- a/backend/src/main/java/gc/mda/kcg/collector/satellite/SatelliteCollector.java +++ b/backend/src/main/java/gc/mda/kcg/collector/satellite/SatelliteCollector.java @@ -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(); diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml new file mode 100644 index 0000000..b62d921 --- /dev/null +++ b/backend/src/main/resources/application-prod.yml @@ -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 diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 19fc170..8ab9a87 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -3,7 +3,7 @@ spring: active: ${SPRING_PROFILES_ACTIVE:local} jpa: hibernate: - ddl-auto: validate + ddl-auto: none properties: hibernate: default_schema: kcg diff --git a/deploy/kcg-backend-watcher.path b/deploy/kcg-backend-watcher.path deleted file mode 100644 index a57f92d..0000000 --- a/deploy/kcg-backend-watcher.path +++ /dev/null @@ -1,9 +0,0 @@ -[Unit] -Description=Watch for KCG Backend deploy trigger - -[Path] -PathModified=/deploy/kcg-backend/.deploy-trigger -Unit=kcg-backend-watcher.service - -[Install] -WantedBy=multi-user.target diff --git a/deploy/kcg-backend-watcher.service b/deploy/kcg-backend-watcher.service deleted file mode 100644 index 1c2b17a..0000000 --- a/deploy/kcg-backend-watcher.service +++ /dev/null @@ -1,6 +0,0 @@ -[Unit] -Description=Restart KCG Backend on deploy - -[Service] -Type=oneshot -ExecStart=/bin/systemctl restart kcg-backend diff --git a/deploy/kcg-backend.service b/deploy/kcg-backend.service index 3367540..bac0ea8 100644 --- a/deploy/kcg-backend.service +++ b/deploy/kcg-backend.service @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0f0ccfc..bd84db9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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) { {dashboardTab === 'iran' ? satPositions.length : satPositionsKorea.length} SAT
+ @@ -1157,6 +1168,10 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { )} + + {showCollectorMonitor && ( + setShowCollectorMonitor(false)} /> + )}
); } diff --git a/frontend/src/components/CollectorMonitor.tsx b/frontend/src/components/CollectorMonitor.tsx new file mode 100644 index 0000000..5b86731 --- /dev/null +++ b/frontend/src/components/CollectorMonitor.tsx @@ -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([]); + 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 ( +
+ {/* Header */} +
+

+ 수집기 모니터링 +

+
+ {serverTime && ( + + 서버: {new Date(serverTime).toLocaleTimeString('ko-KR')} + + )} + + +
+
+ + {error && ( +
+ {error} +
+ )} + + {/* Table */} + + + + + + + + + + + + + + {collectors.map((c) => ( + + + + + + + + + + ))} + {collectors.length === 0 && !error && ( + + + + )} + +
상태수집기최근 건수성공/실패총 수집마지막 성공에러
+ + {c.name}{c.lastCount} + {c.totalSuccess} + {' / '} + 0 ? '#ef4444' : 'inherit' }}>{c.totalFailure} + {c.totalItems.toLocaleString()}{formatRelativeTime(c.lastSuccess)} + {c.lastError || '-'} +
+ 수집기 데이터 로딩 중... +
+
+ ); +}; + +export default CollectorMonitor; diff --git a/frontend/src/services/collectorStatus.ts b/frontend/src/services/collectorStatus.ts new file mode 100644 index 0000000..485d8a8 --- /dev/null +++ b/frontend/src/services/collectorStatus.ts @@ -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 { + 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; +} diff --git a/frontend/src/services/ships.ts b/frontend/src/services/ships.ts index a901b8e..2aa9242 100644 --- a/frontend/src/services/ships.ts +++ b/frontend/src/services/ships.ts @@ -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 { } // ═══ 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 { - // 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 { + 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 { try { const body: RecentPositionDetailRequest = {