fix(deploy): 배포 자동화 + 수집기 모니터링 + 이란 signal-batch 연동 (#32)
All checks were successful
Deploy KCG / deploy (push) Successful in 1m10s
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:
부모
ed9a2e3233
커밋
fe1de4bf51
@ -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
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×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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
15
backend/src/main/resources/application-prod.yml
Normal file
15
backend/src/main/resources/application-prod.yml
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
181
frontend/src/components/CollectorMonitor.tsx
Normal file
181
frontend/src/components/CollectorMonitor.tsx
Normal file
@ -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;
|
||||
26
frontend/src/services/collectorStatus.ts
Normal file
26
frontend/src/services/collectorStatus.ts
Normal file
@ -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 = {
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user