Compare commits

..

No commits in common. "main" and "release/2026-04-14-enforcement-dar" have entirely different histories.

147개의 변경된 파일3435개의 추가작업 그리고 12438개의 파일을 삭제

1
.gitignore vendored
파일 보기

@ -5,7 +5,6 @@ backend/target/
backend/build/
# === Python (prediction) ===
.venv/
prediction/.venv/
prediction/__pycache__/
prediction/**/__pycache__/

파일 보기

@ -2,45 +2,6 @@
해양경찰청 AI 기반 불법어선 탐지 및 단속 지원 플랫폼
## 🚨 절대 지침 (Absolute Rules)
아래 두 지침은 모든 작업에 우선 적용된다. 사용자가 명시적으로 해제하지 않는 한 우회 금지.
### 1. 신규 기능 설계·구현 착수 시: 원격 develop 동기화 필수
신규 기능/버그 수정/리팩터 등 **어떤 작업이든 브랜치를 새로 만들기 전**에는 아래 절차를 반드시 수행한다.
```bash
git fetch origin --prune
# origin/develop이 로컬 develop보다 앞서 있는지 확인
git log --oneline develop..origin/develop | head
```
- **로컬 develop이 뒤처진 경우** → 사용자에게 다음을 권유하고 동의를 받은 후 진행:
> "`origin/develop`이 로컬보다 N개 커밋 앞서 있습니다. 최신화 후 신규 브랜치를 생성하는 것을 권장합니다. 진행할까요?"
승인 시: `git checkout develop && git pull --ff-only origin develop` → 그 위에서 `git checkout -b <new-branch>`
- **로컬 develop이 최신인 경우** → 그대로 develop에서 신규 브랜치 분기
- **로컬 develop이 없는 경우**`git checkout -b develop origin/develop`로 tracking branch 먼저 생성
- **로컬에 unstaged/uncommitted 변경이 있을 때** → 사용자에게 먼저 알리고 stash/commit 여부 확인 후 진행. 임의로 폐기 금지.
**이유**: 오래된 develop 위에서 작업하면 머지 충돌·리베이스 비용이 커지고, 이미 해결된 이슈를 중복 해결할 위험이 있다. 브랜치 분기 시점의 기반을 항상 최신으로 맞춘다.
**적용 범위**: `/push`, `/mr`, `/create-mr`, `/release`, `/fix-issue` 스킬 실행 시, 그리고 Claude가 자발적으로 새 브랜치를 만들 때 모두.
### 2. 프론트엔드 개발 시: `design-system.html` 쇼케이스 규칙 전면 준수
`frontend/` 하위의 모든 페이지·컴포넌트·스타일 작성은 `design-system.html`(쇼케이스)에 정의된 컴포넌트·토큰·카탈로그만 사용한다. 이 문서 하단 **"디자인 시스템 (필수 준수)"** 섹션의 규칙을 **예외 없이** 따른다.
핵심 요약 (상세는 하단 섹션 참조):
- 공통 컴포넌트 우선 사용: `Badge`, `Button`, `Input`, `Select`, `TabBar`, `Card`, `PageContainer`, `PageHeader`, `Section`
- 라벨/색상은 `shared/constants/` 카탈로그 API(`getAlertLevelIntent` 등) 경유, ad-hoc 문자열 매핑 금지
- **인라인 색상·하드코딩 Tailwind 색상·`!important` 전면 금지**
- 접근성: `<button type="button">`, 아이콘 전용은 `aria-label`, 폼 요소는 `aria-label`/`<label>` 필수
위반 시 리뷰 단계에서 반려 대상. 신규 페이지는 하단의 **"페이지 작성 표준 템플릿"** 을 시작점으로 사용한다.
---
## 모노레포 구조
```
@ -63,14 +24,14 @@ kcg-ai-monitoring/
```
[Frontend Vite :5173] ──→ [Backend Spring :8080] ──→ [PostgreSQL kcgaidb]
↑ write
[Prediction FastAPI :18092] ─────┘ (5분 주기 분석 결과 저장)
↑ read
[SNPDB PostgreSQL] (AIS 원본)
[Prediction FastAPI :8001] ──────┘ (5분 주기 분석 결과 저장)
↑ read ↑ read
[SNPDB PostgreSQL] (AIS 원본) [Iran Backend] (레거시 프록시, 선택)
```
- **자체 백엔드**: 인증/권한/감사로그/관리자 + 운영자 의사결정 (확정/제외/학습) + prediction 분석 결과 조회 API (`/api/analysis/*`)
- **Prediction**: AIS → 분석 결과를 kcgaidb 에 직접 write (백엔드 호출 없음)
- **DB 공유 아키텍처**: 백엔드와 prediction 은 HTTP 호출 없이 kcgaidb 를 통해서만 연동
- **자체 백엔드**: 인증/권한/감사로그/관리자 + 운영자 의사결정 (확정/제외/학습)
- **iran 백엔드 프록시**: 분석 결과 read-only 참조 (vessel_analysis, group_polygons, correlations)
- **신규 DB (kcgaidb)**: 자체 생산 데이터만 저장, prediction 분석 테이블은 미복사
## 명령어

파일 보기

@ -12,7 +12,7 @@ Phase 2에서 초기화 예정.
## 책임
- 자체 인증/권한/감사로그
- 운영자 의사결정 (모선 확정/제외/학습)
- prediction 분석 결과 조회 API (`/api/analysis/*`)
- iran 백엔드 분석 데이터 프록시
- 관리자 화면 API
상세 설계: `.claude/plans/vast-tinkering-knuth.md`

파일 보기

@ -142,7 +142,6 @@
<goal>compile</goal>
</goals>
<configuration>
<parameters>true</parameters>
<annotationProcessorPaths>
<path>
<groupId>org.springframework.boot</groupId>
@ -162,7 +161,6 @@
<goal>testCompile</goal>
</goals>
<configuration>
<parameters>true</parameters>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>

파일 보기

@ -1,11 +1,17 @@
package gc.mda.kcg.admin;
import gc.mda.kcg.audit.AccessLogRepository;
import gc.mda.kcg.audit.AuditLogRepository;
import gc.mda.kcg.auth.LoginHistoryRepository;
import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
@ -22,23 +28,127 @@ import java.util.Map;
@RequiredArgsConstructor
public class AdminStatsController {
private final AdminStatsService adminStatsService;
private final AuditLogRepository auditLogRepository;
private final AccessLogRepository accessLogRepository;
private final LoginHistoryRepository loginHistoryRepository;
private final JdbcTemplate jdbc;
/**
* 감사 로그 통계.
* - total: 전체 건수
* - last24h: 24시간 건수
* - failed24h: 24시간 FAILED 건수
* - byAction: 액션별 카운트 (top 10)
* - hourly24: 시간별 24시간 추세
*/
@GetMapping("/audit")
@RequirePermission(resource = "admin:audit-logs", operation = "READ")
public Map<String, Object> auditStats() {
return adminStatsService.auditStats();
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", auditLogRepository.count());
result.put("last24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours'", Long.class));
result.put("failed24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours' AND result = 'FAILED'", Long.class));
List<Map<String, Object>> byAction = jdbc.queryForList(
"SELECT action_cd AS action, COUNT(*) AS count FROM kcg.auth_audit_log " +
"WHERE created_at > now() - interval '7 days' " +
"GROUP BY action_cd ORDER BY count DESC LIMIT 10");
result.put("byAction", byAction);
List<Map<String, Object>> hourly = jdbc.queryForList(
"SELECT date_trunc('hour', created_at) AS hour, COUNT(*) AS count " +
"FROM kcg.auth_audit_log " +
"WHERE created_at > now() - interval '24 hours' " +
"GROUP BY hour ORDER BY hour");
result.put("hourly24", hourly);
return result;
}
/**
* 접근 로그 통계.
* - total: 전체 건수
* - last24h: 24시간
* - error4xx, error5xx: 24시간 에러
* - avgDurationMs: 24시간 평균 응답 시간
* - topPaths: 24시간 호출 많은 경로
*/
@GetMapping("/access")
@RequirePermission(resource = "admin:access-logs", operation = "READ")
public Map<String, Object> accessStats() {
return adminStatsService.accessStats();
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", accessLogRepository.count());
result.put("last24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'", Long.class));
result.put("error4xx", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 400 AND status_code < 500", Long.class));
result.put("error5xx", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 500", Long.class));
Double avg = jdbc.queryForObject(
"SELECT AVG(duration_ms)::float FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'",
Double.class);
result.put("avgDurationMs", avg != null ? Math.round(avg * 10) / 10.0 : 0);
List<Map<String, Object>> topPaths = jdbc.queryForList(
"SELECT request_path AS path, COUNT(*) AS count, AVG(duration_ms)::int AS avg_ms " +
"FROM kcg.auth_access_log " +
"WHERE created_at > now() - interval '24 hours' AND request_path NOT LIKE '/actuator%' " +
"GROUP BY request_path ORDER BY count DESC LIMIT 10");
result.put("topPaths", topPaths);
return result;
}
/**
* 로그인 통계.
* - total: 전체 건수
* - success24h: 24시간 성공
* - failed24h: 24시간 실패
* - locked24h: 24시간 잠금
* - successRate: 성공률 (24시간 , %)
* - byUser: 사용자별 성공 카운트 (top 10)
* - daily7d: 7일 일별 추세
*/
@GetMapping("/login")
@RequirePermission(resource = "admin:login-history", operation = "READ")
public Map<String, Object> loginStats() {
return adminStatsService.loginStats();
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", loginHistoryRepository.count());
Long success24h = jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'SUCCESS'", Long.class);
Long failed24h = jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'FAILED'", Long.class);
Long locked24h = jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'LOCKED'", Long.class);
result.put("success24h", success24h);
result.put("failed24h", failed24h);
result.put("locked24h", locked24h);
long total24h = (success24h == null ? 0 : success24h) + (failed24h == null ? 0 : failed24h) + (locked24h == null ? 0 : locked24h);
double rate = total24h == 0 ? 0 : (success24h == null ? 0 : success24h) * 100.0 / total24h;
result.put("successRate", Math.round(rate * 10) / 10.0);
List<Map<String, Object>> byUser = jdbc.queryForList(
"SELECT user_acnt, COUNT(*) AS count FROM kcg.auth_login_hist " +
"WHERE login_dtm > now() - interval '7 days' AND result = 'SUCCESS' " +
"GROUP BY user_acnt ORDER BY count DESC LIMIT 10");
result.put("byUser", byUser);
List<Map<String, Object>> daily = jdbc.queryForList(
"SELECT date_trunc('day', login_dtm) AS day, " +
"COUNT(*) FILTER (WHERE result='SUCCESS') AS success, " +
"COUNT(*) FILTER (WHERE result='FAILED') AS failed, " +
"COUNT(*) FILTER (WHERE result='LOCKED') AS locked " +
"FROM kcg.auth_login_hist " +
"WHERE login_dtm > now() - interval '7 days' " +
"GROUP BY day ORDER BY day");
result.put("daily7d", daily);
return result;
}
}

파일 보기

@ -1,124 +0,0 @@
package gc.mda.kcg.admin;
import gc.mda.kcg.audit.AccessLogRepository;
import gc.mda.kcg.audit.AuditLogRepository;
import gc.mda.kcg.auth.LoginHistoryRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 시스템 관리 대시보드 메트릭 서비스.
* 감사 로그 / 접근 로그 / 로그인 이력의 집계 쿼리를 담당한다.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AdminStatsService {
private final AuditLogRepository auditLogRepository;
private final AccessLogRepository accessLogRepository;
private final LoginHistoryRepository loginHistoryRepository;
private final JdbcTemplate jdbc;
/**
* 감사 로그 통계.
*/
public Map<String, Object> auditStats() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", auditLogRepository.count());
result.put("last24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours'", Long.class));
result.put("failed24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours' AND result = 'FAILED'", Long.class));
List<Map<String, Object>> byAction = jdbc.queryForList(
"SELECT action_cd AS action, COUNT(*) AS count FROM kcg.auth_audit_log " +
"WHERE created_at > now() - interval '7 days' " +
"GROUP BY action_cd ORDER BY count DESC LIMIT 10");
result.put("byAction", byAction);
List<Map<String, Object>> hourly = jdbc.queryForList(
"SELECT date_trunc('hour', created_at) AS hour, COUNT(*) AS count " +
"FROM kcg.auth_audit_log " +
"WHERE created_at > now() - interval '24 hours' " +
"GROUP BY hour ORDER BY hour");
result.put("hourly24", hourly);
return result;
}
/**
* 접근 로그 통계.
*/
public Map<String, Object> accessStats() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", accessLogRepository.count());
result.put("last24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'", Long.class));
result.put("error4xx", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 400 AND status_code < 500", Long.class));
result.put("error5xx", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 500", Long.class));
Double avg = jdbc.queryForObject(
"SELECT AVG(duration_ms)::float FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'",
Double.class);
result.put("avgDurationMs", avg != null ? Math.round(avg * 10) / 10.0 : 0);
List<Map<String, Object>> topPaths = jdbc.queryForList(
"SELECT request_path AS path, COUNT(*) AS count, AVG(duration_ms)::int AS avg_ms " +
"FROM kcg.auth_access_log " +
"WHERE created_at > now() - interval '24 hours' AND request_path NOT LIKE '/actuator%' " +
"GROUP BY request_path ORDER BY count DESC LIMIT 10");
result.put("topPaths", topPaths);
return result;
}
/**
* 로그인 통계.
*/
public Map<String, Object> loginStats() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", loginHistoryRepository.count());
Long success24h = jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'SUCCESS'", Long.class);
Long failed24h = jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'FAILED'", Long.class);
Long locked24h = jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'LOCKED'", Long.class);
result.put("success24h", success24h);
result.put("failed24h", failed24h);
result.put("locked24h", locked24h);
long total24h = (success24h == null ? 0 : success24h) + (failed24h == null ? 0 : failed24h) + (locked24h == null ? 0 : locked24h);
double rate = total24h == 0 ? 0 : (success24h == null ? 0 : success24h) * 100.0 / total24h;
result.put("successRate", Math.round(rate * 10) / 10.0);
List<Map<String, Object>> byUser = jdbc.queryForList(
"SELECT user_acnt, COUNT(*) AS count FROM kcg.auth_login_hist " +
"WHERE login_dtm > now() - interval '7 days' AND result = 'SUCCESS' " +
"GROUP BY user_acnt ORDER BY count DESC LIMIT 10");
result.put("byUser", byUser);
List<Map<String, Object>> daily = jdbc.queryForList(
"SELECT date_trunc('day', login_dtm) AS day, " +
"COUNT(*) FILTER (WHERE result='SUCCESS') AS success, " +
"COUNT(*) FILTER (WHERE result='FAILED') AS failed, " +
"COUNT(*) FILTER (WHERE result='LOCKED') AS locked " +
"FROM kcg.auth_login_hist " +
"WHERE login_dtm > now() - interval '7 days' " +
"GROUP BY day ORDER BY day");
result.put("daily7d", daily);
return result;
}
}

파일 보기

@ -12,7 +12,7 @@ import org.springframework.context.annotation.Configuration;
public class AppProperties {
private Prediction prediction = new Prediction();
private SignalBatch signalBatch = new SignalBatch();
private IranBackend iranBackend = new IranBackend();
private Cors cors = new Cors();
private Jwt jwt = new Jwt();
@ -22,7 +22,7 @@ public class AppProperties {
}
@Getter @Setter
public static class SignalBatch {
public static class IranBackend {
private String baseUrl;
}

파일 보기

@ -1,49 +0,0 @@
package gc.mda.kcg.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
/**
* 외부 서비스용 RestClient Bean 정의.
* Proxy controller 들이 @PostConstruct 에서 ad-hoc 생성하던 RestClient 일원화한다.
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class RestClientConfig {
private final AppProperties appProperties;
/**
* prediction FastAPI 서비스 호출용.
* base-url: ${PREDICTION_BASE_URL:http://localhost:8001}
*/
@Bean
public RestClient predictionRestClient(RestClient.Builder builder) {
String baseUrl = appProperties.getPrediction().getBaseUrl();
String resolved = (baseUrl != null && !baseUrl.isBlank()) ? baseUrl : "http://localhost:8001";
log.info("predictionRestClient initialized: baseUrl={}", resolved);
return builder
.baseUrl(resolved)
.defaultHeader("Accept", "application/json")
.build();
}
/**
* signal-batch 선박 항적 서비스 호출용.
* base-url: ${SIGNAL_BATCH_BASE_URL:http://192.168.1.18:18090/signal-batch}
*/
@Bean
public RestClient signalBatchRestClient(RestClient.Builder builder) {
String baseUrl = appProperties.getSignalBatch().getBaseUrl();
String resolved = (baseUrl != null && !baseUrl.isBlank()) ? baseUrl : "http://192.168.1.18:18090/signal-batch";
log.info("signalBatchRestClient initialized: baseUrl={}", resolved);
return builder
.baseUrl(resolved)
.defaultHeader("Accept", "application/json")
.build();
}
}

파일 보기

@ -1,27 +0,0 @@
package gc.mda.kcg.domain.analysis;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
/**
* vessel_analysis_results 집계 응답.
* MMSI별 최신 row 기준으로 단일 쿼리 COUNT FILTER 집계한다.
*/
public record AnalysisStatsResponse(
long total,
long darkCount,
long spoofingCount,
long transshipCount,
long criticalCount,
long highCount,
long mediumCount,
long lowCount,
long territorialCount,
long contiguousCount,
long eezCount,
long fishingCount,
BigDecimal avgRiskScore,
OffsetDateTime windowStart,
OffsetDateTime windowEnd
) {
}

파일 보기

@ -1,64 +0,0 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.permission.annotation.RequirePermission;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.web.bind.annotation.*;
/**
* 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 조회 + 분류 API.
*
* 경로: /api/analysis/gear-collisions
* - GET / 목록 (status/severity/name 필터, hours 윈도우)
* - GET /stats status/severity 집계
* - GET /{id} 단건 상세
* - POST /{id}/resolve 분류 (REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE / REOPEN)
*/
@RestController
@RequestMapping("/api/analysis/gear-collisions")
@RequiredArgsConstructor
public class GearCollisionController {
private static final String RESOURCE = "detection:gear-collision";
private final GearIdentityCollisionService service;
@GetMapping
@RequirePermission(resource = RESOURCE, operation = "READ")
public Page<GearCollisionResponse> list(
@RequestParam(required = false) String status,
@RequestParam(required = false) String severity,
@RequestParam(required = false) String name,
@RequestParam(defaultValue = "48") int hours,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size
) {
return service.list(status, severity, name, hours,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "lastSeenAt")))
.map(GearCollisionResponse::from);
}
@GetMapping("/stats")
@RequirePermission(resource = RESOURCE, operation = "READ")
public GearCollisionStatsResponse stats(@RequestParam(defaultValue = "48") int hours) {
return service.stats(hours);
}
@GetMapping("/{id}")
@RequirePermission(resource = RESOURCE, operation = "READ")
public GearCollisionResponse get(@PathVariable Long id) {
return GearCollisionResponse.from(service.get(id));
}
@PostMapping("/{id}/resolve")
@RequirePermission(resource = RESOURCE, operation = "UPDATE")
public GearCollisionResponse resolve(
@PathVariable Long id,
@Valid @RequestBody GearCollisionResolveRequest req
) {
return GearCollisionResponse.from(service.resolve(id, req));
}
}

파일 보기

@ -1,18 +0,0 @@
package gc.mda.kcg.domain.analysis;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
/**
* gear_identity_collisions 분류(해결) 액션 요청.
*
* action: REVIEWED | CONFIRMED_ILLEGAL | FALSE_POSITIVE | REOPEN
* note : 선택 (운영자 메모)
*/
public record GearCollisionResolveRequest(
@NotBlank
@Pattern(regexp = "REVIEWED|CONFIRMED_ILLEGAL|FALSE_POSITIVE|REOPEN")
String action,
String note
) {
}

파일 보기

@ -1,57 +0,0 @@
package gc.mda.kcg.domain.analysis;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
/**
* gear_identity_collisions 조회 응답 DTO.
*/
public record GearCollisionResponse(
Long id,
String name,
String mmsiLo,
String mmsiHi,
String parentName,
Long parentVesselId,
OffsetDateTime firstSeenAt,
OffsetDateTime lastSeenAt,
Integer coexistenceCount,
Integer swapCount,
BigDecimal maxDistanceKm,
BigDecimal lastLatLo,
BigDecimal lastLonLo,
BigDecimal lastLatHi,
BigDecimal lastLonHi,
String severity,
String status,
String resolutionNote,
List<Map<String, Object>> evidence,
OffsetDateTime updatedAt
) {
public static GearCollisionResponse from(GearIdentityCollision e) {
return new GearCollisionResponse(
e.getId(),
e.getName(),
e.getMmsiLo(),
e.getMmsiHi(),
e.getParentName(),
e.getParentVesselId(),
e.getFirstSeenAt(),
e.getLastSeenAt(),
e.getCoexistenceCount(),
e.getSwapCount(),
e.getMaxDistanceKm(),
e.getLastLatLo(),
e.getLastLonLo(),
e.getLastLatHi(),
e.getLastLonHi(),
e.getSeverity(),
e.getStatus(),
e.getResolutionNote(),
e.getEvidence(),
e.getUpdatedAt()
);
}
}

파일 보기

@ -1,14 +0,0 @@
package gc.mda.kcg.domain.analysis;
import java.util.Map;
/**
* gear_identity_collisions status/severity 집계 응답.
*/
public record GearCollisionStatsResponse(
long total,
Map<String, Long> byStatus,
Map<String, Long> bySeverity,
int hours
) {
}

파일 보기

@ -1,38 +0,0 @@
package gc.mda.kcg.domain.analysis;
import java.time.OffsetDateTime;
import java.util.List;
/**
* prediction 자동 어구 탐지 결과 응답 DTO.
* gear_code / gear_judgment NOT NULL row의 핵심 필드만 노출.
*/
public record GearDetectionResponse(
Long id,
String mmsi,
OffsetDateTime analyzedAt,
String vesselType,
String gearCode,
String gearJudgment,
String permitStatus,
String riskLevel,
Integer riskScore,
String zoneCode,
List<String> violationCategories
) {
public static GearDetectionResponse from(VesselAnalysisResult e) {
return new GearDetectionResponse(
e.getId(),
e.getMmsi(),
e.getAnalyzedAt(),
e.getVesselType(),
e.getGearCode(),
e.getGearJudgment(),
e.getPermitStatus(),
e.getRiskLevel(),
e.getRiskScore(),
e.getZoneCode(),
e.getViolationCategories()
);
}
}

파일 보기

@ -1,99 +0,0 @@
package gc.mda.kcg.domain.analysis;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* gear_identity_collisions 엔티티 (GEAR_IDENTITY_COLLISION 탐지 패턴).
*
* 동일 어구 이름이 서로 다른 MMSI 같은 cycle 동시 송출되는 공존 이력.
* prediction 엔진이 5분 주기로 UPSERT, 백엔드는 조회 운영자 분류(status) 갱신.
*/
@Entity
@Table(name = "gear_identity_collisions", schema = "kcg")
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class GearIdentityCollision {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name", nullable = false, length = 200)
private String name;
@Column(name = "mmsi_lo", nullable = false, length = 20)
private String mmsiLo;
@Column(name = "mmsi_hi", nullable = false, length = 20)
private String mmsiHi;
@Column(name = "parent_name", length = 100)
private String parentName;
@Column(name = "parent_vessel_id")
private Long parentVesselId;
@Column(name = "first_seen_at", nullable = false)
private OffsetDateTime firstSeenAt;
@Column(name = "last_seen_at", nullable = false)
private OffsetDateTime lastSeenAt;
@Column(name = "coexistence_count", nullable = false)
private Integer coexistenceCount;
@Column(name = "swap_count", nullable = false)
private Integer swapCount;
@Column(name = "max_distance_km", precision = 8, scale = 2)
private BigDecimal maxDistanceKm;
@Column(name = "last_lat_lo", precision = 9, scale = 6)
private BigDecimal lastLatLo;
@Column(name = "last_lon_lo", precision = 10, scale = 6)
private BigDecimal lastLonLo;
@Column(name = "last_lat_hi", precision = 9, scale = 6)
private BigDecimal lastLatHi;
@Column(name = "last_lon_hi", precision = 10, scale = 6)
private BigDecimal lastLonHi;
@Column(name = "severity", nullable = false, length = 20)
private String severity;
@Column(name = "status", nullable = false, length = 30)
private String status;
@Column(name = "resolved_by")
private UUID resolvedBy;
@Column(name = "resolved_at")
private OffsetDateTime resolvedAt;
@Column(name = "resolution_note", columnDefinition = "text")
private String resolutionNote;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "evidence", columnDefinition = "jsonb")
private List<Map<String, Object>> evidence;
@Column(name = "created_at", nullable = false, updatable = false)
private OffsetDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
}

파일 보기

@ -1,41 +0,0 @@
package gc.mda.kcg.domain.analysis;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import java.time.OffsetDateTime;
import java.util.List;
public interface GearIdentityCollisionRepository
extends JpaRepository<GearIdentityCollision, Long>,
JpaSpecificationExecutor<GearIdentityCollision> {
Page<GearIdentityCollision> findAllByLastSeenAtAfterOrderByLastSeenAtDesc(
OffsetDateTime after, Pageable pageable);
/**
* status 카운트 집계 (hours 윈도우).
* 반환: [{status, count}, ...] Object[] {String status, Long count}
*/
@Query("""
SELECT g.status AS status, COUNT(g) AS cnt
FROM GearIdentityCollision g
WHERE g.lastSeenAt > :after
GROUP BY g.status
""")
List<Object[]> countByStatus(OffsetDateTime after);
/**
* severity 카운트 집계 (hours 윈도우).
*/
@Query("""
SELECT g.severity AS severity, COUNT(g) AS cnt
FROM GearIdentityCollision g
WHERE g.lastSeenAt > :after
GROUP BY g.severity
""")
List<Object[]> countBySeverity(OffsetDateTime after);
}

파일 보기

@ -1,133 +0,0 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.audit.annotation.Auditable;
import gc.mda.kcg.auth.AuthPrincipal;
import jakarta.persistence.EntityNotFoundException;
import jakarta.persistence.criteria.Predicate;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 조회/분류 서비스.
*
* 조회는 모두 {@link Transactional}(readOnly=true), 분류 액션은 {@link Auditable}
* 감사로그 기록. 상태 전이 화이트리스트는 REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE / REOPEN.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class GearIdentityCollisionService {
private static final String RESOURCE_TYPE = "GEAR_COLLISION";
private final GearIdentityCollisionRepository repository;
@Transactional(readOnly = true)
public Page<GearIdentityCollision> list(
String status,
String severity,
String name,
int hours,
Pageable pageable
) {
OffsetDateTime after = OffsetDateTime.now().minusHours(Math.max(hours, 1));
Specification<GearIdentityCollision> spec = (root, query, cb) -> {
List<Predicate> preds = new ArrayList<>();
preds.add(cb.greaterThan(root.get("lastSeenAt"), after));
if (status != null && !status.isBlank()) {
preds.add(cb.equal(root.get("status"), status));
}
if (severity != null && !severity.isBlank()) {
preds.add(cb.equal(root.get("severity"), severity));
}
if (name != null && !name.isBlank()) {
preds.add(cb.like(root.get("name"), "%" + name + "%"));
}
return cb.and(preds.toArray(new Predicate[0]));
};
return repository.findAll(spec, pageable);
}
@Transactional(readOnly = true)
public GearIdentityCollision get(Long id) {
return repository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("GEAR_COLLISION_NOT_FOUND: " + id));
}
@Transactional(readOnly = true)
public GearCollisionStatsResponse stats(int hours) {
OffsetDateTime after = OffsetDateTime.now().minusHours(Math.max(hours, 1));
Map<String, Long> byStatus = new HashMap<>();
long total = 0;
for (Object[] row : repository.countByStatus(after)) {
String s = (String) row[0];
long c = ((Number) row[1]).longValue();
byStatus.put(s, c);
total += c;
}
Map<String, Long> bySeverity = new HashMap<>();
for (Object[] row : repository.countBySeverity(after)) {
bySeverity.put((String) row[0], ((Number) row[1]).longValue());
}
return new GearCollisionStatsResponse(total, byStatus, bySeverity, hours);
}
@Auditable(action = "GEAR_COLLISION_RESOLVE", resourceType = RESOURCE_TYPE)
@Transactional
public GearIdentityCollision resolve(Long id, GearCollisionResolveRequest req) {
GearIdentityCollision row = repository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("GEAR_COLLISION_NOT_FOUND: " + id));
AuthPrincipal principal = currentPrincipal();
OffsetDateTime now = OffsetDateTime.now();
switch (req.action().toUpperCase()) {
case "REVIEWED" -> {
row.setStatus("REVIEWED");
row.setResolvedBy(principal != null ? principal.getUserId() : null);
row.setResolvedAt(now);
row.setResolutionNote(req.note());
}
case "CONFIRMED_ILLEGAL" -> {
row.setStatus("CONFIRMED_ILLEGAL");
row.setResolvedBy(principal != null ? principal.getUserId() : null);
row.setResolvedAt(now);
row.setResolutionNote(req.note());
}
case "FALSE_POSITIVE" -> {
row.setStatus("FALSE_POSITIVE");
row.setResolvedBy(principal != null ? principal.getUserId() : null);
row.setResolvedAt(now);
row.setResolutionNote(req.note());
}
case "REOPEN" -> {
row.setStatus("OPEN");
row.setResolvedBy(null);
row.setResolvedAt(null);
row.setResolutionNote(req.note());
}
default -> throw new IllegalArgumentException("UNKNOWN_ACTION: " + req.action());
}
row.setUpdatedAt(now);
return repository.save(row);
}
private AuthPrincipal currentPrincipal() {
var auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof AuthPrincipal p) {
return p;
}
return null;
}
}

파일 보기

@ -0,0 +1,70 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.config.AppProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import java.time.Duration;
import java.util.Map;
/**
* iran 백엔드 REST 클라이언트.
*
* 운영 환경: https://kcg.gc-si.dev (Spring Boot + Prediction 통합)
* 호출 실패 graceful degradation: null 반환 프론트에 응답.
*
* 향후 prediction 이관 IranBackendClient를 PredictionDirectClient로 교체하면 .
*/
@Slf4j
@Component
public class IranBackendClient {
private final RestClient restClient;
private final boolean enabled;
public IranBackendClient(AppProperties appProperties) {
String baseUrl = appProperties.getIranBackend().getBaseUrl();
this.enabled = baseUrl != null && !baseUrl.isBlank();
this.restClient = enabled
? RestClient.builder()
.baseUrl(baseUrl)
.defaultHeader("Accept", "application/json")
.build()
: RestClient.create();
log.info("IranBackendClient initialized: enabled={}, baseUrl={}", enabled, baseUrl);
}
public boolean isEnabled() {
return enabled;
}
/**
* GET 호출 (Map 반환). 실패 null 반환.
*/
public Map<String, Object> getJson(String path) {
if (!enabled) return null;
try {
@SuppressWarnings("unchecked")
Map<String, Object> body = restClient.get().uri(path).retrieve().body(Map.class);
return body;
} catch (RestClientException e) {
log.debug("iran 백엔드 호출 실패: {} - {}", path, e.getMessage());
return null;
}
}
/**
* 임의 타입 GET 호출.
*/
public <T> T getAs(String path, Class<T> responseType) {
if (!enabled) return null;
try {
return restClient.get().uri(path).retrieve().body(responseType);
} catch (RestClientException e) {
log.debug("iran 백엔드 호출 실패: {} - {}", path, e.getMessage());
return null;
}
}
}

파일 보기

@ -2,129 +2,66 @@ package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* Prediction FastAPI 서비스 프록시.
* 기본 baseUrl: app.prediction.base-url (개발 http://localhost:8001, 운영 http://192.168.1.19:18092)
*
* 엔드포인트:
* GET /api/prediction/health FastAPI /health
* GET /api/prediction/status FastAPI /api/v1/analysis/status
* POST /api/prediction/trigger FastAPI /api/v1/analysis/trigger
* POST /api/prediction/chat stub (Phase 9)
* GET /api/prediction/groups/{key}/history FastAPI /api/v1/groups/{key}/history?hours=
* GET /api/prediction/correlation/{key}/tracks FastAPI /api/v1/correlation/{key}/tracks?hours=&min_score=
* Prediction (Python FastAPI) 서비스 프록시.
* 현재는 stub - Phase 5에서 연결.
*/
@Slf4j
@RestController
@RequestMapping("/api/prediction")
@RequiredArgsConstructor
public class PredictionProxyController {
@Qualifier("predictionRestClient")
private final RestClient predictionClient;
private final IranBackendClient iranClient;
@GetMapping("/health")
public ResponseEntity<?> health() {
return proxyGet("/health", Map.of(
"status", "DISCONNECTED",
"message", "Prediction 서비스 미연결"
));
Map<String, Object> data = iranClient.getJson("/api/prediction/health");
if (data == null) {
return ResponseEntity.ok(Map.of(
"status", "DISCONNECTED",
"message", "Prediction 서비스 미연결 (Phase 5에서 연결 예정)"
));
}
return ResponseEntity.ok(data);
}
@GetMapping("/status")
@RequirePermission(resource = "monitoring", operation = "READ")
public ResponseEntity<?> status() {
return proxyGet("/api/v1/analysis/status", Map.of("status", "DISCONNECTED"));
Map<String, Object> data = iranClient.getJson("/api/prediction/status");
if (data == null) {
return ResponseEntity.ok(Map.of("status", "DISCONNECTED"));
}
return ResponseEntity.ok(data);
}
@PostMapping("/trigger")
@RequirePermission(resource = "ai-operations:mlops", operation = "UPDATE")
public ResponseEntity<?> trigger() {
return proxyPost("/api/v1/analysis/trigger", null,
Map.of("ok", false, "message", "Prediction 서비스 미연결"));
return ResponseEntity.ok(Map.of("ok", false, "message", "Prediction 서비스 미연결"));
}
/**
* AI 채팅 프록시 (POST) Phase 9에서 연결.
* AI 채팅 프록시 (POST).
* 향후 prediction 인증 통과 SSE 스트리밍으로 전환.
*/
@PostMapping("/chat")
@RequirePermission(resource = "ai-operations:ai-assistant", operation = "READ")
public ResponseEntity<?> chat(@RequestBody Map<String, Object> body) {
public ResponseEntity<?> chat(@org.springframework.web.bind.annotation.RequestBody Map<String, Object> body) {
// iran 백엔드에 인증 토큰이 필요하므로 현재 stub 응답
// 향후: iranClient에 Bearer 토큰 전달 + SSE 스트리밍
return ResponseEntity.ok(Map.of(
"ok", false,
"serviceAvailable", false,
"message", "Prediction 채팅 인증 연동 대기 중 (Phase 9에서 활성화 예정). 입력: "
+ body.getOrDefault("message", "")
"message", "Prediction 채팅 인증 연동 대기 중 (Phase 9에서 활성화 예정). 입력: " + body.getOrDefault("message", "")
));
}
/**
* 그룹 스냅샷 이력 (FastAPI 위임).
*/
@GetMapping("/groups/{groupKey}/history")
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> groupHistory(
@PathVariable String groupKey,
@RequestParam(defaultValue = "24") int hours
) {
return proxyGet("/api/v1/groups/" + groupKey + "/history?hours=" + hours,
Map.of("serviceAvailable", false, "groupKey", groupKey));
}
/**
* 상관관계 궤적 (FastAPI 위임).
*/
@GetMapping("/correlation/{groupKey}/tracks")
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> correlationTracks(
@PathVariable String groupKey,
@RequestParam(defaultValue = "24") int hours,
@RequestParam(name = "min_score", required = false) Double minScore
) {
String path = "/api/v1/correlation/" + groupKey + "/tracks?hours=" + hours;
if (minScore != null) path += "&min_score=" + minScore;
return proxyGet(path, Map.of("serviceAvailable", false, "groupKey", groupKey));
}
// 내부 헬퍼
@SuppressWarnings("unchecked")
private ResponseEntity<?> proxyGet(String path, Map<String, Object> fallback) {
try {
Map<String, Object> body = predictionClient.get()
.uri(path)
.retrieve()
.body(Map.class);
return ResponseEntity.ok(body != null ? body : fallback);
} catch (RestClientException e) {
log.debug("Prediction 호출 실패 GET {}: {}", path, e.getMessage());
return ResponseEntity.ok(fallback);
}
}
@SuppressWarnings("unchecked")
private ResponseEntity<?> proxyPost(String path, Object requestBody, Map<String, Object> fallback) {
try {
var spec = predictionClient.post().uri(path);
Map<String, Object> body;
if (requestBody != null) {
body = spec.body(requestBody).retrieve().body(Map.class);
} else {
body = spec.retrieve().body(Map.class);
}
return ResponseEntity.ok(body != null ? body : fallback);
} catch (RestClientException e) {
log.debug("Prediction 호출 실패 POST {}: {}", path, e.getMessage());
return ResponseEntity.ok(fallback);
}
}
}

파일 보기

@ -7,13 +7,13 @@ import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.List;
/**
* vessel_analysis_results 직접 조회 API.
* prediction이 kcgaidb에 저장한 분석 결과를 프론트엔드에 직접 제공한다 (/api/analysis/*).
* prediction이 kcgaidb에 저장한 분석 결과를 프론트엔드에 직접 제공.
* 기존 iran proxy와 별도 경로 (/api/analysis/*).
*/
@RestController
@RequestMapping("/api/analysis")
@ -33,52 +33,17 @@ public class VesselAnalysisController {
@RequestParam(required = false) String zoneCode,
@RequestParam(required = false) String riskLevel,
@RequestParam(required = false) Boolean isDark,
@RequestParam(required = false) String mmsiPrefix,
@RequestParam(required = false) Integer minRiskScore,
@RequestParam(required = false) BigDecimal minFishingPct,
@RequestParam(defaultValue = "1") int hours,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size
) {
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
return service.getAnalysisResults(
mmsi, zoneCode, riskLevel, isDark,
mmsiPrefix, minRiskScore, minFishingPct, after,
mmsi, zoneCode, riskLevel, isDark, after,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "analyzedAt"))
).map(VesselAnalysisResponse::from);
}
/**
* MMSI별 최신 row 기준 집계 (단일 쿼리 COUNT FILTER).
* - hours: 윈도우 (기본 1시간)
* - mmsiPrefix: '412' 같은 MMSI prefix 필터 (선택)
*/
@GetMapping("/stats")
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public AnalysisStatsResponse getStats(
@RequestParam(defaultValue = "1") int hours,
@RequestParam(required = false) String mmsiPrefix
) {
return service.getStats(hours, mmsiPrefix);
}
/**
* prediction 자동 어구 탐지 결과 목록.
* gear_code/gear_judgment NOT NULL row만 MMSI 중복 제거 반환.
*/
@GetMapping("/gear-detections")
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public Page<GearDetectionResponse> listGearDetections(
@RequestParam(defaultValue = "1") int hours,
@RequestParam(required = false) String mmsiPrefix,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size
) {
return service.getGearDetections(hours, mmsiPrefix,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "analyzedAt"))
).map(GearDetectionResponse::from);
}
/**
* 특정 선박 최신 분석 결과 (features 포함).
*/

파일 보기

@ -1,373 +0,0 @@
package gc.mda.kcg.domain.analysis;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import gc.mda.kcg.audit.annotation.Auditable;
import gc.mda.kcg.domain.fleet.ParentResolution;
import gc.mda.kcg.domain.fleet.repository.ParentResolutionRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 어구 그룹/상관관계 직접 DB 조회 서비스.
* kcg.group_polygon_snapshots, kcg.gear_correlation_scores,
* kcg.correlation_param_models JdbcTemplate으로 직접 쿼리.
* ParentResolution 합성은 JPA를 통해 수행.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class VesselAnalysisGroupService {
private final JdbcTemplate jdbc;
private final ParentResolutionRepository parentResolutionRepo;
private final ObjectMapper objectMapper;
/**
* 그룹 목록 (최신 스냅샷 per group_key+sub_cluster_id) + parentResolution 합성.
* 목록에서는 polygon/members를 제외하여 응답 크기를 최소화.
* polygon·members는 detail API에서만 반환.
*
* @param groupType null이면 전체, "GEAR" GEAR_IN_ZONE+GEAR_OUT_ZONE만
*/
public Map<String, Object> getGroups(String groupType) {
List<Map<String, Object>> rows;
// LATERAL JOIN으로 최신 스냅샷만 빠르게 조회 (DISTINCT ON 대비 60x 개선)
String typeFilter = "GEAR".equals(groupType)
? "AND group_type IN ('GEAR_IN_ZONE', 'GEAR_OUT_ZONE')"
: "";
String sql = """
SELECT g.*
FROM (
SELECT DISTINCT group_key, sub_cluster_id
FROM kcg.group_polygon_snapshots
WHERE snapshot_time > NOW() - INTERVAL '1 hour'
%s
) keys
JOIN LATERAL (
SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time,
ST_AsGeoJSON(polygon)::text AS polygon_geojson,
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
area_sq_nm, member_count, zone_id, zone_name, members::text, color
FROM kcg.group_polygon_snapshots
WHERE group_key = keys.group_key AND sub_cluster_id = keys.sub_cluster_id
ORDER BY snapshot_time DESC LIMIT 1
) g ON true
""".formatted(typeFilter);
try {
rows = jdbc.queryForList(sql);
} catch (Exception e) {
log.warn("group_polygon_snapshots 조회 실패: {}", e.getMessage());
return Map.of("serviceAvailable", false, "items", List.of(), "count", 0);
}
// parentResolution 인덱싱
Map<String, ParentResolution> resolutionByKey = new HashMap<>();
for (ParentResolution r : parentResolutionRepo.findAll()) {
resolutionByKey.put(r.getGroupKey() + "::" + r.getSubClusterId(), r);
}
// correlation_scores 실시간 최고 점수 + 후보 일괄 조회
Map<String, Object[]> corrTopByGroup = new HashMap<>();
try {
List<Map<String, Object>> corrRows = jdbc.queryForList("""
SELECT group_key, sub_cluster_id,
MAX(current_score) AS max_score,
COUNT(*) FILTER (WHERE current_score > 0.3) AS candidate_count
FROM kcg.gear_correlation_scores
WHERE model_id = (SELECT id FROM kcg.correlation_param_models WHERE is_default = true LIMIT 1)
AND freeze_state = 'ACTIVE'
GROUP BY group_key, sub_cluster_id
""");
for (Map<String, Object> cr : corrRows) {
String ck = cr.get("group_key") + "::" + cr.get("sub_cluster_id");
corrTopByGroup.put(ck, new Object[]{cr.get("max_score"), cr.get("candidate_count")});
}
} catch (Exception e) {
log.debug("correlation top score 조회 실패: {}", e.getMessage());
}
List<Map<String, Object>> items = new ArrayList<>();
for (Map<String, Object> row : rows) {
Map<String, Object> item = buildGroupItem(row);
String groupKey = String.valueOf(row.get("group_key"));
Object subRaw = row.get("sub_cluster_id");
Integer sub = subRaw == null ? null : Integer.valueOf(subRaw.toString());
String compositeKey = groupKey + "::" + sub;
ParentResolution res = resolutionByKey.get(compositeKey);
// 실시간 최고 점수 (correlation_scores)
Object[] corrTop = corrTopByGroup.get(compositeKey);
if (corrTop != null) {
item.put("liveTopScore", corrTop[0]);
item.put("candidateCount", corrTop[1]);
}
if (res != null) {
Map<String, Object> resolution = new LinkedHashMap<>();
resolution.put("status", res.getStatus());
resolution.put("selectedParentMmsi", res.getSelectedParentMmsi());
resolution.put("selectedParentName", res.getSelectedParentName());
resolution.put("topScore", res.getTopScore());
resolution.put("confidence", res.getConfidence());
resolution.put("secondScore", res.getSecondScore());
resolution.put("scoreMargin", res.getScoreMargin());
resolution.put("decisionSource", res.getDecisionSource());
resolution.put("stableCycles", res.getStableCycles());
resolution.put("approvedAt", res.getApprovedAt());
resolution.put("manualComment", res.getManualComment());
item.put("resolution", resolution);
} else {
item.put("resolution", null);
}
items.add(item);
}
return Map.of("serviceAvailable", true, "count", items.size(), "items", items);
}
/**
* 단일 그룹 상세 (최신 스냅샷 + 24시간 이력).
*/
public Map<String, Object> getGroupDetail(String groupKey) {
String latestSql = """
SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time,
ST_AsGeoJSON(polygon)::text AS polygon_geojson,
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
area_sq_nm, member_count, zone_id, zone_name, members::text, color
FROM kcg.group_polygon_snapshots
WHERE group_key = ?
ORDER BY snapshot_time DESC
LIMIT 1
""";
String historySql = """
SELECT snapshot_time,
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
member_count,
ST_AsGeoJSON(polygon)::text AS polygon_geojson,
members::text, sub_cluster_id, area_sq_nm
FROM kcg.group_polygon_snapshots
WHERE group_key = ? AND snapshot_time > NOW() - INTERVAL '24 hours'
ORDER BY snapshot_time ASC
""";
try {
List<Map<String, Object>> latestRows = jdbc.queryForList(latestSql, groupKey);
if (latestRows.isEmpty()) {
return Map.of("serviceAvailable", false, "groupKey", groupKey,
"message", "그룹을 찾을 수 없습니다.");
}
Map<String, Object> latest = buildGroupItem(latestRows.get(0));
List<Map<String, Object>> historyRows = jdbc.queryForList(historySql, groupKey);
List<Map<String, Object>> history = new ArrayList<>();
for (Map<String, Object> row : historyRows) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("snapshotTime", row.get("snapshot_time"));
entry.put("centerLat", row.get("center_lat"));
entry.put("centerLon", row.get("center_lon"));
entry.put("memberCount", row.get("member_count"));
entry.put("polygon", parseGeoJson((String) row.get("polygon_geojson")));
entry.put("members", parseJsonArray((String) row.get("members")));
entry.put("subClusterId", row.get("sub_cluster_id"));
entry.put("areaSqNm", row.get("area_sq_nm"));
history.add(entry);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("groupKey", groupKey);
result.put("latest", latest);
result.put("history", history);
return result;
} catch (Exception e) {
log.warn("getGroupDetail 조회 실패 groupKey={}: {}", groupKey, e.getMessage());
return Map.of("serviceAvailable", false, "groupKey", groupKey);
}
}
/**
* 그룹별 상관관계 점수 목록 (활성 모델 기준).
*/
public Map<String, Object> getGroupCorrelations(String groupKey, Double minScore) {
String sql = """
SELECT s.target_mmsi, s.target_type, s.target_name,
s.current_score AS score, s.streak_count AS streak,
s.observation_count AS observations, s.freeze_state,
0 AS shadow_bonus, s.sub_cluster_id,
s.proximity_ratio, s.visit_score, s.heading_coherence,
m.id AS model_id, m.name AS model_name, m.is_default
FROM kcg.gear_correlation_scores s
JOIN kcg.correlation_param_models m ON s.model_id = m.id
WHERE s.group_key = ? AND m.is_active = true
AND (? IS NULL OR s.current_score >= ?)
ORDER BY s.current_score DESC
""";
try {
List<Map<String, Object>> rows = jdbc.queryForList(
sql, groupKey, minScore, minScore);
List<Map<String, Object>> items = new ArrayList<>();
for (Map<String, Object> row : rows) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("targetMmsi", row.get("target_mmsi"));
item.put("targetType", row.get("target_type"));
item.put("targetName", row.get("target_name"));
item.put("score", row.get("score"));
item.put("streak", row.get("streak"));
item.put("observations", row.get("observations"));
item.put("freezeState", row.get("freeze_state"));
item.put("subClusterId", row.get("sub_cluster_id"));
item.put("proximityRatio", row.get("proximity_ratio"));
item.put("visitScore", row.get("visit_score"));
item.put("headingCoherence", row.get("heading_coherence"));
item.put("modelId", row.get("model_id"));
item.put("modelName", row.get("model_name"));
item.put("isDefault", row.get("is_default"));
items.add(item);
}
return Map.of("groupKey", groupKey, "count", items.size(), "items", items);
} catch (Exception e) {
log.warn("getGroupCorrelations 조회 실패 groupKey={}: {}", groupKey, e.getMessage());
return Map.of("serviceAvailable", false, "groupKey", groupKey, "items", List.of(), "count", 0);
}
}
/**
* 후보 상세 raw metrics (최근 N건).
*/
public Map<String, Object> getCandidateMetrics(String groupKey, String targetMmsi) {
String sql = """
SELECT observed_at, proximity_ratio, visit_score, activity_sync,
dtw_similarity, speed_correlation, heading_coherence, drift_similarity,
shadow_stay, shadow_return, gear_group_active_ratio
FROM kcg.gear_correlation_raw_metrics
WHERE group_key = ? AND target_mmsi = ?
ORDER BY observed_at DESC LIMIT 20
""";
try {
List<Map<String, Object>> rows = jdbc.queryForList(sql, groupKey, targetMmsi);
List<Map<String, Object>> items = new ArrayList<>();
for (Map<String, Object> row : rows) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("observedAt", row.get("observed_at"));
item.put("proximityRatio", row.get("proximity_ratio"));
item.put("visitScore", row.get("visit_score"));
item.put("activitySync", row.get("activity_sync"));
item.put("dtwSimilarity", row.get("dtw_similarity"));
item.put("speedCorrelation", row.get("speed_correlation"));
item.put("headingCoherence", row.get("heading_coherence"));
item.put("driftSimilarity", row.get("drift_similarity"));
item.put("shadowStay", row.get("shadow_stay"));
item.put("shadowReturn", row.get("shadow_return"));
item.put("gearGroupActiveRatio", row.get("gear_group_active_ratio"));
items.add(item);
}
return Map.of("groupKey", groupKey, "targetMmsi", targetMmsi, "count", items.size(), "items", items);
} catch (Exception e) {
log.warn("getCandidateMetrics 실패: {}", e.getMessage());
return Map.of("groupKey", groupKey, "targetMmsi", targetMmsi, "items", List.of(), "count", 0);
}
}
/**
* 모선 확정/제외 처리.
*/
@Auditable(action = "PARENT_RESOLVE", resourceType = "GEAR_GROUP")
public Map<String, Object> resolveParent(String groupKey, String action, String targetMmsi, String comment) {
try {
// 먼저 resolution 존재 확인
List<Map<String, Object>> existing = jdbc.queryForList(
"SELECT id, sub_cluster_id FROM kcg.gear_group_parent_resolution WHERE group_key = ? LIMIT 1",
groupKey);
if (existing.isEmpty()) {
return Map.of("ok", false, "message", "resolution을 찾을 수 없습니다.");
}
Long id = ((Number) existing.get(0).get("id")).longValue();
if ("confirm".equals(action)) {
jdbc.update("""
UPDATE kcg.gear_group_parent_resolution
SET status = 'MANUAL_CONFIRMED', selected_parent_mmsi = ?,
manual_comment = ?, approved_at = NOW(), updated_at = NOW()
WHERE id = ?
""", targetMmsi, comment, id);
} else if ("reject".equals(action)) {
jdbc.update("""
UPDATE kcg.gear_group_parent_resolution
SET rejected_candidate_mmsi = ?, manual_comment = ?,
rejected_at = NOW(), updated_at = NOW()
WHERE id = ?
""", targetMmsi, comment, id);
} else {
return Map.of("ok", false, "message", "알 수 없는 액션: " + action);
}
return Map.of("ok", true, "action", action, "groupKey", groupKey, "targetMmsi", targetMmsi);
} catch (Exception e) {
log.warn("resolveParent 실패: {}", e.getMessage());
return Map.of("ok", false, "message", e.getMessage());
}
}
// 내부 헬퍼
private Map<String, Object> buildGroupItem(Map<String, Object> row) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("groupType", row.get("group_type"));
item.put("groupKey", row.get("group_key"));
item.put("groupLabel", row.get("group_label"));
item.put("subClusterId", row.get("sub_cluster_id"));
item.put("snapshotTime", row.get("snapshot_time"));
item.put("polygon", parseGeoJson((String) row.get("polygon_geojson")));
item.put("centerLat", row.get("center_lat"));
item.put("centerLon", row.get("center_lon"));
item.put("areaSqNm", row.get("area_sq_nm"));
item.put("memberCount", row.get("member_count"));
item.put("zoneId", row.get("zone_id"));
item.put("zoneName", row.get("zone_name"));
item.put("members", parseJsonArray((String) row.get("members")));
item.put("color", row.get("color"));
item.put("resolution", null);
item.put("candidateCount", null);
return item;
}
private Map<String, Object> parseGeoJson(String geoJson) {
if (geoJson == null || geoJson.isBlank()) return null;
try {
return objectMapper.readValue(geoJson, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
log.debug("GeoJSON 파싱 실패: {}", e.getMessage());
return null;
}
}
private List<Object> parseJsonArray(String json) {
if (json == null || json.isBlank()) return List.of();
try {
return objectMapper.readValue(json, new TypeReference<List<Object>>() {});
} catch (Exception e) {
log.debug("JSON 배열 파싱 실패: {}", e.getMessage());
return List.of();
}
}
}

파일 보기

@ -1,70 +1,109 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.domain.fleet.ParentResolution;
import gc.mda.kcg.domain.fleet.repository.ParentResolutionRepository;
import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import java.util.List;
import java.util.Map;
import java.util.*;
/**
* 분석 데이터 API group_polygon_snapshots / gear_correlation_scores 직접 DB 조회
* + signal-batch 선박 항적 프록시.
* iran 백엔드 분석 데이터 프록시 + 자체 DB 운영자 결정 합성 (HYBRID).
*
* 라우팅:
* GET /api/vessel-analysis/groups 어구/선단 그룹 + parentResolution 합성
* GET /api/vessel-analysis/groups/{key}/detail 단일 그룹 상세 + 24h 이력
* GET /api/vessel-analysis 전체 분석결과 + 통계 (단순 프록시)
* GET /api/vessel-analysis/groups 어구/선단 그룹 + parentResolution 합성
* GET /api/vessel-analysis/groups/{key}/detail 단일 그룹 상세
* GET /api/vessel-analysis/groups/{key}/correlations 상관관계 점수
*
* 권한: detection:gear-detection (READ)
* 권한: detection / detection:gear-detection (READ)
*/
@Slf4j
@RestController
@RequestMapping("/api/vessel-analysis")
@RequiredArgsConstructor
public class VesselAnalysisProxyController {
private final VesselAnalysisGroupService groupService;
@Qualifier("signalBatchRestClient")
private final RestClient signalBatchClient;
private final IranBackendClient iranClient;
private final ParentResolutionRepository resolutionRepository;
@GetMapping
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public ResponseEntity<?> getVesselAnalysis() {
// vessel_analysis_results 직접 조회는 /api/analysis/vessels 사용.
// 엔드포인트는 하위 호환을 위해 구조 반환.
return ResponseEntity.ok(Map.of(
"serviceAvailable", true,
"message", "vessel_analysis_results는 /api/analysis/vessels 에서 제공됩니다.",
"items", List.of(),
"stats", Map.of(),
"count", 0
));
Map<String, Object> data = iranClient.getJson("/api/vessel-analysis");
if (data == null) {
return ResponseEntity.ok(Map.of(
"serviceAvailable", false,
"message", "iran 백엔드 미연결",
"items", List.of(),
"stats", Map.of(),
"count", 0
));
}
// 통과 + 메타데이터 추가
Map<String, Object> enriched = new LinkedHashMap<>(data);
enriched.put("serviceAvailable", true);
return ResponseEntity.ok(enriched);
}
/**
* 그룹 목록 + 자체 DB의 parentResolution 합성.
* 그룹에 resolution 필드 추가.
*/
@GetMapping("/groups")
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> getGroups(
@RequestParam(required = false) String groupType
) {
Map<String, Object> result = groupService.getGroups(groupType);
public ResponseEntity<?> getGroups() {
Map<String, Object> data = iranClient.getJson("/api/vessel-analysis/groups");
if (data == null) {
return ResponseEntity.ok(Map.of(
"serviceAvailable", false,
"items", List.of(),
"count", 0
));
}
@SuppressWarnings("unchecked")
List<Map<String, Object>> items = (List<Map<String, Object>>) data.getOrDefault("items", List.of());
// 자체 DB의 모든 resolution을 group_key로 인덱싱
Map<String, ParentResolution> resolutionByKey = new HashMap<>();
for (ParentResolution r : resolutionRepository.findAll()) {
resolutionByKey.put(r.getGroupKey() + "::" + r.getSubClusterId(), r);
}
// 그룹에 합성
for (Map<String, Object> item : items) {
String groupKey = String.valueOf(item.get("groupKey"));
Object subRaw = item.get("subClusterId");
Integer sub = subRaw == null ? null : Integer.valueOf(subRaw.toString());
ParentResolution res = resolutionByKey.get(groupKey + "::" + sub);
if (res != null) {
Map<String, Object> resolution = new LinkedHashMap<>();
resolution.put("status", res.getStatus());
resolution.put("selectedParentMmsi", res.getSelectedParentMmsi());
resolution.put("approvedAt", res.getApprovedAt());
resolution.put("manualComment", res.getManualComment());
item.put("resolution", resolution);
} else {
item.put("resolution", null);
}
}
Map<String, Object> result = new LinkedHashMap<>(data);
result.put("items", items);
result.put("serviceAvailable", true);
return ResponseEntity.ok(result);
}
@GetMapping("/groups/{groupKey}/detail")
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> getGroupDetail(@PathVariable String groupKey) {
Map<String, Object> result = groupService.getGroupDetail(groupKey);
return ResponseEntity.ok(result);
Map<String, Object> data = iranClient.getJson("/api/vessel-analysis/groups/" + groupKey + "/detail");
if (data == null) {
return ResponseEntity.ok(Map.of("serviceAvailable", false, "groupKey", groupKey));
}
return ResponseEntity.ok(data);
}
@GetMapping("/groups/{groupKey}/correlations")
@ -73,57 +112,12 @@ public class VesselAnalysisProxyController {
@PathVariable String groupKey,
@RequestParam(required = false) Double minScore
) {
Map<String, Object> result = groupService.getGroupCorrelations(groupKey, minScore);
return ResponseEntity.ok(result);
}
/**
* 후보 상세 raw metrics (최근 20건 관측 이력).
*/
@GetMapping("/groups/{groupKey}/candidates/{targetMmsi}/metrics")
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> getCandidateMetrics(
@PathVariable String groupKey,
@PathVariable String targetMmsi
) {
return ResponseEntity.ok(groupService.getCandidateMetrics(groupKey, targetMmsi));
}
/**
* 모선 확정/제외.
* POST body: { "action": "confirm"|"reject", "targetMmsi": "...", "comment": "..." }
*/
@PostMapping("/groups/{groupKey}/resolve")
@RequirePermission(resource = "detection:gear-detection", operation = "UPDATE")
public ResponseEntity<?> resolveParent(
@PathVariable String groupKey,
@RequestBody Map<String, String> body
) {
String action = body.getOrDefault("action", "");
String targetMmsi = body.getOrDefault("targetMmsi", "");
String comment = body.getOrDefault("comment", "");
return ResponseEntity.ok(groupService.resolveParent(groupKey, action, targetMmsi, comment));
}
/**
* 선박 항적 일괄 조회 (signal-batch 프록시).
* POST /api/vessel-analysis/tracks signal-batch /api/v2/tracks/vessels
*/
@PostMapping("/tracks")
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> vesselTracks(@RequestBody Map<String, Object> body) {
try {
String json = signalBatchClient.post()
.uri("/api/v2/tracks/vessels")
.body(body)
.retrieve()
.body(String.class);
return ResponseEntity.ok()
.header("Content-Type", "application/json")
.body(json != null ? json : "[]");
} catch (RestClientException e) {
log.warn("signal-batch 항적 조회 실패: {}", e.getMessage());
return ResponseEntity.ok("[]");
String path = "/api/vessel-analysis/groups/" + groupKey + "/correlations";
if (minScore != null) path += "?minScore=" + minScore;
Map<String, Object> data = iranClient.getJson(path);
if (data == null) {
return ResponseEntity.ok(Map.of("serviceAvailable", false, "groupKey", groupKey));
}
return ResponseEntity.ok(data);
}
}

파일 보기

@ -9,7 +9,6 @@ import org.springframework.data.repository.query.Param;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
@ -58,61 +57,4 @@ public interface VesselAnalysisRepository
""")
Page<VesselAnalysisResult> findLatestTransshipSuspects(
@Param("after") OffsetDateTime after, Pageable pageable);
/**
* 어구 탐지 결과 목록 (gear_code/judgment NOT NULL, MMSI 중복 제거).
* mmsiPrefix null 이면 전체, 아니면 LIKE ':prefix%'.
*/
@Query("""
SELECT v FROM VesselAnalysisResult v
WHERE v.gearCode IS NOT NULL
AND v.gearJudgment IS NOT NULL
AND v.analyzedAt > :after
AND (:mmsiPrefix IS NULL OR v.mmsi LIKE CONCAT(:mmsiPrefix, '%'))
AND v.analyzedAt = (
SELECT MAX(v2.analyzedAt) FROM VesselAnalysisResult v2
WHERE v2.mmsi = v.mmsi AND v2.analyzedAt > :after
)
ORDER BY v.analyzedAt DESC
""")
Page<VesselAnalysisResult> findLatestGearDetections(
@Param("after") OffsetDateTime after,
@Param("mmsiPrefix") String mmsiPrefix,
Pageable pageable);
/**
* MMSI별 최신 row 기준 집계 (단일 쿼리 COUNT FILTER).
* mmsiPrefix null 이면 전체.
* 반환 Map : total, dark_count, spoofing_count, transship_count,
* critical_count, high_count, medium_count, low_count,
* territorial_count, contiguous_count, eez_count,
* fishing_count, avg_risk_score
*/
@Query(value = """
WITH latest AS (
SELECT DISTINCT ON (mmsi) *
FROM kcg.vessel_analysis_results
WHERE analyzed_at > :after
AND (:mmsiPrefix IS NULL OR mmsi LIKE :mmsiPrefix || '%')
ORDER BY mmsi, analyzed_at DESC
)
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE is_dark = TRUE) AS dark_count,
COUNT(*) FILTER (WHERE spoofing_score >= 0.3) AS spoofing_count,
COUNT(*) FILTER (WHERE transship_suspect = TRUE) AS transship_count,
COUNT(*) FILTER (WHERE risk_level = 'CRITICAL') AS critical_count,
COUNT(*) FILTER (WHERE risk_level = 'HIGH') AS high_count,
COUNT(*) FILTER (WHERE risk_level = 'MEDIUM') AS medium_count,
COUNT(*) FILTER (WHERE risk_level = 'LOW') AS low_count,
COUNT(*) FILTER (WHERE zone_code = 'TERRITORIAL_SEA') AS territorial_count,
COUNT(*) FILTER (WHERE zone_code = 'CONTIGUOUS_ZONE') AS contiguous_count,
COUNT(*) FILTER (WHERE zone_code = 'EEZ_OR_BEYOND') AS eez_count,
COUNT(*) FILTER (WHERE fishing_pct > 0.5) AS fishing_count,
COALESCE(AVG(risk_score), 0)::NUMERIC(5,2) AS avg_risk_score
FROM latest
""", nativeQuery = true)
Map<String, Object> aggregateStats(
@Param("after") OffsetDateTime after,
@Param("mmsiPrefix") String mmsiPrefix);
}

파일 보기

@ -2,7 +2,6 @@ package gc.mda.kcg.domain.analysis;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
/**
@ -17,7 +16,6 @@ public record VesselAnalysisResponse(
String vesselType,
BigDecimal confidence,
BigDecimal fishingPct,
Integer clusterId,
String season,
// 위치
Double lat,
@ -26,14 +24,11 @@ public record VesselAnalysisResponse(
BigDecimal distToBaselineNm,
// 행동
String activityState,
BigDecimal ucafScore,
BigDecimal ucftScore,
// 위협
Boolean isDark,
Integer gapDurationMin,
String darkPattern,
BigDecimal spoofingScore,
BigDecimal bd09OffsetM,
Integer speedJumpCount,
// 환적
Boolean transshipSuspect,
@ -50,7 +45,6 @@ public record VesselAnalysisResponse(
String gearCode,
String gearJudgment,
String permitStatus,
List<String> violationCategories,
// features
Map<String, Object> features
) {
@ -62,20 +56,16 @@ public record VesselAnalysisResponse(
e.getVesselType(),
e.getConfidence(),
e.getFishingPct(),
e.getClusterId(),
e.getSeason(),
e.getLat(),
e.getLon(),
e.getZoneCode(),
e.getDistToBaselineNm(),
e.getActivityState(),
e.getUcafScore(),
e.getUcftScore(),
e.getIsDark(),
e.getGapDurationMin(),
e.getDarkPattern(),
e.getSpoofingScore(),
e.getBd09OffsetM(),
e.getSpeedJumpCount(),
e.getTransshipSuspect(),
e.getTransshipPairMmsi(),
@ -88,7 +78,6 @@ public record VesselAnalysisResponse(
e.getGearCode(),
e.getGearJudgment(),
e.getPermitStatus(),
e.getViolationCategories(),
e.getFeatures()
);
}

파일 보기

@ -7,7 +7,6 @@ import org.hibernate.type.SqlTypes;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
/**
@ -126,10 +125,6 @@ public class VesselAnalysisResult {
@Column(name = "permit_status", length = 20)
private String permitStatus;
@JdbcTypeCode(SqlTypes.ARRAY)
@Column(name = "violation_categories", columnDefinition = "text[]")
private List<String> violationCategories;
// features JSONB
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "features", columnDefinition = "jsonb")

파일 보기

@ -7,10 +7,8 @@ import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
/**
* vessel_analysis_results 직접 조회 서비스.
@ -28,10 +26,9 @@ public class VesselAnalysisService {
*/
public Page<VesselAnalysisResult> getAnalysisResults(
String mmsi, String zoneCode, String riskLevel, Boolean isDark,
String mmsiPrefix, Integer minRiskScore, BigDecimal minFishingPct,
OffsetDateTime after, Pageable pageable
) {
Specification<VesselAnalysisResult> spec = (root, query, cb) -> cb.conjunction();
Specification<VesselAnalysisResult> spec = Specification.where(null);
if (after != null) {
spec = spec.and((root, query, cb) -> cb.greaterThan(root.get("analyzedAt"), after));
@ -39,10 +36,6 @@ public class VesselAnalysisService {
if (mmsi != null && !mmsi.isBlank()) {
spec = spec.and((root, query, cb) -> cb.equal(root.get("mmsi"), mmsi));
}
if (mmsiPrefix != null && !mmsiPrefix.isBlank()) {
final String prefix = mmsiPrefix;
spec = spec.and((root, query, cb) -> cb.like(root.get("mmsi"), prefix + "%"));
}
if (zoneCode != null && !zoneCode.isBlank()) {
spec = spec.and((root, query, cb) -> cb.equal(root.get("zoneCode"), zoneCode));
}
@ -52,66 +45,10 @@ public class VesselAnalysisService {
if (isDark != null && isDark) {
spec = spec.and((root, query, cb) -> cb.isTrue(root.get("isDark")));
}
if (minRiskScore != null) {
spec = spec.and((root, query, cb) -> cb.greaterThanOrEqualTo(root.get("riskScore"), minRiskScore));
}
if (minFishingPct != null) {
spec = spec.and((root, query, cb) -> cb.greaterThan(root.get("fishingPct"), minFishingPct));
}
return repository.findAll(spec, pageable);
}
/**
* MMSI별 최신 row 기준 집계 (단일 쿼리).
*/
public AnalysisStatsResponse getStats(int hours, String mmsiPrefix) {
OffsetDateTime windowEnd = OffsetDateTime.now();
OffsetDateTime windowStart = windowEnd.minusHours(hours);
String prefix = (mmsiPrefix != null && !mmsiPrefix.isBlank()) ? mmsiPrefix : null;
Map<String, Object> row = repository.aggregateStats(windowStart, prefix);
return new AnalysisStatsResponse(
longOf(row, "total"),
longOf(row, "dark_count"),
longOf(row, "spoofing_count"),
longOf(row, "transship_count"),
longOf(row, "critical_count"),
longOf(row, "high_count"),
longOf(row, "medium_count"),
longOf(row, "low_count"),
longOf(row, "territorial_count"),
longOf(row, "contiguous_count"),
longOf(row, "eez_count"),
longOf(row, "fishing_count"),
bigDecimalOf(row, "avg_risk_score"),
windowStart,
windowEnd
);
}
/**
* prediction 자동 어구 탐지 결과 목록.
*/
public Page<VesselAnalysisResult> getGearDetections(int hours, String mmsiPrefix, Pageable pageable) {
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
String prefix = (mmsiPrefix != null && !mmsiPrefix.isBlank()) ? mmsiPrefix : null;
return repository.findLatestGearDetections(after, prefix, pageable);
}
private static long longOf(Map<String, Object> row, String key) {
Object v = row.get(key);
if (v == null) return 0L;
return ((Number) v).longValue();
}
private static BigDecimal bigDecimalOf(Map<String, Object> row, String key) {
Object v = row.get(key);
if (v == null) return BigDecimal.ZERO;
if (v instanceof BigDecimal bd) return bd;
return new BigDecimal(v.toString());
}
/**
* 특정 선박 최신 분석 결과.
*/

파일 보기

@ -1,6 +1,5 @@
package gc.mda.kcg.domain.enforcement;
import gc.mda.kcg.audit.annotation.Auditable;
import gc.mda.kcg.domain.enforcement.dto.CreatePlanRequest;
import gc.mda.kcg.domain.enforcement.dto.CreateRecordRequest;
import gc.mda.kcg.domain.enforcement.dto.UpdateRecordRequest;
@ -49,7 +48,6 @@ public class EnforcementService {
}
@Transactional
@Auditable(action = "ENFORCEMENT_CREATE", resourceType = "ENFORCEMENT")
public EnforcementRecord createRecord(CreateRecordRequest req) {
EnforcementRecord record = EnforcementRecord.builder()
.enfUid(generateEnfUid())
@ -89,7 +87,6 @@ public class EnforcementService {
}
@Transactional
@Auditable(action = "ENFORCEMENT_UPDATE", resourceType = "ENFORCEMENT")
public EnforcementRecord updateRecord(Long id, UpdateRecordRequest req) {
EnforcementRecord record = getRecord(id);
if (req.result() != null) record.setResult(req.result());
@ -110,7 +107,6 @@ public class EnforcementService {
}
@Transactional
@Auditable(action = "ENFORCEMENT_PLAN_CREATE", resourceType = "ENFORCEMENT")
public EnforcementPlan createPlan(CreatePlanRequest req) {
EnforcementPlan plan = EnforcementPlan.builder()
.planUid("PLN-" + LocalDate.now().format(UID_DATE_FMT) + "-" + UUID.randomUUID().toString().substring(0, 4).toUpperCase())

파일 보기

@ -2,9 +2,12 @@ package gc.mda.kcg.domain.event;
import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 알림 조회 API.
* 예측 이벤트에 대해 발송된 알림(SMS, 푸시 ) 이력을 제공.
@ -14,7 +17,7 @@ import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor
public class AlertController {
private final AlertService alertService;
private final PredictionAlertRepository alertRepository;
/**
* 알림 목록 조회 (페이징). eventId 파라미터로 특정 이벤트의 알림만 필터 가능.
@ -27,8 +30,10 @@ public class AlertController {
@RequestParam(defaultValue = "20") int size
) {
if (eventId != null) {
return alertService.findByEventId(eventId);
return alertRepository.findByEventIdOrderBySentAtDesc(eventId);
}
return alertService.findAll(PageRequest.of(page, size));
return alertRepository.findAllByOrderBySentAtDesc(
PageRequest.of(page, size)
);
}
}

파일 보기

@ -1,29 +0,0 @@
package gc.mda.kcg.domain.event;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 예측 알림 조회 서비스.
* 이벤트에 대해 발송된 알림(SMS/푸시 ) 이력을 조회한다.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AlertService {
private final PredictionAlertRepository alertRepository;
public List<PredictionAlert> findByEventId(Long eventId) {
return alertRepository.findByEventIdOrderBySentAtDesc(eventId);
}
public Page<PredictionAlert> findAll(Pageable pageable) {
return alertRepository.findAllByOrderBySentAtDesc(pageable);
}
}

파일 보기

@ -16,8 +16,8 @@ import java.time.OffsetDateTime;
import java.util.List;
/**
* 모선 워크플로우 핵심 서비스.
* - 후보 데이터: prediction이 kcgaidb 저장한 분석 결과를 참조
* 모선 워크플로우 핵심 서비스 (HYBRID).
* - 후보 데이터: iran 백엔드 API 호출 (현재 stub)
* - 운영자 결정: 자체 DB (gear_group_parent_resolution )
*
* 모든 쓰기 액션은 @Auditable로 감사로그 자동 기록.

파일 보기

@ -10,7 +10,7 @@ import java.util.UUID;
/**
* 모선 확정 결과 (운영자 의사결정).
* prediction이 생성한 후보 데이터 별도로 운영자 결정만 자체 DB에 저장.
* iran 백엔드의 후보 데이터(prediction이 생성) 별도로 운영자 결정만 자체 DB에 저장.
*/
@Entity
@Table(name = "gear_group_parent_resolution", schema = "kcg",
@ -34,27 +34,6 @@ public class ParentResolution {
@Column(name = "selected_parent_mmsi", length = 20)
private String selectedParentMmsi;
@Column(name = "selected_parent_name", length = 200)
private String selectedParentName;
@Column(name = "confidence", columnDefinition = "numeric(7,4)")
private java.math.BigDecimal confidence;
@Column(name = "top_score", columnDefinition = "numeric(7,4)")
private java.math.BigDecimal topScore;
@Column(name = "second_score", columnDefinition = "numeric(7,4)")
private java.math.BigDecimal secondScore;
@Column(name = "score_margin", columnDefinition = "numeric(7,4)")
private java.math.BigDecimal scoreMargin;
@Column(name = "decision_source", length = 30)
private String decisionSource;
@Column(name = "stable_cycles", columnDefinition = "integer default 0")
private Integer stableCycles;
@Column(name = "rejected_candidate_mmsi", length = 20)
private String rejectedCandidateMmsi;

파일 보기

@ -4,7 +4,9 @@ import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@ -16,7 +18,10 @@ import java.util.List;
@RequiredArgsConstructor
public class MasterDataController {
private final MasterDataService masterDataService;
private final CodeMasterRepository codeMasterRepository;
private final GearTypeRepository gearTypeRepository;
private final PatrolShipRepository patrolShipRepository;
private final VesselPermitRepository vesselPermitRepository;
// ========================================================================
// 코드 마스터 (인증만, 권한 불필요)
@ -24,12 +29,12 @@ public class MasterDataController {
@GetMapping("/api/codes")
public List<CodeMaster> listCodes(@RequestParam String group) {
return masterDataService.listCodes(group);
return codeMasterRepository.findByGroupCodeAndIsActiveTrueOrderBySortOrder(group);
}
@GetMapping("/api/codes/{codeId}/children")
public List<CodeMaster> listChildren(@PathVariable String codeId) {
return masterDataService.listChildren(codeId);
return codeMasterRepository.findByParentIdOrderBySortOrder(codeId);
}
// ========================================================================
@ -38,24 +43,35 @@ public class MasterDataController {
@GetMapping("/api/gear-types")
public List<GearType> listGearTypes() {
return masterDataService.listGearTypes();
return gearTypeRepository.findByIsActiveTrueOrderByDisplayOrder();
}
@GetMapping("/api/gear-types/{gearCode}")
public GearType getGearType(@PathVariable String gearCode) {
return masterDataService.getGearType(gearCode);
return gearTypeRepository.findById(gearCode)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"어구 유형을 찾을 수 없습니다: " + gearCode));
}
@PostMapping("/api/gear-types")
@RequirePermission(resource = "admin:system-config", operation = "CREATE")
public GearType createGearType(@RequestBody GearType gearType) {
return masterDataService.createGearType(gearType);
if (gearTypeRepository.existsById(gearType.getGearCode())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"이미 존재하는 어구 코드입니다: " + gearType.getGearCode());
}
return gearTypeRepository.save(gearType);
}
@PutMapping("/api/gear-types/{gearCode}")
@RequirePermission(resource = "admin:system-config", operation = "UPDATE")
public GearType updateGearType(@PathVariable String gearCode, @RequestBody GearType gearType) {
return masterDataService.updateGearType(gearCode, gearType);
if (!gearTypeRepository.existsById(gearCode)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
"어구 유형을 찾을 수 없습니다: " + gearCode);
}
gearType.setGearCode(gearCode);
return gearTypeRepository.save(gearType);
}
// ========================================================================
@ -65,7 +81,7 @@ public class MasterDataController {
@GetMapping("/api/patrol-ships")
@RequirePermission(resource = "patrol:patrol-route", operation = "READ")
public List<PatrolShip> listPatrolShips() {
return masterDataService.listPatrolShips();
return patrolShipRepository.findByIsActiveTrueOrderByShipCode();
}
@PatchMapping("/api/patrol-ships/{id}/status")
@ -74,28 +90,47 @@ public class MasterDataController {
@PathVariable Long id,
@RequestBody PatrolShipStatusRequest request
) {
return masterDataService.updatePatrolShipStatus(id, new MasterDataService.PatrolShipStatusCommand(
request.status(), request.lat(), request.lon(), request.zoneCode(), request.fuelPct()
));
PatrolShip ship = patrolShipRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"함정을 찾을 수 없습니다: " + id));
if (request.status() != null) ship.setCurrentStatus(request.status());
if (request.lat() != null) ship.setCurrentLat(request.lat());
if (request.lon() != null) ship.setCurrentLon(request.lon());
if (request.zoneCode() != null) ship.setCurrentZoneCode(request.zoneCode());
if (request.fuelPct() != null) ship.setFuelPct(request.fuelPct());
return patrolShipRepository.save(ship);
}
// ========================================================================
// 선박 허가 (인증만, 공통 마스터 데이터)
// 선박 허가 (vessel 권한)
// ========================================================================
@GetMapping("/api/vessel-permits")
// 인증된 사용자 모두 접근 가능 (메뉴 권한이 아닌 공통 마스터 데이터)
public Page<VesselPermit> listVesselPermits(
@RequestParam(required = false) String flag,
@RequestParam(required = false) String permitStatus,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
return masterDataService.listVesselPermits(flag, permitStatus, PageRequest.of(page, size));
PageRequest pageable = PageRequest.of(page, size);
if (flag != null) {
return vesselPermitRepository.findByFlagCountry(flag, pageable);
}
if (permitStatus != null) {
return vesselPermitRepository.findByPermitStatus(permitStatus, pageable);
}
return vesselPermitRepository.findAll(pageable);
}
@GetMapping("/api/vessel-permits/{mmsi}")
// 인증된 사용자 모두 접근 가능 (메뉴 권한이 아닌 공통 마스터 데이터)
public VesselPermit getVesselPermit(@PathVariable String mmsi) {
return masterDataService.getVesselPermit(mmsi);
return vesselPermitRepository.findByMmsi(mmsi)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"선박 허가 정보를 찾을 수 없습니다: " + mmsi));
}
// ========================================================================

파일 보기

@ -1,115 +0,0 @@
package gc.mda.kcg.master;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
/**
* 마스터 데이터(코드/어구/함정/선박허가) 조회 관리 서비스.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MasterDataService {
private final CodeMasterRepository codeMasterRepository;
private final GearTypeRepository gearTypeRepository;
private final PatrolShipRepository patrolShipRepository;
private final VesselPermitRepository vesselPermitRepository;
// 코드 마스터
public List<CodeMaster> listCodes(String groupCode) {
return codeMasterRepository.findByGroupCodeAndIsActiveTrueOrderBySortOrder(groupCode);
}
public List<CodeMaster> listChildren(String parentId) {
return codeMasterRepository.findByParentIdOrderBySortOrder(parentId);
}
// 어구 유형
public List<GearType> listGearTypes() {
return gearTypeRepository.findByIsActiveTrueOrderByDisplayOrder();
}
public GearType getGearType(String gearCode) {
return gearTypeRepository.findById(gearCode)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"어구 유형을 찾을 수 없습니다: " + gearCode));
}
@Transactional
public GearType createGearType(GearType gearType) {
if (gearTypeRepository.existsById(gearType.getGearCode())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"이미 존재하는 어구 코드입니다: " + gearType.getGearCode());
}
return gearTypeRepository.save(gearType);
}
@Transactional
public GearType updateGearType(String gearCode, GearType gearType) {
if (!gearTypeRepository.existsById(gearCode)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
"어구 유형을 찾을 수 없습니다: " + gearCode);
}
gearType.setGearCode(gearCode);
return gearTypeRepository.save(gearType);
}
// 함정
public List<PatrolShip> listPatrolShips() {
return patrolShipRepository.findByIsActiveTrueOrderByShipCode();
}
@Transactional
public PatrolShip updatePatrolShipStatus(Long id, PatrolShipStatusCommand command) {
PatrolShip ship = patrolShipRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"함정을 찾을 수 없습니다: " + id));
if (command.status() != null) ship.setCurrentStatus(command.status());
if (command.lat() != null) ship.setCurrentLat(command.lat());
if (command.lon() != null) ship.setCurrentLon(command.lon());
if (command.zoneCode() != null) ship.setCurrentZoneCode(command.zoneCode());
if (command.fuelPct() != null) ship.setFuelPct(command.fuelPct());
return patrolShipRepository.save(ship);
}
// 선박 허가
public Page<VesselPermit> listVesselPermits(String flag, String permitStatus, Pageable pageable) {
if (flag != null) {
return vesselPermitRepository.findByFlagCountry(flag, pageable);
}
if (permitStatus != null) {
return vesselPermitRepository.findByPermitStatus(permitStatus, pageable);
}
return vesselPermitRepository.findAll(pageable);
}
public VesselPermit getVesselPermit(String mmsi) {
return vesselPermitRepository.findByMmsi(mmsi)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"선박 허가 정보를 찾을 수 없습니다: " + mmsi));
}
// Command DTO
public record PatrolShipStatusCommand(
String status,
Double lat,
Double lon,
String zoneCode,
Integer fuelPct
) {}
}

파일 보기

@ -1,6 +0,0 @@
config.stopBubbling = true
# @RequiredArgsConstructor 가 생성하는 constructor parameter 에 필드의 @Qualifier 를 복사한다.
# Spring 6.1+ 의 bean 이름 기반 fallback 은 parameter-level annotation 을 요구하므로,
# 필수 처리하지 않으면 여러 bean 중 모호성이 발생해 기동이 실패한다.
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier

파일 보기

@ -40,10 +40,6 @@ spring:
server:
port: 8080
forward-headers-strategy: framework
compression:
enabled: true
min-response-size: 1024
mime-types: application/json,application/xml,text/html,text/plain
management:
endpoints:
@ -64,8 +60,9 @@ logging:
app:
prediction:
base-url: ${PREDICTION_BASE_URL:http://localhost:8001}
signal-batch:
base-url: ${SIGNAL_BATCH_BASE_URL:http://192.168.1.18:18090/signal-batch}
iran-backend:
# 운영 환경: https://kcg.gc-si.dev (Spring Boot + Prediction 통합)
base-url: ${IRAN_BACKEND_BASE_URL:https://kcg.gc-si.dev}
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:5174}
jwt:

파일 보기

@ -1,28 +0,0 @@
-- ============================================================
-- V028: 성능 모니터링 (PER-01~06) 메뉴 추가
-- 시스템관리 > 감사·보안 서브그룹
-- ============================================================
-- 1. 권한 트리 노드 등록
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('admin:performance-monitoring', 'admin', '성능 모니터링', 1, 59)
ON CONFLICT (rsrc_cd) DO NOTHING;
-- 2. 메뉴 메타데이터 갱신
UPDATE kcg.auth_perm_tree
SET url_path = '/admin/performance-monitoring',
label_key = 'nav.performanceMonitoring',
component_key = 'features/admin/PerformanceMonitoring',
nav_group = 'admin',
nav_sub_group = '감사·보안',
nav_sort = 2110,
labels = '{"ko":"성능 모니터링","en":"Performance Monitoring"}'
WHERE rsrc_cd = 'admin:performance-monitoring';
-- 3. ADMIN 역할에 전체 권한 부여
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'admin:performance-monitoring', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
WHERE r.role_cd = 'ADMIN'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;

파일 보기

@ -1,72 +0,0 @@
-- V026: 한중어업협정 중국어선 허가현황 원본 테이블 + fleet_vessels 연도 컬럼
-- 출처: docs/중국어선_허가현황_YYYYMMDD.xls (연단위 갱신)
-- ===== 1. fishery_permit_cn : 허가현황 원본 스냅샷 =====
CREATE TABLE IF NOT EXISTS kcg.fishery_permit_cn (
id BIGSERIAL PRIMARY KEY,
permit_year INTEGER NOT NULL,
permit_no VARCHAR(30) NOT NULL,
fishery_type VARCHAR(60), -- 업종 (2척식저인망어업 등)
fishery_code VARCHAR(10) NOT NULL, -- 업종코드 (PT/PT-S/GN/FC/PS/OT)
name_cn VARCHAR(100) NOT NULL,
name_en VARCHAR(200) NOT NULL,
applicant_cn VARCHAR(100),
applicant_en VARCHAR(200),
applicant_addr_cn VARCHAR(300),
applicant_addr_en VARCHAR(300),
registration_no VARCHAR(100),
tonnage NUMERIC(10,2),
port_cn VARCHAR(100),
port_en VARCHAR(200),
callsign VARCHAR(40),
engine_power NUMERIC(10,2),
length_m NUMERIC(6,2),
beam_m NUMERIC(6,2),
depth_m NUMERIC(6,2),
fishing_zones VARCHAR(30), -- Ⅱ,Ⅲ 등
fishing_period_1 VARCHAR(50),
fishing_period_2 VARCHAR(50),
catch_quota_t NUMERIC(10,2),
cumulative_quota_t NUMERIC(10,2),
refrig_hold_count INTEGER,
freezer_hold_count INTEGER,
admin_sanction TEXT,
parent_permit_no VARCHAR(30), -- 부속선(PT-S)이 참조하는 본선 허가번호
volume_enclosed NUMERIC(10,2),
volume_above_deck NUMERIC(10,2),
volume_below_deck NUMERIC(10,2),
volume_excluded NUMERIC(10,2),
raw_data JSONB,
source_file VARCHAR(255),
loaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (permit_year, permit_no)
);
CREATE INDEX idx_fishery_permit_cn_name_cn ON kcg.fishery_permit_cn(permit_year, name_cn);
CREATE INDEX idx_fishery_permit_cn_name_en ON kcg.fishery_permit_cn(permit_year, LOWER(name_en));
CREATE INDEX idx_fishery_permit_cn_code ON kcg.fishery_permit_cn(permit_year, fishery_code);
CREATE INDEX idx_fishery_permit_cn_parent ON kcg.fishery_permit_cn(permit_year, parent_permit_no);
COMMENT ON TABLE kcg.fishery_permit_cn IS '한중어업협정 중국어선 허가현황 원본 스냅샷 (연단위 갱신)';
COMMENT ON COLUMN kcg.fishery_permit_cn.permit_year IS '허가 연도 (파일명 YYYY에서 추출)';
COMMENT ON COLUMN kcg.fishery_permit_cn.fishery_code IS 'PT(쌍끌이 본선)/PT-S(부속선)/GN(자망)/FC(운반)/PS(선망)/OT(외끌이)';
COMMENT ON COLUMN kcg.fishery_permit_cn.parent_permit_no IS 'PT-S(부속선)가 소속된 본선의 허가번호';
-- ===== 2. fleet_vessels 확장 : 연도 + 업종코드 추적 =====
ALTER TABLE kcg.fleet_vessels ADD COLUMN IF NOT EXISTS permit_year INTEGER;
ALTER TABLE kcg.fleet_vessels ADD COLUMN IF NOT EXISTS fishery_code VARCHAR(10);
CREATE INDEX IF NOT EXISTS idx_fleet_vessels_permit_year ON kcg.fleet_vessels(permit_year);
CREATE INDEX IF NOT EXISTS idx_fleet_vessels_fishery_code ON kcg.fleet_vessels(fishery_code);
COMMENT ON COLUMN kcg.fleet_vessels.permit_year IS '허가 연도. fleet_tracker는 현재 연도만 조회';
COMMENT ON COLUMN kcg.fleet_vessels.fishery_code IS 'fishery_permit_cn.fishery_code 복제 (PT/PT-S/GN/FC/PS/OT)';
-- ===== 3. V014 데모 seed 제거 =====
-- 기존 6행 데모 vessels 제거 (실제 허가현황만 남김).
-- fleet_companies id=1,2는 vessel_permit_master가 FK로 참조하여 삭제 불가 — 잔존 허용
-- (loader 실행 시 실 허가 신청인 회사가 별도 id로 upsert됨)
DELETE FROM kcg.fleet_vessels WHERE permit_no IN (
'ZY-2024-001','ZY-2024-002','ZY-2024-003',
'ZY-2024-010','ZY-2024-011','ZY-2024-012'
);

파일 보기

@ -1,90 +0,0 @@
-- ============================================================================
-- V030: 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 탐지 패턴
-- 동일 어구 이름이 서로 다른 MMSI 로 같은 5분 사이클 내 동시 AIS 송출되는
-- 케이스를 독립 탐지 패턴으로 기록. 공존 이력·심각도·운영자 분류 상태를 보존한다.
-- ============================================================================
-- ──────────────────────────────────────────────────────────────────
-- 1. 충돌 이력 테이블
-- ──────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS kcg.gear_identity_collisions (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL, -- 동일한 어구 이름
mmsi_lo VARCHAR(20) NOT NULL, -- 정렬된 쌍 (lo < hi)
mmsi_hi VARCHAR(20) NOT NULL,
parent_name VARCHAR(100),
parent_vessel_id BIGINT, -- fleet_vessels.id
first_seen_at TIMESTAMPTZ NOT NULL,
last_seen_at TIMESTAMPTZ NOT NULL,
coexistence_count INT NOT NULL DEFAULT 1, -- 동일 cycle 공존 누적
swap_count INT NOT NULL DEFAULT 0, -- 시간차 스왑 누적(참고)
max_distance_km NUMERIC(8,2), -- 양 위치 최대 거리
last_lat_lo NUMERIC(9,6),
last_lon_lo NUMERIC(10,6),
last_lat_hi NUMERIC(9,6),
last_lon_hi NUMERIC(10,6),
severity VARCHAR(20) NOT NULL DEFAULT 'MEDIUM', -- CRITICAL/HIGH/MEDIUM/LOW
status VARCHAR(30) NOT NULL DEFAULT 'OPEN', -- OPEN/REVIEWED/CONFIRMED_ILLEGAL/FALSE_POSITIVE
resolved_by UUID,
resolved_at TIMESTAMPTZ,
resolution_note TEXT,
evidence JSONB, -- 최근 관측 요약
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT gear_identity_collisions_pair_uk UNIQUE (name, mmsi_lo, mmsi_hi),
CONSTRAINT gear_identity_collisions_pair_ord CHECK (mmsi_lo < mmsi_hi)
);
CREATE INDEX IF NOT EXISTS idx_gic_status
ON kcg.gear_identity_collisions(status, last_seen_at DESC);
CREATE INDEX IF NOT EXISTS idx_gic_severity
ON kcg.gear_identity_collisions(severity, last_seen_at DESC);
CREATE INDEX IF NOT EXISTS idx_gic_parent
ON kcg.gear_identity_collisions(parent_vessel_id)
WHERE parent_vessel_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_gic_name
ON kcg.gear_identity_collisions(name);
CREATE INDEX IF NOT EXISTS idx_gic_last_seen
ON kcg.gear_identity_collisions(last_seen_at DESC);
COMMENT ON TABLE kcg.gear_identity_collisions IS
'동일 어구 이름이 서로 다른 MMSI 로 공존 송출되는 스푸핑 의심 패턴 (GEAR_IDENTITY_COLLISION).';
-- ──────────────────────────────────────────────────────────────────
-- 2. 권한 트리 / 메뉴 슬롯 (V024 이후 detection 그룹은 평탄화됨: parent_cd=NULL)
-- ──────────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm_tree
(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord,
url_path, label_key, component_key, nav_sort, labels)
VALUES
('detection:gear-collision', NULL, '어구 정체성 충돌', 1, 40,
'/gear-collision', 'nav.gearCollision',
'features/detection/GearCollisionDetection', 950,
'{"ko":"어구 정체성 충돌","en":"Gear Identity Collision"}'::jsonb)
ON CONFLICT (rsrc_cd) DO NOTHING;
-- ──────────────────────────────────────────────────────────────────
-- 3. 권한 부여
-- ADMIN : 전체 op (READ/CREATE/UPDATE/DELETE/EXPORT)
-- OPERATOR: READ + UPDATE (분류 액션)
-- VIEWER/ANALYST/FIELD: READ
-- ──────────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'detection:gear-collision', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
WHERE r.role_cd = 'ADMIN'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'detection:gear-collision', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('UPDATE')) AS op(oper_cd)
WHERE r.role_cd = 'OPERATOR'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'detection:gear-collision', 'READ', 'Y'
FROM kcg.auth_role r
WHERE r.role_cd IN ('VIEWER', 'ANALYST', 'FIELD')
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;

파일 보기

@ -31,7 +31,7 @@
| 서비스 | systemd | 포트 | 로그 |
|---|---|---|---|
| kcg-ai-prediction | `kcg-ai-prediction.service` | 18092 | `journalctl -u kcg-ai-prediction -f` |
| kcg-prediction (레거시) | `kcg-prediction.service` | 8001 | `journalctl -u kcg-prediction -f` |
| kcg-prediction (기존 iran) | `kcg-prediction.service` | 8001 | `journalctl -u kcg-prediction -f` |
| kcg-prediction-lab | `kcg-prediction-lab.service` | 18091 | `journalctl -u kcg-prediction-lab -f` |
## 디렉토리 구조
@ -166,7 +166,7 @@ PGPASSWORD='Kcg2026ai' psql -h 211.208.115.83 -U kcg-app -d kcgaidb
| 443 | nginx (HTTPS) | rocky-211 |
| 18080 | kcg-ai-backend (Spring Boot) | rocky-211 |
| 18092 | kcg-ai-prediction (FastAPI) | redis-211 |
| 8001 | kcg-prediction (레거시) | redis-211 |
| 8001 | kcg-prediction (기존 iran) | redis-211 |
| 18091 | kcg-prediction-lab | redis-211 |
| 5432 | PostgreSQL (kcgaidb, snpdb) | 211.208.115.83 |
| 6379 | Redis | redis-211 |
@ -226,5 +226,5 @@ ssh redis-211 "systemctl restart kcg-ai-prediction"
| `/home/apps/kcg-ai-prediction/.env` | prediction 환경변수 |
| `/home/apps/kcg-ai-prediction/venv/` | Python 3.9 가상환경 |
| `/etc/systemd/system/kcg-ai-prediction.service` | prediction systemd 서비스 |
| `/home/apps/kcg-prediction/` | 레거시 prediction (포트 8001) |
| `/home/apps/kcg-prediction/` | 기존 iran prediction (포트 8001) |
| `/home/apps/kcg-prediction-lab/` | 기존 lab prediction (포트 18091) |

파일 보기

@ -4,143 +4,6 @@
## [Unreleased]
## [2026-04-17.4]
### 수정
- **백엔드 RestClient bean 모호성으로 기동 실패 해소** — rocky-211 `kcg-ai-backend` 가 restart 시 `No qualifying bean of type RestClient, but 2 were found: predictionRestClient, signalBatchRestClient` 로 크래시 루프 진입하던 문제. PR #A(2026-04-17) 의 RestClientConfig 도입 이후 잠복해 있던 버그로, `@RequiredArgsConstructor` 가 생성한 constructor parameter 에 필드의 `@Qualifier` 가 복사되지 않아 Spring 6.1 의 parameter-level annotation 기반 주입이 실패한 것. 수정: `backend/pom.xml``maven-compiler-plugin` 실행 설정에 `<parameters>true</parameters>` 명시 + `backend/src/main/java/lombok.config` 신설해 `lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier` 등록. 재빌드 후 bytecode `RuntimeVisibleParameterAnnotations``@Qualifier` 복사 확인, 운영 기동 `Started KcgAiApplication in 7.333 seconds` 복구
## [2026-04-17.3]
### 문서
- **절대 지침 섹션 추가** — CLAUDE.md 최상단에 "절대 지침(Absolute Rules)" 섹션 신설. (1) 신규 브랜치 생성 전 `git fetch``origin/develop` 대비 뒤처지면 사용자 확인 → `git pull --ff-only` → 분기하는 동기화 절차 명시, (2) `frontend/` 작업 시 `design-system.html` 쇼케이스 규칙 전면 준수(공통 컴포넌트 우선 사용, 인라인/하드코딩 Tailwind 색상·`!important` 금지, 접근성 필수 체크리스트) 요약. 하단 기존 "디자인 시스템 (필수 준수)" 상세 섹션과 연결
## [2026-04-17.2]
### 추가
- **어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 탐지 패턴** — 동일 어구 이름이 서로 다른 MMSI 로 같은 5분 사이클에 동시 AIS 송출되는 스푸핑/복제 의심 패턴을 신규 탐지. prediction `fleet_tracker.track_gear_identity()` 가 공존(simultaneous) / 교체(sequential) 경로를 분리해 공존 쌍은 `gear_identity_collisions` 에 UPSERT (누적 공존 횟수, 최대 거리, 양측 좌표, evidence JSONB append). 심각도는 거리/누적/스왑 기반으로 CRITICAL/HIGH/MEDIUM/LOW 자동 재계산, 운영자 확정 상태(CONFIRMED_ILLEGAL/FALSE_POSITIVE)는 보존. CRITICAL/HIGH 승격 시 `prediction_events` 허브에 `GEAR_IDENTITY_COLLISION` 카테고리 등록(dedup 367분). `/api/analysis/gear-collisions` READ + resolve 액션(REVIEWED/CONFIRMED_ILLEGAL/FALSE_POSITIVE/REOPEN, `@Auditable GEAR_COLLISION_RESOLVE`). 좌측 메뉴 "어구 정체성 충돌" 자동 노출(nav_sort=950, detection:gear-collision)
- **gearCollisionStatuses 카탈로그**`shared/constants/gearCollisionStatuses.ts` + `catalogRegistry` 등록으로 design-system 쇼케이스 자동 노출. OPEN/REVIEWED/CONFIRMED_ILLEGAL/FALSE_POSITIVE 4단계 Badge intent 매핑
### 변경
- **prediction 5분 사이클 안정화**`gear_correlation_scores_pkey` 충돌이 매 사이클 `InFailedSqlTransaction` 을 유발해 이벤트 생성·분석 결과 upsert 가 전부 스킵되던 문제 해소. `gear_correlation_scores``target_mmsi` 이전 쿼리를 SAVEPOINT 로 격리해 PK 충돌 시 트랜잭션 유지. 공존 경로는 이전 시도 자체를 하지 않아 재발 방지
### 문서
- **프로젝트 산출문서 2026-04-17 기준 정비**`architecture.md` shared/components/ui 9개·i18n 네임스페이스 갱신, `sfr-traceability.md` v3.0 전면 재작성(운영 상태 기반 531라인), `sfr-user-guide.md` 헤더 + SFR-01/02/09/10/11/12/13/17 구현 현황 갱신, stale 3건(`data-sharing-analysis.md` / `next-refactoring.md` / `page-workflow.md`) 제거
## [2026-04-17]
### 변경
- **디자인 시스템 SSOT 일괄 준수 (30파일)**`frontend/design-system.html` 쇼케이스의 공통 컴포넌트와 `shared/constants/` 카탈로그를 우회하던 하드코딩 UI 를 전영역 치환. raw `<button>``<Button variant>` / raw `<input>``<Input>` / raw `<select>``<Select>` / 커스텀 탭 → `<TabBar>` + `<TabButton>` / raw checkbox → `<Checkbox>`. `text-red-400` 같은 다크 전용 색상을 `text-red-600 dark:text-red-400` 쌍으로 라이트 모드 대응. StatBox `color: string` prop 을 `intent: BadgeIntent` + `INTENT_TEXT_CLASS` 매핑으로 재설계. 에러 메시지도 `t('error.errorPrefix', { msg })` 로 통일. 영역: detection(6) / detection/components(4) / enforcement / surveillance(2) / admin(7) / parent-inference(3) / statistics / ai-operations(3) / dashboard / field-ops(2) / auth
- **i18n 하드코딩 한글 제거 (alert/confirm/aria-label 우선순위)**`common.json``aria` / `error` / `dialog` / `success` / `message` 네임스페이스 추가 (ko/en 대칭, 52개 키). 운영자 노출 `alert('실패: ' + msg)` 11건과 접근성 위반 `aria-label="역할 코드"` 등 40+건을 `t('aria.*')` / `t('error.*')` / `t('dialog.*')` 로 일괄 치환. parent-inference / admin / detection / enforcement / vessel / statistics / ai-operations 전 영역. MainLayout 언어 토글은 `title={t('message.switchToEnglish')}` + `aria-label={t('aria.languageToggle')}` 로 정비
- **iran 백엔드 프록시 잔재 제거**`IranBackendClient` dead class 삭제, `application.yml``iran-backend:` 블록 + `AppProperties.IranBackend` inner class 정리. prediction 이 kcgaidb 에 직접 write 하는 현 아키텍처에 맞춰 CLAUDE.md 시스템 구성 다이어그램 최신화. Frontend UI 라벨 `iran 백엔드 (분석)``AI 분석 엔진` 로 교체, system-flow manifest `external.iran_backend` 노드는 `status: deprecated` 마킹(노드 ID 안정성 원칙 준수, 1~2 릴리즈 후 삭제 예정)
- **백엔드 계층 분리** — AlertController/MasterDataController/AdminStatsController 에서 repository·JdbcTemplate 직접 주입 패턴 제거. `AlertService` · `MasterDataService` · `AdminStatsService` 신규 계층 도입 + `@Transactional(readOnly=true)` 적용. 공통 `RestClientConfig @Configuration` 으로 `predictionRestClient` / `signalBatchRestClient` Bean 통합 → Proxy controller 들의 `@PostConstruct` ad-hoc 생성 제거
- **감사 로그 보강**`EnforcementService` 의 createRecord / updateRecord / createPlan 에 `@Auditable` 추가 (ENFORCEMENT_CREATE/UPDATE/PLAN_CREATE). `VesselAnalysisGroupService.resolveParent``PARENT_RESOLVE` 액션 기록. 모든 쓰기 액션이 `auth_audit_log` 에 자동 수집
- **alertLevels 카탈로그 확장** — 8개 화면의 `level === 'CRITICAL' ? ... : 'HIGH' ? ...` 식 직접 분기를 제거하기 위해 `isValidAlertLevel` (타입 가드) / `isHighSeverity` / `getAlertLevelOrder` / `ALERT_LEVEL_MARKER_OPACITY` / `ALERT_LEVEL_MARKER_RADIUS` / `ALERT_LEVEL_TIER_SCORE` 헬퍼·상수 신설. LiveMapView 마커 시각 매핑, DarkVesselDetection tier→점수, GearIdentification 타입 가드, vesselAnomaly 패널 severity 할당 헬퍼로 치환
### 추가
- **performanceStatus 카탈로그 등록** — 이미 존재하던 `shared/constants/performanceStatus.ts` (good/normal/warning/critical/running/passed/failed/active/scheduled/archived 10종) 를 `catalogRegistry` 에 등록. design-system 쇼케이스 자동 노출 + admin 성능/보관/검증 페이지 SSOT 일원화
## [2026-04-16.7]
### 변경
- **경량 분석 riskScore 해상도 개선**`compute_lightweight_risk_score``dark_suspicion_score`(0~100 패턴 기반 의심도) / `dist_from_baseline_nm`(EEZ 외 기선 근접도 12·24NM 차등) / `dark_history_24h`(반복 이력) 반영. 허가·반복 이중계산 방지 축소 로직. 배포 후 실측: 45점 60.8% 고정 수렴 → **0%** (11~40 구간 고르게 분산)
- **vessel_type 매핑** — fleet_vessels 등록선 `fishery_code` (PT/PT-S/OT/GN/PS/FC) 를 `TRAWL/GILLNET/PURSE/CARGO` 로 매핑하는 `vessel_type_mapping.py` 신설. 경량 경로의 `vessel_type='UNKNOWN'` 하드코딩 제거. 실측: UNKNOWN 98.6% → **89.1%** (886척이 구체 유형으로 전환)
- **VesselType 값 확장** — 기존 TRAWL/PURSE/LONGLINE/TRAP/UNKNOWN 에 `GILLNET`(유자망) / `CARGO`(운반선) 2종 추가
- **중국 선박 분석 그리드 정합성** — Tab 1 상단 `RealAllVessels` 편의 export 를 `mmsiPrefix='412'` 로 고정 + 제목 "중국 선박 전체 분석 결과 (실시간)" 로 변경. 상단/하단 모두 중국 선박 기준으로 일관 표시
### 추가
- **선박 유형 한글 카탈로그**`shared/constants/vesselTypes.ts` 신설. 저인망/선망/유자망/연승/통발/운반선/미분류 한글 라벨 + Badge intent. 기존 `alertLevels` 패턴 답습, `catalogRegistry` 등록으로 design-system 쇼케이스 자동 노출
## [2026-04-16.6]
### 추가
- **중국어선 감시 화면 실데이터 연동 (3개 탭)** — deprecated iran proxy `/api/vessel-analysis` → 자체 백엔드 `/api/analysis/*` 전환. AI 감시 대시보드·환적접촉탐지·어구/어망 판별 모두 prediction 5분 사이클 결과 실시간 반영. 관심영역/VIIRS/기상/VTS 카드는 "데모 데이터" 뱃지, 비허가/제재/관심 선박 탭은 "준비중" 뱃지로 데이터 소스 미연동 항목 명시
- **특이운항 미니맵 + 판별 구간 패널** — AI 감시 대시보드 선박 리스트 클릭 → 24h AIS 항적(MapLibre + deck.gl) + Dark/Spoofing/환적/어구위반/고위험 신호를 시간순 segment 로 병합해 지도 하이라이트(CRITICAL/WARNING/INFO 3단계). 판별 패널에 시작~종료·지속·N회 연속 감지·카테고리·설명 표시. 어구/어망 판별 탭 최하단 자동탐지 결과 row 클릭 시 상단 입력 폼 프리필
- **`/api/analysis/stats`** — MMSI별 최신 row 기준 단일 쿼리 COUNT FILTER 집계(total/dark/spoofing/transship/risk 분포/zone 분포/fishing/avgRiskScore + windowStart/End). 선택적 `mmsiPrefix` 필터(중국 선박 '412' 등)
- **`/api/analysis/gear-detections`** — gear_code/judgment NOT NULL row MMSI 중복 제거 목록. 자동 탐지 결과 섹션 연동용
- **`/api/analysis/vessels` 필터 확장** — `mmsiPrefix` / `minRiskScore` / `minFishingPct` 쿼리 파라미터 추가
- **VesselAnalysisResponse 필드 확장**`violationCategories` / `bd09OffsetM` / `ucafScore` / `ucftScore` / `clusterId` 5개 필드 노출
- **prediction 분석 시점 좌표 저장**`AnalysisResult` + `to_db_tuple` + `upsert_results` SQL 에 `lat/lon` 추가. 분류 파이프라인(last_row) / 경량 분석(all_positions) 두 경로 주입. 기존 `vessel_analysis_results.lat/lon` 컬럼이 항상 NULL 이던 구조적 누락 해결 (첫 사이클 8173/8173 non-null 확인)
## [2026-04-16.5]
### 변경
- **Admin 11개 페이지 디자인 시스템 하드코딩 색상 제거 (Phase 1-B)** — 129건 Tailwind 색상 → 시맨틱 토큰(text-label/text-heading/text-hint) + Badge intent 치환. raw `<button>``<Button>` 컴포넌트 교체. 미사용 import 정리
## [2026-04-16.4]
### 추가
- **G-02 금어기 조업 탐지**`fishery_permit_cn.fishing_period_1/2` 파싱(YYYY/MM/DD 범위) + `classify_gear_violations()` 에 permit_periods/observation_ts 인자 추가. 허가기간 밖 조업 시 `CLOSED_SEASON_FISHING` judgment
- **G-03 미등록/허가외 어구 탐지** — 감지 어구와 `fishery_code` 허용 목록 대조(PT→trawl, GN→gillnet, FC→금지 등). 불일치 시 `UNREGISTERED_GEAR` judgment
- **NAME_FUZZY 매칭** — 선박명 정규화(공백/대소문자/'NO.' 마커 통일, 선박번호 유지) + name_en 기반 fuzzy lookup. 동명이 중복 방지. 매칭률 9.1% → 53.1%
- **서버 스크립트 tier/G-02/G-03/match_method 추적** — diagnostic(5분) + hourly(1시간) 에 pair_tier 분포, reject 사유 카운터, fishery_code×match_method 교차, G-02/G-03 상세 섹션
### 변경
- **pair_trawl tier 분류** — AND 게이트(스펙 100% 2시간) 대신 STRONG/PROBABLE/SUSPECT 3단계. 완화 임계(800m/SOG 1.5-5/sog_delta 1.0/cog 20°)로 실제 공조 신호 포착. G-06 판정은 STRONG/PROBABLE 만
- **pair_trawl join key** — raw AIS timestamp → `time_bucket`(5분 리샘플). sog/cog on-demand 계산(`_ensure_sog_cog`)으로 vessel_store._tracks 직접 사용
- **pair base 확장** — classification 500척 → 전체 중국 MID(412/413/414) 조업 속력대 선박. candidates 61→1,668, detected 0→57
- **match_ais_to_registry 대상 확장** — vessel_dfs(500척) → vessel_store._tracks 전체 중국 선박(8k+)
### 수정
- **violation_classifier**`CLOSED_SEASON_FISHING`, `UNREGISTERED_GEAR` judgment → `ILLEGAL_GEAR` 카테고리 매핑 추가
## [2026-04-16.3]
### 변경
- **Admin 3개 페이지 디자인 시스템 준수 리팩토링 (Phase 1-A)** — PerformanceMonitoring/DataRetentionPolicy/DataModelVerification 자체 탭 네비 → `TabBar/TabButton` 공통 컴포넌트, 원시 `<button>``TabButton`, PerformanceMonitoring 정적 hex 9건 → `performanceStatus` 카탈로그 경유
- **신규 카탈로그** `shared/constants/performanceStatus.ts` — PerformanceStatus(good/warning/critical/running/passed/failed/active/scheduled/archived) → {intent, hex, label} + utilizationStatus(ratio) 헬퍼
- **RBAC skeleton** — 3개 페이지 최상단 `useAuth().hasPermission('admin:{resource}', 'OP')` 호출 배치 (Phase 3 액션 버튼 추가 시 가드로 연결)
## [2026-04-16.2]
### 추가
- **성능 모니터링(PER-01~06) 메뉴** — 시스템관리 > 감사·보안에 성능 모니터링 페이지 추가 (PerformanceMonitoring 컴포넌트 + V028 메뉴 마이그레이션)
- **데이터 모델 검증(DAR-11) 메뉴** — DataModelVerification 페이지 + V027 메뉴
- **데이터 보관·파기 정책(DAR-10) 메뉴** — DataRetentionPolicy 페이지 + V026 메뉴
- **DAR-03 5종 어구 구조 비교** — AI 모델관리 어구 탐지 탭에 저층 트롤 / 스토우넷 / 자망 / 통발 / 쌍끌이 이미지 및 설명 추가
- **단속 계획 탭 확장** — 단일 함정 순찰 작전 / 다함정 순찰 작전 탭 추가 (EnforcementPlan)
## [2026-04-16]
### 추가
- **한중어업협정 중국어선 허가현황 레지스트리**`kcg.fishery_permit_cn` 신규 테이블(29컬럼, 연단위 스냅샷). V029 마이그레이션 + `fleet_vessels.permit_year/fishery_code` 컬럼 추가. `load_fishery_permit_cn.py`로 연도별 XLS → DB 적재(906척/497 신청인사)
- **페어 탐색 재설계**`find_pair_candidates()` bbox 1차(인접 9 cell) + 궤적 유사도 2차(location/sog_corr/cog_alignment). 동종 어선 페어도 허용, role 가점(PT_REGISTERED/COOP_FISHING/TRANSSHIP_LIKE)
- **fleet_tracker API 3개**`get_pt_registered_mmsis` / `get_gear_episodes` / `get_gear_positions`
### 수정
- **DAR-03 G-04/G-05/G-06 Dead code 해결**`classify_gear_violations()` scheduler 호출 연결. `if 'pair_results' in dir()` 버그 제거. 사이클당 G-05 303건 / G-04 1건 탐지 시작
- **spoofing 산식** — 24h 희석 버그 → 최근 1h 윈도우 + teleport 절대 가점(건당 0.20) + extreme(>50kn) 단독 발견 시 score=max(0.6) 확정
- **gear_code DB write 경로**`AnalysisResult.gear_code` 필드 + `kcgdb.upsert_results()` INSERT/UPDATE + scheduler 두 경로에서 `fleet_tracker.get_vessel_gear_code()` 호출
### 변경
- **transshipment 선종 완화**`_CARRIER_HINTS`(cargo/tanker/supply/carrier/reefer) 부분일치 + 412* 중국어선 FISHING 간주
- **gear drift 임계** — 750m → **500m** (DAR-03 스펙 정합)
- **fleet_tracker 현재 연도 필터**`WHERE permit_year = EXTRACT(YEAR FROM now())::int OR permit_year IS NULL`
### 기타
- cron 스크립트 신규 섹션: hourly P1~P5(허가/매칭/gear_code/fleet_role) + D3.5(pair_type) / diagnostic PART 7.5 + 4-5.5
## [2026-04-15]
### 추가
- **DAR-03 G-code 위반 분류** — prediction에 G-01(수역×어구 위반)/G-04(MMSI 사이클링)/G-05(고정어구 표류)/G-06(쌍끌이 공조) 4개 위반 유형 자동 분류 + 점수 합산
- **쌍끌이 공조 탐지 알고리즘** — pair_trawl.py 신규 (cell-key 파티션 O(n) 스캔, 500m 근접·0.5kn 속도차·10° COG 일치·2h 지속 임계값)
- **모선 검토 워크플로우** — 어구 판정 상세 패널에 후보 검토 UI 추가 (관측 지표 7종 평균 + 보정 지표 + 모선 확정/제외 버튼). 별도 화면 진입 없이 어구 탐지 페이지 내에서 의사결정
- **24시간 궤적 리플레이** — TripsLayer fade trail 애니메이션, 멤버별 개별 타임라인 보간(빈 구간 자연 연속), convex hull 폴리곤 실시간 생성, 후보 선박 항적 동시 재생 (signal-batch /api/v2/tracks/vessels 프록시 연동)
- **어구 탐지 그리드 UX** — 다중 선택 필터 패널(설치 해역/판정/위험도/모선 상태/허가/멤버 수 슬라이더, localStorage 영속화), 행 클릭 시 지도 flyTo, 후보 일치율 칼럼 + 정렬
### 변경
- **그리드 후보 일치율 정확도** — resolution.top_score(평가 시점 고정) 대신 correlation_scores.current_score(실시간 갱신)의 최댓값 사용 → 최신 점수 반영
- **어구 그룹 칼럼 표시** — 모선 후보 MMSI가 그룹명 자리에 표시되던 버그 수정 (groupLabel/groupKey 우선 표시)
- **ParentResolution Entity 확장** — top_score/confidence/score_margin/decision_source/stable_cycles 등 점수 근거 7개 필드 추가
- **백엔드 correlation API 응답 정규화** — snake_case 컬럼을 camelCase로 명시 매핑 (프론트 매핑 누락 방지)
### 수정
- **궤적 리플레이 깜박임** — useMapLayers와 useGearReplayLayers가 동시에 overlay.setProps()를 호출하던 경쟁 조건 제거. 리플레이 활성 시 useMapLayers 비활성화 (단일 렌더링 경로)
- **멤버-중심 연결선 제거** — 어구 그룹 선택 모드에서 불필요하게 그려지던 dashed 연결선 코드 삭제
### 기타
- **루트 .venv/ gitignore 추가**
## [2026-04-14]
### 추가
@ -169,7 +32,7 @@
- **NoticeManagement CRUD 권한 가드** — admin:notices CREATE/UPDATE/DELETE 체크 추가 (disabled + 툴팁)
- **EventList CRUD 권한 가드** — enforcement:event-list UPDATE + enforcement:enforcement-history CREATE 체크 추가 (disabled + 툴팁)
## [2026-04-09]
## [2026-04-09.2]
### 추가
- **워크플로우 연결 5단계** — 탐지→단속 관통 워크플로우 구현
@ -192,6 +55,7 @@
- **V019 마이그레이션** — ai-operations:llm-ops 권한 트리 항목
- **analysisApi.ts** 프론트 서비스 (직접 조회 API 5개 연동)
- **PredictionEvent.features** 타입 확장 (dark_tier, transship_score 등)
- **메뉴 DB SSOT 구조화** — auth_perm_tree 기반 메뉴·권한·i18n 통합
- auth_perm_tree에 메뉴 컬럼 추가 (url_path, label_key, component_key, nav_group, nav_sort)
- labels JSONB 다국어 지원 (`{"ko":"종합 상황판", "en":"Dashboard"}`) — DB가 i18n SSOT
@ -207,6 +71,23 @@
- MainLayout: DB menuConfig에서 사이드바 자동 렌더링
- **PermissionsPanel 개선** — DB labels 기반 표시명 + 페이지/패널 아이콘 구분 + 메뉴 순서 정렬
- **DB migration README.md 전면 재작성** — V001~V024, 49테이블, 149인덱스 실측 문서화
### 변경
- **event_generator.py** INSERT에 features JSONB 추가 (이벤트에 분석 핵심 특성 저장)
- **@RequirePermission 12곳 수정** — 삭제된 그룹 rsrc_cd → 구체적 자식 리소스
- **EnforcementController** vesselMmsi 필터 파라미터 추가
- **enforcement.ts** getEnforcementRecords에 vesselMmsi 파라미터 추가
### 수정
- `/map-control` labelKey 중복 해소 (nav.riskMap → nav.mapControl, "해역 관리")
- system-flow 08-frontend.json 누락 노드 14개 추가
### 문서
- i18n darkTier/transshipTier/adminSubGroup/mapControl 키 추가 (ko/en)
## [2026-04-09]
### 추가
- **Dark Vessel 의심 점수화** — 기존 "gap≥30분→dark" 이분법에서 8가지 패턴 기반 0~100점 점수 산출 + CRITICAL/HIGH/WATCH/NONE 등급 분류
- P1 이동 중 OFF / P2 민감 수역 / P3 반복 이력(7일) / P4 거리 비정상 / P5 주간 조업 OFF / P6 직전 이상행동 / P7 무허가 / P8 장기 gap
- 한국 AIS 수신 커버리지 밖은 자연 gap 가능성으로 감점
@ -219,10 +100,6 @@
- pair_history 구조 확장: `{'first_seen', 'last_seen', 'miss_count'}` (GPS 노이즈 내성)
### 변경
- **event_generator.py** INSERT에 features JSONB 추가 (이벤트에 분석 핵심 특성 저장)
- **@RequirePermission 12곳 수정** — 삭제된 그룹 rsrc_cd → 구체적 자식 리소스
- **EnforcementController** vesselMmsi 필터 파라미터 추가
- **enforcement.ts** getEnforcementRecords에 vesselMmsi 파라미터 추가
- **stats_aggregator hourly**: UTC→KST hour boundary 전환, `by_category`/`by_zone` JSONB 집계 추가
- **event_generator 룰 전면 재편**:
- EEZ_INTRUSION: 실측 zone_code(TERRITORIAL_SEA/CONTIGUOUS_ZONE/ZONE_*) 기반 신규 3룰
@ -237,8 +114,6 @@
- `AnalysisResult.to_db_tuple` features sanitize: 중첩 dict/list 지원
### 수정
- `/map-control` labelKey 중복 해소 (nav.riskMap → nav.mapControl, "해역 관리")
- system-flow 08-frontend.json 누락 노드 14개 추가
- `prediction_stats_hourly.by_category`/`by_zone` 영구 NULL → 채움
- `prediction_stats_hourly.critical_count` 영구 0 → CRITICAL 이벤트 수 반영
- `prediction_events` 카테고리 2종(ZONE_DEPARTURE/ILLEGAL_TRANSSHIP)만 → 6종 이상
@ -247,9 +122,6 @@
- dark 과다 판정 해소: 핫픽스(한국 수신 영역 필터) + 2차(의심 점수화)
- transship 과다 판정 해소: 사이클당 2,400~12,600 → CRITICAL/HIGH/WATCH 점수 기반
### 문서
- i18n darkTier/transshipTier/adminSubGroup/mapControl 키 추가 (ko/en)
## [2026-04-08]
### 추가

파일 보기

@ -50,7 +50,6 @@ src/
│ ├── i18n/ # 10 NS (common, dashboard, detection, patrol, enforcement, statistics, ai, fieldOps, admin, auth)
│ │ ├── config.ts # i18next 초기화 (ko 기본, en 폴백)
│ │ └── locales/ # ko/*.json, en/*.json (10파일 x 2언어)
│ │ # 2026-04-17: common.json 에 aria(36)/error(7)/dialog(4)/success(2)/message(5) 네임스페이스 추가
│ └── theme/ # tokens, colors, variants (CVA)
│ ├── tokens.ts # CSS 변수 매핑 + resolved 색상값
│ ├── colors.ts # 시맨틱 팔레트 (risk, alert, vessel, status, chartSeries)
@ -90,28 +89,20 @@ src/
│ ├── ws.ts # connectWs (STOMP 스텁, 미구현)
│ └── index.ts # 배럴 export
├── shared/components/ # 공유 UI 컴포넌트 (design-system.html SSOT)
│ ├── ui/ # 9개 공통 컴포넌트 (2026-04-17 모든 화면 SSOT 준수 완료)
│ │ ├── card.tsx # Card(CVA variant), CardHeader, CardTitle, CardContent (4 variant)
│ │ ├── badge.tsx # Badge(CVA intent 8종 × size 4단계, LEGACY_MAP 변형 호환)
│ │ ├── button.tsx # Button (variant 5종 × size 3단계, icon/trailingIcon prop)
│ │ ├── input.tsx # Input (size/state, forwardRef)
│ │ ├── select.tsx # Select (aria-label|aria-labelledby|title TS union 강제)
│ │ ├── textarea.tsx # Textarea
│ │ ├── checkbox.tsx # Checkbox (native input 래퍼)
│ │ ├── radio.tsx # Radio
│ │ └── tabs.tsx # TabBar + TabButton (underline/pill/segmented 3 variant)
│ ├── layout/ # PageContainer / PageHeader / Section (표준 페이지 루트)
├── shared/components/ # 공유 UI 컴포넌트
│ ├── ui/
│ │ ├── card.tsx # Card(CVA variant), CardHeader, CardTitle, CardContent
│ │ └── badge.tsx # Badge(CVA intent/size)
│ └── common/
│ ├── DataTable.tsx # 범용 테이블 (가변너비, 검색, 정렬, 페이징, 엑셀, 출력)
│ ├── Pagination.tsx # 페이지네이션
│ ├── SearchInput.tsx # 검색 입력 (i18n 통합)
│ ├── SearchInput.tsx # 검색 입력
│ ├── ExcelExport.tsx # 엑셀 다운로드
│ ├── FileUpload.tsx # 파일 업로드
│ ├── PageToolbar.tsx # 페이지 상단 툴바
│ ├── PrintButton.tsx # 인쇄 버튼
│ ├── SaveButton.tsx # 저장 버튼
│ └── NotificationBanner.tsx # 알림 배너 (common.aria.closeNotification)
│ └── NotificationBanner.tsx # 알림 배너
├── features/ # 13 도메인 그룹 (31 페이지)
│ ├── dashboard/ # 종합 대시보드 (Dashboard)

파일 보기

@ -0,0 +1,252 @@
# Mock 데이터 공유 현황 분석 및 통합 결과
> 최초 작성일: 2026-04-06
> 마지막 업데이트: 2026-04-06
> 대상: `kcg-ai-monitoring` 프론트엔드 코드베이스 전체 (31개 페이지)
> 상태: **통합 완료**
---
## 1. 선박 데이터 교차참조
현재 동일한 선박 데이터가 여러 컴포넌트에 독립적으로 하드코딩되어 있다. 각 파일마다 동일 선박의 속성(위험도, 위치, 상태 등)이 서로 다른 형식과 값으로 중복 정의되어 있어 데이터 일관성 문제가 발생한다.
| 선박명 | 등장 파일 수 | 파일 목록 |
|---|---|---|
| 鲁荣渔56555 | 7+ | Dashboard, MobileService, LiveMapView, MonitoringDashboard, EventList, EnforcementHistory, ChinaFishing |
| 浙甬渔60651 | 4 | Dashboard, LiveMapView, EventList, DarkVesselDetection |
| 冀黄港渔05001 | 6 | MobileService, LiveMapView, Dashboard, TransferDetection, EventList, GearDetection |
| 3001함 | 6+ | ShipAgent, MobileService, LiveMapView, Dashboard, PatrolRoute, FleetOptimization |
| 3009함 | 6+ | ShipAgent, MobileService, Dashboard, PatrolRoute, FleetOptimization, AIAlert |
| 미상선박-A | 5 | MobileService, Dashboard, LiveMapView, MonitoringDashboard, EventList |
### 문제점
- 하나의 선박이 평균 5~7개 파일에 중복 정의됨
- 선박 속성(이름, MMSI, 위치, 위험도, 상태)이 파일마다 미세하게 다를 수 있음
- 새 선박 추가/수정 시 모든 관련 파일을 일일이 찾아 수정해야 함
---
## 2. 위험도 스케일 불일치
동일한 선박의 위험도가 페이지마다 서로 다른 스케일로 표현되고 있다.
| 선박명 | Dashboard (risk) | DarkVesselDetection (risk) | MonitoringDashboard |
|---|---|---|---|
| 鲁荣渔56555 | **0.96** (0~1 스케일) | - | **CRITICAL** (레벨 문자열) |
| 浙甬渔60651 | **0.85** (0~1 스케일) | **94** (0~100 정수) | - |
| 미상선박-A | **0.94** (0~1 스케일) | **96** (0~100 정수) | - |
### 원인 분석
- Dashboard는 `risk: 0.96` 형식 (0~1 소수)
- DarkVesselDetection은 `risk: 96` 형식 (0~100 정수)
- MonitoringDashboard는 `'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'` 레벨 문자열
- LiveMapView는 `risk: 0.94` 형식 (0~1 소수)
- EventList는 레벨 문자열 (`AlertLevel`)
### 통합 방안
위험도를 **0~100 정수** 스케일로 통일하되, 레벨 문자열은 구간별 자동 매핑 유틸로 변환한다.
```
0~30: LOW | 31~60: MEDIUM | 61~85: HIGH | 86~100: CRITICAL
```
---
## 3. KPI 수치 중복
Dashboard와 MonitoringDashboard가 **완전히 동일한 KPI 수치**를 독립적으로 정의하고 있다.
| 지표 | Dashboard `KPI_DATA` | MonitoringDashboard `KPI` |
|---|---|---|
| 실시간 탐지 | 47 | 47 |
| EEZ 침범 | 18 | 18 |
| 다크베셀 | 12 | 12 |
| 불법환적 의심 | 8 | 8 |
| 추적 중 | 15 | 15 |
| 나포/검문(금일 단속) | 3 | 3 |
### 문제점
- 6개 KPI 수치가 두 파일에 100% 동일하게 하드코딩
- 수치 변경 시 양쪽 모두 수정해야 함
- Dashboard에는 `prev` 필드(전일 비교)가 추가로 있으나, Monitoring에는 없음
---
## 4. 이벤트 타임라인 중복
08:47~06:12 시계열 이벤트가 최소 4개 파일에 각각 정의되어 있다.
| 시각 | Dashboard | Monitoring | MobileService | EventList |
|---|---|---|---|---|
| 08:47 | EEZ 침범 (鲁荣渔56555) | EEZ 침범 (鲁荣渔56555 외 2척) | [긴급] EEZ 침범 탐지 | EVT-0001 EEZ 침범 |
| 08:32 | 다크베셀 출현 | 다크베셀 출현 | 다크베셀 출현 | EVT-0002 다크베셀 |
| 08:15 | 선단 밀집 경보 | 선단 밀집 경보 | - | EVT-0003 선단밀집 |
| 07:58 | 불법환적 의심 | 불법환적 의심 | 환적 의심 | EVT-0004 불법환적 |
| 07:41 | MMSI 변조 탐지 | MMSI 변조 탐지 | - | EVT-0005 MMSI 변조 |
| 07:23 | 함정 검문 완료 | 함정 검문 완료 | - | EVT-0006 검문 완료 |
| 06:12 | 속력 이상 탐지 | - | - | EVT-0010 속력 이상 |
### 문제점
- 동일 이벤트의 description이 파일마다 미세하게 다름 (예: "鲁荣渔56555" vs "鲁荣渔56555 외 2척")
- EventList에는 ID가 있으나(EVT-xxxx), 다른 파일에는 없음
- Dashboard에는 10개, Monitoring에는 6개, EventList에는 15개로 **건수도 불일치**
---
## 5. 환적 데이터 100% 중복
`TransferDetection.tsx``ChinaFishing.tsx`에 **TR-001~TR-003 환적 데이터가 완전히 동일**하게 정의되어 있다.
```
TransferDetection.tsx:
const transferData = [
{ id: 'TR-001', time: '2026-01-20 13:42:11', a: {name:'장저우8호'}, b: {name:'黑江9호'}, ... },
{ id: 'TR-002', time: '2026-01-20 11:15:33', ... },
{ id: 'TR-003', time: '2026-01-20 09:23:45', ... },
];
ChinaFishing.tsx:
const TRANSFER_DATA = [
{ id: 'TR-001', time: '2026-01-20 13:42:11', a: {name:'장저우8호'}, b: {name:'黑江9호'}, ... },
{ id: 'TR-002', time: '2026-01-20 11:15:33', ... },
{ id: 'TR-003', time: '2026-01-20 09:23:45', ... },
];
```
### 문제점
- 변수명만 다르고 (`transferData` vs `TRANSFER_DATA`) 데이터 구조와 값이 100% 동일
- 한쪽만 수정하면 다른 쪽과 불일치 발생
---
## 6. 함정 상태 불일치
동일 함정의 상태가 페이지마다 모순되는 경우가 확인되었다.
| 함정 | ShipAgent | Dashboard | PatrolRoute | FleetOptimization |
|---|---|---|---|---|
| 5001함 | **오프라인** (`status: '오프라인'`) | **가용** (PATROL_SHIPS에 대기로 표시) | **가용** (`status: '가용'`) | **가용** (`status: '가용'`) |
| 3009함 | **온라인** (동기화 중) | **검문 중** | **출동중** | **출동중** |
| 1503함 | **미배포** | - | - | **정비중** |
### 문제점
- 5001함이 ShipAgent에서는 오프라인이지만, Dashboard/PatrolRoute/FleetOptimization에서는 가용으로 표시됨 -- **직접적 모순**
- 3009함의 상태가 "온라인", "검문 중", "출동중"으로 파일마다 다름
- 실제 운영 시 혼란을 초래할 수 있는 시나리오 불일치
---
## 7. 현재 상태: 통합 완료
아래 분석에서 식별한 모든 중복/불일치 문제를 해소하기 위해, 7개 공유 Mock 모듈 + 7개 Zustand 스토어 체계로 통합이 **완료**되었다.
### 7.1 완료된 아키텍처: mock -> store -> page
```
┌─────────────────────────────────────────────────────────────────────────┐
│ src/data/mock/ (7개 공유 모듈) │
├───────────┬──────────┬──────────┬────────┬───────────┬────────┬────────┤
│ vessels │ patrols │ events │ kpi │ transfers │ gear │enforce-│
│ .ts │ .ts │ .ts │ .ts │ .ts │ .ts │ment.ts │
└─────┬─────┴─────┬────┴─────┬────┴───┬────┴─────┬────┴───┬────┴───┬────┘
│ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────────┐
│ src/stores/ (7개 Zustand 스토어 + settingsStore) │
├───────────┬──────────┬──────────┬────────┬───────────┬────────┬────────┤
│ vessel │ patrol │ event │ kpi │ transfer │ gear │enforce-│
│ Store │ Store │ Store │ Store │ Store │ Store │mentStr │
└─────┬─────┴─────┬────┴─────┬────┴───┬────┴─────┬────┴───┬────┴───┬────┘
│ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────────┐
│ src/features/*/ (페이지 컴포넌트) │
│ store.load() 호출 -> store에서 데이터 구독 -> 뷰 변환은 페이지 책임 │
└─────────────────────────────────────────────────────────────────────────┘
```
### 7.2 스토어별 소비 현황 (16개 페이지가 스토어 사용)
| 스토어 | 소비 페이지 |
|---|---|
| `useVesselStore` | Dashboard, LiveMapView, DarkVesselDetection, VesselDetail |
| `usePatrolStore` | Dashboard, PatrolRoute, FleetOptimization |
| `useEventStore` | Dashboard, MonitoringDashboard, LiveMapView, EventList, MobileService, AIAlert |
| `useKpiStore` | Dashboard, MonitoringDashboard, Statistics |
| `useTransferStore` | TransferDetection, ChinaFishing |
| `useGearStore` | GearDetection |
| `useEnforcementStore` | EnforcementPlan, EnforcementHistory |
### 7.3 페이지 전용 인라인 데이터 (미통합)
아래 페이지들은 도메인 특성상 공유 mock에 포함하지 않고 페이지 전용 인라인 데이터를 유지한다.
| 페이지 | 인라인 데이터 | 사유 |
|---|---|---|
| ChinaFishing | `COUNTERS_ROW1/2`, `VESSEL_LIST`, `MONTHLY_DATA`, `VTS_ITEMS` | 중국어선 전용 센서 카운터/통계 (다른 페이지에서 미사용) |
| VesselDetail | `VESSELS: VesselTrack[]` | 항적 데이터 구조가 `VesselData`와 다름 (주석으로 명시) |
| MLOpsPage | 실험/배포 데이터 | MLOps 전용 도메인 데이터 |
| MapControl | 훈련구역 데이터 | 해상사격 훈련구역 전용 |
| DataHub | 수신현황 데이터 | 데이터 허브 전용 모니터링 |
| AIModelManagement | 모델/규칙 데이터 | AI 모델 관리 전용 |
| AIAssistant | `SAMPLE_CONVERSATIONS` | 챗봇 샘플 대화 |
| LoginPage | `DEMO_ACCOUNTS` | 데모 인증 정보 |
| 기타 (AdminPanel, SystemConfig 등) | 각 페이지 전용 설정/관리 데이터 | 관리 도메인 특화 |
### 7.4 설계 원칙 (구현 완료)
1. **위험도 0~100 통일**: 모든 선박의 위험도를 0~100 정수로 통일. 레벨 문자열은 유틸 함수로 변환.
2. **단일 원천(Single Source of Truth)**: 각 데이터는 하나의 mock 모듈에서만 정의하고, 스토어를 통해 접근.
3. **Lazy Loading**: 스토어의 `load()` 메서드가 최초 호출 시 `import()`로 mock 데이터를 동적 로딩 (loaded 플래그로 중복 방지).
4. **뷰 변환은 페이지 책임**: mock 모듈/스토어는 원본 데이터만 제공하고, 화면별 가공(필터, 정렬, 포맷)은 각 페이지에서 수행.
### 7.5 Mock 모듈 상세 (참고용)
참고: 초기 분석에서 계획했던 `areas.ts`는 최종 구현 시 `enforcement.ts`(단속 이력 데이터)로 대체되었다.
해역/구역 데이터는 RiskMap, MapControl 등 각 페이지에서 전용 데이터로 관리한다.
| # | 모듈 파일 | 스토어 | 내용 |
|---|---|---|---|
| 1 | `data/mock/vessels.ts` | `vesselStore` | 중국어선 + 한국어선 + 미상선박 마스터 (`MOCK_VESSELS`, `MOCK_SUSPECTS`) |
| 2 | `data/mock/patrols.ts` | `patrolStore` | 경비함정 마스터 + 경로/시나리오/커버리지 |
| 3 | `data/mock/events.ts` | `eventStore` | 이벤트 타임라인 + 알림 데이터 |
| 4 | `data/mock/kpi.ts` | `kpiStore` | KPI 수치 + 월별 추이 |
| 5 | `data/mock/transfers.ts` | `transferStore` | 환적 데이터 (TR-001~003) |
| 6 | `data/mock/gear.ts` | `gearStore` | 어구 데이터 (불법어구 목록) |
| 7 | `data/mock/enforcement.ts` | `enforcementStore` | 단속 이력 + 단속 계획 데이터 |
---
## 8. 작업 완료 요약
| 모듈 | 상태 | 스토어 소비 페이지 수 |
|---|---|---|
| `vessels.ts` | **완료** | 4개 (useVesselStore) |
| `events.ts` | **완료** | 6개 (useEventStore) |
| `patrols.ts` | **완료** | 3개 (usePatrolStore) |
| `kpi.ts` | **완료** | 3개 (useKpiStore) |
| `transfers.ts` | **완료** | 2개 (useTransferStore) |
| `gear.ts` | **완료** | 1개 (useGearStore) |
| `enforcement.ts` | **완료** | 2개 (useEnforcementStore) |
### 실제 작업 결과
- Mock 모듈 생성: 7개 파일 (`src/data/mock/`)
- Zustand 스토어 생성: 7개 + 1개 설정용 (`src/stores/`)
- 기존 페이지 리팩토링: 16개 페이지에서 스토어 소비로 전환
- 나머지 15개 페이지: 도메인 특화 인라인 데이터 유지 (공유 필요성 없음)
---
## 9. 결론
위 1~6절에서 분석한 6개의 심각한 중복/불일치 문제(위험도 스케일, 함정 상태 모순, KPI 중복, 이벤트 불일치, 환적 100% 중복, 선박 교차참조)는 **7개 공유 mock 모듈 + 7개 Zustand 스토어** 도입으로 모두 해소되었다.
달성한 효과:
- **데이터 일관성**: Single Source of Truth로 불일치 원천 차단
- **유지보수성**: 데이터 변경 시 mock 모듈 1곳만 수정
- **확장성**: 신규 페이지 추가 시 기존 store import로 즉시 사용
- **코드 품질**: 중복 인라인 데이터 제거, 16개 페이지가 스토어 기반으로 전환
- **성능**: Zustand lazy loading으로 최초 접근 시에만 mock 데이터 로딩
1~6절의 분석 내용은 통합 전 문제 식별 기록으로 보존한다.

194
docs/next-refactoring.md Normal file
파일 보기

@ -0,0 +1,194 @@
# KCG AI Monitoring - 다음 단계 리팩토링 TODO
> 프론트엔드 UI 스캐폴딩 + 기반 인프라(상태관리, 지도 GPU, mock 데이터, CVA) 완료 상태. 백엔드 연동 및 운영 품질 확보를 위해 남은 항목을 순차적으로 진행한다.
---
## 1. ✅ 상태관리 도입 (Zustand 5.0) — COMPLETED
`zustand` 5.0.12 설치, `src/stores/`에 8개 독립 스토어 구현 완료.
- `vesselStore` — 선박 목록, 선택, 필터
- `patrolStore` — 순찰 경로/함정
- `eventStore` — 탐지/경보 이벤트
- `kpiStore` — KPI 메트릭, 추세
- `transferStore` — 전재(환적)
- `gearStore` — 어구 탐지
- `enforcementStore` — 단속 이력
- `settingsStore` — theme/language + localStorage 동기화, 지도 타일 자동 전환
> `AuthContext`는 유지 (인증은 Context API가 적합, 마이그레이션 불필요로 결정)
---
## 2. API 서비스 계층 (Axios 1.14) — 구조 완성, 실제 연동 대기
### 현재 상태
- `src/services/`에 7개 서비스 모듈 구현 (api, vessel, event, patrol, kpi, ws, index)
- `api.ts`: fetch 래퍼 (`apiGet`, `apiPost`) — 향후 Axios 교체 예정
- 각 서비스가 `data/mock/` 모듈에서 mock 데이터 반환 (실제 HTTP 호출 0건)
- `ws.ts`: STOMP WebSocket 스텁 존재, 미구현
### 남은 작업
- [ ] `axios` 1.14 설치 → `api.ts`의 fetch 래퍼를 Axios 인스턴스로 교체
- [ ] Axios 인터셉터:
- Request: Authorization 헤더 자동 주입
- Response: 401 → 로그인 리다이렉트, 500 → 에러 토스트
- [ ] `@tanstack/react-query` 5.x 설치 → TanStack Query Provider 추가
- [ ] 각 서비스의 mock 반환을 실제 API 호출로 교체
- [ ] 로딩 스켈레톤, 에러 바운더리 공통 컴포넌트
---
## 3. 실시간 인프라 (STOMP.js + SockJS) — 스텁 구조만 존재
### 현재 상태
- `services/ws.ts``connectWs` 스텁 함수 존재 (인터페이스 정의 완료)
- STOMP.js, SockJS 미설치 — 실제 WebSocket 연결 없음
- `useStoreLayerSync` hook으로 store→지도 실시간 파이프라인 준비 완료
### 남은 작업
- [ ] `@stomp/stompjs` + `sockjs-client` 설치
- [ ] `ws.ts` 스텁을 실제 STOMP 클라이언트로 구현
- [ ] 구독 채널 설계:
- `/topic/ais-positions` — 실시간 AIS 위치
- `/topic/alerts` — 경보/이벤트
- `/topic/detections` — 탐지 결과
- `/user/queue/notifications` — 개인 알림
- [ ] 재연결 로직 (지수 백오프)
- [ ] store → `useStoreLayerSync` → 지도 마커 실시간 업데이트 연결
- [ ] `eventStore`와 연동하여 알림 배너/뱃지 카운트 업데이트
---
## 4. ✅ 고급 지도 레이어 (deck.gl 9.2) — COMPLETED
`deck.gl` 9.2.11 + `@deck.gl/mapbox` 설치, MapLibre + deck.gl 인터리브 아키텍처 구현 완료.
- **BaseMap**: `forwardRef` + `memo`, `MapboxOverlay``useImperativeHandle`로 외부 노출
- **useMapLayers**: RAF 배치 레이어 업데이트, React 리렌더 0회
- **useStoreLayerSync**: Zustand store.subscribe → RAF → overlay.setProps (React 우회)
- **STATIC_LAYERS**: EEZ + NLL PathLayer 싱글턴 (GPU 1회 업로드)
- **createMarkerLayer**: ScatterplotLayer + transitions 보간 + DataFilterExtension
- **createRadiusLayer**: 반경 원 표시용 ScatterplotLayer
- 레거시 GeoJSON 레이어(`boundaries.ts`)는 하위 호환으로 유지
> 성능 목표 40만척+ GPU 렌더링 달성. TripsLayer/HexagonLayer/IconLayer는 실데이터 확보 후 추가 예정.
---
## 5. ✅ 더미 데이터 통합 — COMPLETED
`src/data/mock/`에 7개 공유 mock 모듈 구현 완료. TypeScript 인터페이스 정의 포함.
```
data/mock/
├── vessels.ts # VesselData — 선박 목록 (한국, 중국, 경비함)
├── events.ts # EventRecord, AlertRecord — 탐지/단속 이벤트
├── transfers.ts # 전재(환적) 데이터
├── patrols.ts # PatrolShip — 순찰 경로/함정
├── gear.ts # 어구 탐지 데이터
├── kpi.ts # KpiMetric, MonthlyTrend, ViolationType
└── enforcement.ts # 단속 이력 데이터
```
- `services/` 계층이 mock 모듈을 import하여 반환 → 향후 API 교체 시 서비스만 수정
- 인터페이스가 API 응답 타입 계약 역할 수행
---
## 6. i18n 실적용 — 구조 완성, 내부 텍스트 미적용
### 현재 상태
- 10 네임스페이스 리소스 완비: common, dashboard, detection, patrol, enforcement, statistics, ai, fieldOps, admin, auth
- ko/en 각 10파일 (총 20 JSON)
- `settingsStore.toggleLanguage()` + `localStorage` 동기화 구현 완료
- **적용 완료**: MainLayout 사이드바 메뉴명, 24개 페이지 제목, LoginPage
- **미적용**: 각 페이지 내부 텍스트 (카드 레이블, 테이블 헤더, 상태 텍스트 등) — 대부분 한국어 하드코딩 잔존
### 남은 작업
- [ ] 각 feature 페이지 내부 텍스트를 `useTranslation('namespace')` + `t()` 로 교체
- [ ] 날짜/숫자 포맷 로컬라이즈 (`Intl.DateTimeFormat`, `Intl.NumberFormat`)
- [ ] 누락 키 감지 자동화 (i18next missing key handler 또는 lint 규칙)
---
## 7. ✅ Tailwind 공통 스타일 모듈화 (CVA) — COMPLETED
`class-variance-authority` 0.7.1 설치, `src/lib/theme/variants.ts`에 3개 CVA 변형 구현 완료.
- **cardVariants**: default / elevated / inner / transparent — CSS 변수 기반 테마 반응
- **badgeVariants**: 8 intent (critical~cyan) x 4 size (xs~lg) — 150회+ 반복 패턴 통합
- **statusDotVariants**: 4 status (online/warning/danger/offline) x 3 size (sm/md/lg)
- `shared/components/ui/card.tsx`, `badge.tsx`에 CVA 적용 완료
- CSS 변수(`surface-raised`, `surface-overlay`, `border`) 참조로 Dark/Light 자동 반응
---
## 8. 코드 스플리팅 — 미착수
### 현재 상태
- **단일 번들 ~3.2MB** (모든 feature + deck.gl + MapLibre + ECharts 포함)
- `React.lazy` 미적용, 모든 31개 페이지가 동기 import
- 초기 로딩 시 사용하지 않는 페이지 코드까지 전부 다운로드
### 필요한 이유
- 초기 로딩 성능 개선 (FCP, LCP)
- 현장 모바일 환경 (LTE/3G)에서의 사용성 확보
- 번들 캐싱 효율 향상 (변경된 chunk만 재다운로드)
### 구현 계획
- [ ] `React.lazy` + `Suspense`로 feature 단위 동적 임포트:
```typescript
const Dashboard = lazy(() => import('@features/dashboard/Dashboard'));
const RiskMap = lazy(() => import('@features/risk-assessment/RiskMap'));
```
- [ ] `App.tsx` 라우트 전체를 lazy 컴포넌트로 교체
- [ ] 로딩 폴백 컴포넌트 (스켈레톤 또는 스피너) 공통화
- [ ] Vite `build.rollupOptions.output.manualChunks` 설정:
```typescript
manualChunks: {
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
'vendor-map': ['maplibre-gl', 'deck.gl', '@deck.gl/mapbox'],
'vendor-chart': ['echarts'],
}
```
- [ ] 목표: 초기 번들 < 300KB (gzip), feature chunk < 100KB
- [ ] `vite-plugin-compression`으로 gzip/brotli 사전 압축 검토
---
## 9. Light 테마 하드코딩 정리
### 현재 상태
- Dark/Light 테마 전환 구조 완성 (CSS 변수 + `.light` 클래스 + settingsStore)
- 시맨틱 변수(`surface-raised`, `text-heading` 등) + CVA 변형은 정상 작동
- **문제**: 일부 alert/status 색상이 Tailwind 하드코딩 (`bg-red-500/20`, `text-red-400`, `border-red-500/30` 등)
- Dark에서는 자연스러우나, Light 전환 시 대비/가독성 부족
### 구현 계획
- [ ] 하드코딩 alert 색상을 CSS 변수 또는 CVA intent로 교체
- [ ] `badgeVariants`의 intent 색상도 CSS 변수 기반으로 전환 검토
- [ ] Light 모드 전용 대비 테스트 (WCAG AA 기준)
---
## 우선순위 및 의존관계
```
✅ 완료 ─────────────────────────────────────
[1. Zustand] [4. deck.gl] [5. mock 데이터] [7. CVA]
진행 중 / 남은 작업 ──────────────────────────
[6. i18n 내부 텍스트] ──┐
├──▶ [2. API 실제 연동] ──▶ [3. 실시간 STOMP]
[9. Light 테마 정리] ───┘
[8. 코드 스플리팅] ← 독립 작업, 언제든 착수 가능 (~3.2MB → 목표 <300KB)
```
### 권장 진행 순서
1. **Phase A (품질)**: i18n 내부 텍스트 적용 (6) + Light 테마 하드코딩 정리 (9) + 코드 스플리팅 (8)
2. **Phase B (연동)**: Axios 설치 + API 실제 연동 (2)
3. **Phase C (실시간)**: STOMP.js + SockJS 실시간 인프라 (3)

436
docs/page-workflow.md Normal file
파일 보기

@ -0,0 +1,436 @@
# 페이지 역할표 및 업무 파이프라인
> 최초 작성일: 2026-04-06
> 마지막 업데이트: 2026-04-06
> 대상: `kcg-ai-monitoring` 프론트엔드 31개 페이지
---
## 0. 공통 아키텍처
### 디렉토리 구조
모든 페이지는 `src/features/` 아래 도메인별 디렉토리에 배치되어 있다.
```
src/features/
admin/ AccessControl, AdminPanel, DataHub, NoticeManagement, SystemConfig
ai-operations/ AIAssistant, AIModelManagement, MLOpsPage
auth/ LoginPage
dashboard/ Dashboard
detection/ ChinaFishing, DarkVesselDetection, GearDetection, GearIdentification
enforcement/ EnforcementHistory, EventList
field-ops/ AIAlert, MobileService, ShipAgent
monitoring/ MonitoringDashboard
patrol/ FleetOptimization, PatrolRoute
risk-assessment/ EnforcementPlan, RiskMap
statistics/ ExternalService, ReportManagement, Statistics
surveillance/ LiveMapView, MapControl
vessel/ TransferDetection, VesselDetail
```
### 데이터 흐름
모든 공유 데이터는 **mock -> store -> page** 패턴으로 흐른다.
```
src/data/mock/*.ts --> src/stores/*Store.ts --> src/features/*/*.tsx
(7개 공유 모듈) (7개 Zustand 스토어) (16개 페이지가 스토어 소비)
```
- 스토어는 `load()` 호출 시 `import()`로 mock 데이터를 lazy loading
- 도메인 특화 데이터는 페이지 내 인라인으로 유지 (MLOps, MapControl, DataHub 등)
- 상세 매핑은 `docs/data-sharing-analysis.md` 참조
### 지도 렌더링
지도가 필요한 11개 페이지는 공통 `src/lib/map/` 인프라를 사용한다.
- **deck.gl** 기반 렌더링 (`BaseMap.tsx`)
- **`useMapLayers`** 훅: 페이지별 동적 레이어 구성
- **`STATIC_LAYERS`**: EEZ/KDLZ 등 정적 레이어를 상수로 분리하여 zero rerender 보장
- 사용 페이지: Dashboard, LiveMapView, MapControl, EnforcementPlan, PatrolRoute, FleetOptimization, GearDetection, DarkVesselDetection, RiskMap, VesselDetail, MobileService
### 다국어 (i18n)
- `react-i18next` 기반, 24개 페이지 + MainLayout + LoginPage에 i18n 적용
- 지원 언어: 한국어 (ko), 영어 (en)
- 페이지 타이틀, 주요 UI 라벨이 번역 키로 관리됨
### 테마
- `settingsStore`에서 dark/light 테마 전환 지원
- 기본값: dark (해양 감시 시스템 특성상)
- `localStorage`에 선택 유지, CSS 클래스 토글 방식
---
## 1. 31개 페이지 역할표
### 1.1 인증/관리 (4개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| SFR-01 | LoginPage | `/login` | 전체 | SSO/GPKI/비밀번호 인증, 5회 실패 잠금 | ID/PW, 인증 방식 선택 | 세션 발급, 역할 부여 | - | 모든 페이지 (인증 게이트) |
| SFR-01 | AccessControl | `/access-control` | 관리자 | RBAC 권한 관리, 감사 로그 | 역할/사용자/권한 설정 | 권한 변경, 감사 기록 | LoginPage | 전체 시스템 접근 제어 |
| SFR-02 | SystemConfig | `/system-config` | 관리자 | 공통코드 기준정보 관리 (해역52/어종578/어업59/선박186) | 코드 검색/필터 | 코드 조회, 설정 변경 | AccessControl | 탐지/분석 엔진 기준데이터 |
| SFR-02 | NoticeManagement | `/notices` | 관리자 | 시스템 공지(배너/팝업/토스트), 역할별 대상 설정 | 공지 작성, 기간/대상 설정 | 배너/팝업 노출 | AccessControl | 모든 페이지 (NotificationBanner) |
### 1.2 데이터 수집/연계 (1개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| SFR-03 | DataHub | `/data-hub` | 관리자 | 통합데이터 허브 — 선박신호 수신 현황 히트맵, 연계 채널 모니터링 | 수신 소스 선택 | 수신률 조회, 연계 상태 확인 | 외부 센서 (VTS, AIS, V-PASS 등) | 탐지 파이프라인 전체 |
### 1.3 AI 모델/운영 (3개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| SFR-04 | AIModelManagement | `/ai-model` | 분석관 | 모델 레지스트리, 탐지 규칙, 피처 엔지니어링, 학습 파이프라인, 7대 탐지엔진 | 모델 버전/규칙/피처 설정 | 모델 배포, 성능 리포트 | DataHub (학습 데이터) | DarkVessel, GearDetection, TransferDetection 등 탐지 엔진 |
| SFR-18/19 | MLOpsPage | `/mlops` | 분석관/관리자 | MLOps/LLMOps 운영 대시보드 (실험, 배포, API Playground, LLM 테스트) | 실험 템플릿, HPS 설정 | 실험 결과, 모델 배포 | AIModelManagement | AIAssistant, 탐지 엔진 |
| SFR-20 | AIAssistant | `/ai-assistant` | 상황실/분석관 | 자연어 Q&A 의사결정 지원 (법령 조회, 대응 절차 안내) | 자연어 질의 | 답변 + 법령 참조 | MLOpsPage (LLM 모델) | 작전 의사결정 |
### 1.4 탐지 (4개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| SFR-09 | DarkVesselDetection | `/dark-vessel` | 분석관 | AIS 조작/위장/Dark Vessel 패턴 탐지 (6가지 패턴), 지도+테이블 | AIS 데이터 스트림 | 의심 선박 목록, 위험도, 라벨 분류 | DataHub (AIS/레이더) | RiskMap, LiveMapView, EventList |
| SFR-10 | GearDetection | `/gear-detection` | 분석관 | 불법 어망/어구 탐지 및 관리, 허가 상태 판정 | 어구 센서/영상 | 어구 목록, 불법 판정 결과 | DataHub (센서) | RiskMap, EnforcementPlan |
| - | GearIdentification | `features/detection/` | 분석관 | 어구 국적 판별 (중국/한국/불확실), GB/T 5147 기준 | 어구 물리적 특성 입력 | 판별 결과 (국적, 신뢰도, 경보등급) | GearDetection | EnforcementHistory |
| - | ChinaFishing | `/china-fishing` | 분석관/상황실 | 중국어선 통합 감시 (센서 카운터, 특이운항, 월별 통계, 환적 탐지, VTS 연계) | 센서 데이터 융합 | 감시 현황, 환적 의심 목록 | DataHub, DarkVessel | RiskMap, EnforcementPlan |
### 1.5 환적 탐지 (1개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| - | TransferDetection | `features/vessel/` | 분석관 | 선박 간 근접 접촉 및 환적 의심 행위 분석 (거리/시간/속도 기준) | AIS 궤적 분석 | 환적 이벤트 목록, 의심도 점수 | DataHub, DarkVessel | EventList, EnforcementPlan |
### 1.6 위험도 평가/계획 (2개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| SFR-05 | RiskMap | `/risk-map` | 분석관/상황실 | 격자 기반 불법조업 위험도 지도 + MTIS 해양사고 통계 연계 | 탐지 결과, 사고 통계 | 히트맵, 해역별 위험도, 사고 통계 차트 | DarkVessel, GearDetection, ChinaFishing | EnforcementPlan, PatrolRoute |
| SFR-06 | EnforcementPlan | `/enforcement-plan` | 상황실 | 단속 계획 수립, 경보 연계, 우선지역 예보 | 위험도 데이터, 가용 함정 | 단속 계획 테이블, 지도 표시 | RiskMap | PatrolRoute, FleetOptimization |
### 1.7 순찰/함대 (2개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| SFR-07 | PatrolRoute | `/patrol-route` | 상황실 | AI 단일 함정 순찰 경로 추천 (웨이포인트, 거리/시간/연료 산출) | 함정 선택, 구역 조건 | 추천 경로, 웨이포인트 목록 | EnforcementPlan, RiskMap | 함정 출동 (ShipAgent) |
| SFR-08 | FleetOptimization | `/fleet-optimization` | 상황실 | 다함정 협력형 경로 최적화 (커버리지 시뮬레이션, 승인 워크플로) | 함대 목록, 구역 조건 | 최적화 결과, 커버리지 비교 | EnforcementPlan, PatrolRoute | 함정 출동 (ShipAgent) |
### 1.8 감시/지도 (2개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| - | LiveMapView | `/events` | 상황실 | 실시간 해역 감시 지도 (AIS 선박 + 이벤트 경보 + 아군 함정) | 실시간 AIS/이벤트 스트림 | 지도 마커, 이벤트 카드, 위험도 바 | 탐지 엔진 전체 | EventList, AIAlert |
| - | MapControl | `/map-control` | 상황실/관리자 | 해역 통제 관리 (해상사격 훈련구역도 No.462, 군/해경 구역) | 구역 데이터 | 훈련구역 지도, 상태 테이블 | 국립해양조사원 데이터 | LiveMapView (레이어) |
### 1.9 대시보드/모니터링 (2개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| - | Dashboard | `/dashboard` | 전체 | 종합 상황판 (KPI, 타임라인, 위험선박 TOP8, 함정 현황, 해역 위험도, 시간대별 탐지 추이) | 전 시스템 데이터 집계 | 한눈에 보는 현황 | 탐지/순찰/이벤트 전체 | 각 상세 페이지로 드릴다운 |
| SFR-12 | MonitoringDashboard | `/monitoring` | 상황실 | 모니터링 및 경보 현황판 (KPI, 24시간 추이, 탐지 유형 분포, 실시간 이벤트) | 경보/탐지 데이터 | 경보 현황 대시보드 | 탐지 엔진, EventList | AIAlert, EnforcementPlan |
### 1.10 이벤트/이력 (2개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| - | EventList | `/event-list` | 상황실/분석관 | 이벤트 전체 목록 (검색/정렬/페이징/엑셀/출력), 15건+ 이벤트 | 필터 조건 | 이벤트 테이블, 엑셀 내보내기 | 탐지 엔진, LiveMapView | EnforcementHistory, ReportManagement |
| SFR-11 | EnforcementHistory | `/enforcement-history` | 분석관 | 단속/탐지 이력 관리 (AI 매칭 검증 포함) | 검색 조건 | 이력 테이블, AI 일치 여부 | EventList, 현장 단속 | ReportManagement, Statistics |
### 1.11 현장 대응 (3개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| SFR-15 | MobileService | `/mobile-service` | 현장 단속요원 | 모바일 앱 프리뷰 (위험도/의심선박/경로추천/경보, 푸시 설정) | 모바일 위치, 푸시 설정 | 경보 수신, 지도 조회 | AIAlert, LiveMapView | 현장 단속 수행 |
| SFR-16 | ShipAgent | `/ship-agent` | 현장 단속요원 | 함정용 Agent 관리 (배포/동기화 상태, 버전 관리) | 함정 Agent 설치 | Agent 상태 조회, 동기화 | PatrolRoute, FleetOptimization | 현장 단속 수행 |
| SFR-17 | AIAlert | `/ai-alert` | 상황실/현장 | AI 탐지 알림 자동 발송 (함정/관제요원 대상, 탐지시각/좌표/유형/신뢰도 포함) | 탐지 이벤트 트리거 | 알림 발송, 수신 확인 | MonitoringDashboard, EventList | MobileService, ShipAgent |
### 1.12 통계/외부연계/보고 (3개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| SFR-13 | Statistics | `/statistics` | 상황실/분석관 | 통계/지표/성과 분석 (월별 추이, 위반유형, KPI 달성률) | 기간/유형 필터 | 차트, KPI 테이블, 보고서 | EnforcementHistory, EventList | 외부 보고, 전략 수립 |
| SFR-14 | ExternalService | `/external-service` | 관리자/외부 | 외부 서비스 제공 (해수부/수협/기상청 API/파일 연계, 비식별/익명화 정책) | 서비스 설정 | API 호출 수, 연계 상태 | Statistics, 탐지 결과 | 외부기관 |
| - | ReportManagement | `/reports` | 분석관/상황실 | 증거 관리 및 보고서 생성 (사건별 자동 패키징) | 사건 선택, 증거 파일 업로드 | 보고서 PDF, 증거 패키지 | EnforcementHistory, EventList | 검찰/외부기관 |
### 1.13 선박 상세 (1개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| - | VesselDetail | `/vessel/:id` | 분석관/상황실 | 선박 상세 정보 (AIS 데이터, 항적, 입항 이력, 선원 정보, 비허가 선박 목록) | 선박 ID/MMSI | 상세 프로필, 지도 항적 | LiveMapView, DarkVessel, EventList | EnforcementPlan, ReportManagement |
### 1.14 시스템 관리 (1개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| - | AdminPanel | `/admin` | 관리자 | 시스템 인프라 관리 (서버 상태, CPU/메모리/디스크 모니터링) | - | 서버 상태 대시보드 | - | 시스템 안정성 보장 |
---
## 2. 업무 파이프라인 (4개)
### 2.1 탐지 파이프라인
불법 조업을 탐지하고 실시간 감시하여 현장 작전까지 연결하는 핵심 파이프라인.
```
AIS/레이더/위성 신호
┌─────────┐
│ DataHub │ ← 통합데이터 허브 (VTS, AIS, V-PASS, E-Nav 수집)
└────┬────┘
┌──────────────────────────────────────────────┐
│ AI 탐지 엔진 (AIModelManagement 관리) │
│ │
│ DarkVesselDetection ─ AIS 조작/위장/소실 │
│ GearDetection ─────── 불법 어구 탐지 │
│ ChinaFishing ──────── 중국어선 통합 감시 │
│ TransferDetection ─── 환적 행위 탐지 │
│ GearIdentification ── 어구 국적 판별 │
└──────────────┬───────────────────────────────┘
┌──────────┐ ┌───────────────────┐
│ RiskMap │─────▶│ LiveMapView │ ← 실시간 지도 감시
└────┬─────┘ │ MonitoringDashboard│ ← 경보 현황판
│ └───────────────────┘
┌──────────────────┐
│ EnforcementPlan │ ← 단속 우선지역 예보
└────────┬─────────┘
┌──────────────┐ ┌───────────────────┐
│ PatrolRoute │─────▶│ FleetOptimization │ ← 다함정 최적화
└──────┬───────┘ └─────────┬─────────┘
│ │
▼ ▼
┌──────────┐
│ AIAlert │ ← 함정/관제 자동 알림 발송
└────┬─────┘
현장 작전 (MobileService, ShipAgent)
```
### 2.2 대응 파이프라인
AI 알림 수신 후 현장 단속, 이력 기록, 보고서 생성까지의 대응 프로세스.
```
┌──────────┐
│ AIAlert │ ← AI 탐지 알림 자동 발송
└────┬─────┘
┌──────────────────────────────────┐
│ 현장 대응 │
│ │
│ MobileService ── 모바일 경보 수신│
│ ShipAgent ────── 함정 Agent 연동 │
└──────────────┬───────────────────┘
현장 단속 수행
(정선/검문/나포/퇴거)
┌──────────────────────┐
│ EnforcementHistory │ ← 단속 이력 등록, AI 매칭 검증
└──────────┬───────────┘
┌──────────────────────┐
│ ReportManagement │ ← 증거 패키징, 보고서 생성
└──────────┬───────────┘
검찰/외부기관 (ExternalService 통해 연계)
```
### 2.3 분석 파이프라인
축적된 데이터를 분석하여 전략적 의사결정을 지원하는 파이프라인.
```
┌─────────────┐
│ Statistics │ ← 월별 추이, 위반유형, KPI 달성률
└──────┬──────┘
┌──────────┐
│ RiskMap │ ← 격자 위험도 + MTIS 해양사고 통계
└────┬─────┘
┌──────────────┐
│ VesselDetail │ ← 개별 선박 심층 분석 (항적, 이력)
└──────┬───────┘
┌──────────────┐
│ AIAssistant │ ← 자연어 Q&A (법령 조회, 대응 절차)
└──────┬───────┘
전략 수립 (순찰 패턴, 탐지 규칙 조정)
```
### 2.4 관리 파이프라인
시스템 접근 제어, 환경 설정, 데이터 관리, 인프라 모니터링 파이프라인.
```
┌────────────────┐
│ AccessControl │ ← RBAC 역할/권한 설정
└───────┬────────┘
┌────────────┐
│ LoginPage │ ← SSO/GPKI/비밀번호 인증
└──────┬─────┘
┌──────────────────────────────────────┐
│ 시스템 설정/관리 │
│ │
│ SystemConfig ──── 공통코드/환경설정 │
│ NoticeManagement ── 공지/배너/팝업 │
│ DataHub ────────── 데이터 수집 관리 │
│ AdminPanel ────── 서버/인프라 모니터 │
└──────────────────────────────────────┘
```
---
## 3. 사용자 역할별 페이지 접근 매트릭스
시스템에 정의된 5개 역할(LoginPage의 `DEMO_ACCOUNTS` 및 AccessControl의 `ROLES` 기반)에 대한 페이지 접근 권한.
### 3.1 역할 정의
| 역할 | 코드 | 설명 | 인원(시뮬) |
|---|---|---|---|
| 시스템 관리자 | `ADMIN` | 전체 시스템 관리 권한 | 3명 |
| 상황실 운영자 | `OPERATOR` | 상황판, 통계, 경보 운영 | 12명 |
| 분석 담당자 | `ANALYST` | AI 모델, 통계, 항적 분석 | 8명 |
| 현장 단속요원 | `FIELD` | 함정 Agent, 모바일 대응 | 45명 |
| 유관기관 열람자 | `VIEWER` | 공유 대시보드 열람 | 6명 |
### 3.2 접근 매트릭스
| 페이지 | ADMIN | OPERATOR | ANALYST | FIELD | VIEWER |
|---|---|---|---|---|---|
| **인증/관리** | | | | | |
| LoginPage | O | O | O | O | O |
| AccessControl | O | - | - | - | - |
| SystemConfig | O | - | - | - | - |
| NoticeManagement | O | - | - | - | - |
| AdminPanel | O | - | - | - | - |
| **데이터/AI** | | | | | |
| DataHub | O | - | - | - | - |
| AIModelManagement | O | - | O | - | - |
| MLOpsPage | O | - | O | - | - |
| AIAssistant | O | O | O | - | - |
| **탐지** | | | | | |
| DarkVesselDetection | O | - | O | - | - |
| GearDetection | O | - | O | - | - |
| ChinaFishing | O | O | O | - | - |
| TransferDetection | O | - | O | - | - |
| **위험도/계획** | | | | | |
| RiskMap | O | O | O | - | - |
| EnforcementPlan | O | O | - | - | - |
| **순찰** | | | | | |
| PatrolRoute | O | O | - | - | - |
| FleetOptimization | O | O | - | - | - |
| **감시/지도** | | | | | |
| LiveMapView | O | O | O | - | - |
| MapControl | O | O | - | - | - |
| **대시보드** | | | | | |
| Dashboard | O | O | O | O | O |
| MonitoringDashboard | O | O | - | - | - |
| **이벤트/이력** | | | | | |
| EventList | O | O | O | O | - |
| EnforcementHistory | O | - | O | - | - |
| **현장 대응** | | | | | |
| MobileService | O | - | - | O | - |
| ShipAgent | O | - | - | O | - |
| AIAlert | O | O | - | O | - |
| **통계/보고** | | | | | |
| Statistics | O | O | O | - | - |
| ExternalService | O | - | - | - | O |
| ReportManagement | O | O | O | - | - |
| **선박 상세** | | | | | |
| VesselDetail | O | O | O | - | - |
### 3.3 역할별 요약
| 역할 | 접근 가능 페이지 | 페이지 수 |
|---|---|---|
| **시스템 관리자** (ADMIN) | 전체 페이지 | 31 |
| **상황실 운영자** (OPERATOR) | Dashboard, MonitoringDashboard, LiveMapView, MapControl, EventList, EnforcementPlan, PatrolRoute, FleetOptimization, ChinaFishing, RiskMap, Statistics, ReportManagement, AIAssistant, AIAlert, VesselDetail | 15 |
| **분석 담당자** (ANALYST) | Dashboard, DarkVesselDetection, GearDetection, ChinaFishing, TransferDetection, RiskMap, LiveMapView, EventList, EnforcementHistory, Statistics, ReportManagement, VesselDetail, AIAssistant, AIModelManagement, MLOpsPage | 15 |
| **현장 단속요원** (FIELD) | Dashboard, MobileService, ShipAgent, AIAlert, EventList | 5 |
| **유관기관 열람자** (VIEWER) | Dashboard, ExternalService | 2 |
---
## 4. 페이지 간 데이터 흐름 요약
```
┌──────────────────┐
│ LoginPage │
│ (인증 게이트) │
└────────┬─────────┘
┌────────────────────┬┴──────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌─────────────────┐ ┌─────────────┐
│ 관리 파이프라인│ │ 탐지 파이프라인 │ │ 현장 대응 │
│ │ │ │ │ │
│ AccessControl│ │ DataHub │ │ MobileSvc │
│ SystemConfig │ │ ↓ │ │ ShipAgent │
│ NoticeManage │ │ AI탐지엔진 │ │ AIAlert │
│ DataHub │ │ (DV/Gear/CN/TR)│ └──────┬──────┘
│ AdminPanel │ │ ↓ │ │
└──────────────┘ │ RiskMap │ │
│ ↓ │ ▼
│ EnforcementPlan │ ┌──────────────┐
│ ↓ │ │ 대응 파이프라인│
│ PatrolRoute │ │ │
│ FleetOptim │ │ Enforcement │
│ ↓ │ │ History │
│ LiveMapView │ │ ReportManage │
│ Monitoring │ │ ExternalSvc │
└────────┬────────┘ └──────────────┘
┌─────────────────┐
│ 분석 파이프라인 │
│ │
│ Statistics │
│ VesselDetail │
│ AIAssistant │
└─────────────────┘
```
---
## 5. 미할당 SFR 참고
현재 라우트에서 확인되는 SFR 번호 기준, 아래 기능은 기존 페이지에 통합되어 있다:
- **Dashboard**: SFR 번호 미부여, 종합 상황판 (기존 유지)
- **LiveMapView**: SFR 번호 미부여, 실시간 감시 지도
- **EventList**: SFR-02 공통 컴포넌트 적용 대상으로 분류
- **MapControl**: SFR 번호 미부여, 해역 통제 관리
- **VesselDetail**: SFR 번호 미부여, 선박 상세
- **ReportManagement**: SFR 번호 미부여, 증거/보고서 관리
- **AdminPanel**: SFR 번호 미부여, 인프라 관리
- **GearIdentification**: ChinaFishing 내 서브 컴포넌트

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -1,8 +1,7 @@
# SFR 요구사항별 화면 사용 가이드
> **문서 작성일:** 2026-04-06
> **최종 업데이트:** 2026-04-17 (2026-04-17 릴리즈 기준)
> **시스템 버전:** 운영 배포 (rocky-211 + redis-211)
> **시스템 버전:** v0.1.0 (프로토타입)
> **다국어:** 한국어/영어 전환 지원 (헤더 우측 EN/한국어 버튼)
> **테마:** 다크/라이트 전환 지원 (헤더 우측 해/달 아이콘 버튼)
@ -12,12 +11,7 @@
이 문서는 **KCG AI 모니터링 시스템**의 각 SFR(소프트웨어 기능 요구사항)이 화면에서 어떻게 구현되어 있는지를 **비개발자**(일반 사용자, 사업 PM, 산출물 작성자)가 이해할 수 있도록 정리한 가이드입니다.
### 시스템 현황 (2026-04-17 기준)
- **프런트엔드·백엔드·분석엔진(prediction) 운영 배포 완료** — 자체 JWT 인증 + 트리 기반 RBAC + 감사 로그 + 65+ API
- **AI 분석 엔진(prediction)**: 5분 주기로 AIS 원천 데이터(snpdb)를 분석하여 결과를 `kcgaidb` 에 자동 저장 (14 알고리즘 + DAR-03 G-01~G-06)
- **실시간 연동 화면**: Dashboard / MonitoringDashboard / ChinaFishing / DarkVesselDetection / GearDetection / EnforcementHistory / EventList / AIAlert / Statistics / AccessControl / PermissionsPanel / Audit 등 **15+ 화면이 실 API + prediction 결과를 실시간으로 표시**
- **Mock 화면**: DataHub / AIModelManagement / RiskMap / PatrolRoute / FleetOptimization / ExternalService / ShipAgent / MLOpsPage / AIAssistant 는 UI 완성, 백엔드/AI 엔진 연동은 단계적 추가 중
- **자세한 추적 매트릭스**: `docs/sfr-traceability.md` v3.0 참조
현재 시스템은 **프로토타입 단계(v0.1.0)**로, 모든 SFR의 UI가 완성되어 있으나 백엔드 서버 연동은 아직 이루어지지 않았습니다. 화면에 표시되는 데이터는 시연용 샘플 데이터입니다.
---
@ -61,18 +55,17 @@
- 역할별 데모 계정 선택 (ADMIN, OPERATOR, ANALYST, FIELD, VIEWER)
- 로그인 후 역할에 따른 메뉴 접근 제어
**구현 완료 (2026-04-17 기준):**
- ✅ 로그인 화면 UI + 자체 ID/PW 인증 + JWT 쿠키 세션 + 역할별 데모 계정 5종 실 로그인
- ✅ 비밀번호 정책(9자 이상 영문+숫자+특수) + 5회 실패 30분 잠금 + BCrypt 해시
- ✅ 트리 기반 RBAC (47 리소스 노드, Level 0 13개 + Level 1 32개, 5 operation) + Caffeine 10분 TTL
- ✅ 모든 로그인 시도 감사 로그 저장 및 조회 (로그인 이력 화면)
- ✅ 역할 기반 세션 유지 및 메뉴 접근 제어 (사이드바/라우트 가드)
**구현 완료:**
- ✅ 로그인 화면 UI 및 데모 계정 5종 로그인 기능
- ✅ 역할 기반 세션 유지 및 메뉴 접근 제어
**향후 구현 예정 (기업 환경 연동):**
- 🔲 SSO(해양경찰 통합인증) 연동
**향후 구현 예정:**
- 🔲 SSO(Single Sign-On) 연동
- 🔲 GPKI(정부 공인인증서) 인증 연동
- 🔲 공무원증 기반 인증 연동
- 🔲 인사 시스템 연동 역할 자동 부여
- 🔲 실제 사용자 DB 연동 및 비밀번호 암호화
**보완 필요:**
- ⚠️ 현재 데모 계정은 하드코딩되어 있으며, 운영 환경에서는 실제 인증 체계로 대체 필요
---
@ -90,17 +83,16 @@
- 역할별 접근 가능 메뉴 및 기능 권한 설정
- 사용자 목록 조회 및 역할 할당
**구현 완료 (2026-04-17 기준):**
- ✅ 트리 기반 RBAC 실 운영 — 47 리소스 노드 × 5 operation (READ/CREATE/UPDATE/DELETE/EXPORT) × 다중 역할 OR 합집합
- ✅ 역할별 권한 매트릭스 시각화 (셀 클릭 Y → N → 상속 사이클)
- ✅ 부모 READ 거부 시 자식 강제 거부, 상속 표시
- ✅ 역할 CRUD (admin:role-management) + 권한 매트릭스 갱신 (admin:permission-management)
- ✅ 사용자-역할 할당 다이얼로그 (admin:user-management)
- ✅ 모든 권한 변경은 `auth_audit_log` 에 자동 기록 (ROLE_CREATE/UPDATE/DELETE/PERM_UPDATE/USER_ROLE_ASSIGN)
**구현 완료:**
- ✅ RBAC 5역할 체계 UI 및 역할별 권한 매트릭스 표시
- ✅ 권한 설정 화면 레이아웃 및 인터랙션
**향후 구현 예정:**
- 🔲 권한 변경 이력 UI (auth_audit_log 조회는 현재 별도 화면)
- 🔲 역할 템플릿 복제 기능
- 🔲 실제 사용자 DB 연동을 통한 권한 CRUD
- 🔲 감사 로그(권한 변경 이력) 기록
**보완 필요:**
- ⚠️ 현재 화면의 데이터는 샘플이며 실제 저장/반영되지 않음
---
@ -377,18 +369,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 의심 선박 상세 프로필 및 이동 궤적 조회
- 위험도 등급별 분류 표시
**구현 완료 (2026-04-17 기준):**
- ✅ **AI 분석 엔진(prediction) 5분 주기 실시간 탐지 결과 표시** — snpdb AIS 원천 데이터 기반
- ✅ Dark Vessel 11패턴 기반 0~100점 연속 점수 + 4단계 tier(CRITICAL≥70 / HIGH≥50 / WATCH≥30 / NONE)
- ✅ DarkDetailPanel — 선박 선택 시 ScoreBreakdown으로 P1~P11 각 패턴별 기여도 표시
- ✅ 지도 기반 실시간 위치 + tier별 색상 구분 (라이트/다크 모드 대응)
- ✅ 최근 1시간 / 중국 선박(MMSI 412*) 필터, MMSI/선박명/패턴 검색
- ✅ 특이운항 미니맵 (24h 궤적 + DARK/SPOOFING/TRANSSHIP/GEAR_VIOLATION/HIGH_RISK 구간 병합 하이라이트)
**구현 완료:**
- ✅ 의심 선박 7척 목록/지도 시각화
- ✅ 5가지 행동 패턴 분석 결과 UI
**향후 구현 예정:**
- 🔲 spoofing_score 산출 재설계 (중국 MID 412 선박 전원 0 수렴 이슈, BD-09 필터 + teleport 25kn 임계 재검토)
- 🔲 AI Dark Vessel 탐지 엔진 연동
- 🔲 실시간 AIS 데이터 분석 연동
- 🔲 SAR(위성영상) 기반 탐지 연동
- 🔲 과거 이력 차트 (현재는 최근 24h 중심)
**보완 필요:**
- ⚠️ 현재 탐지 결과는 샘플 데이터이며, AI 탐지 엔진 연동 후 실시간 탐지 결과로 교체 필요
---
@ -407,17 +398,16 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 해역별 중국 어선 밀집도 분석
- 시계열 활동 패턴 분석
**구현 완료 (2026-04-17 기준):**
- ✅ **3개 탭(AI 감시 대시보드 / 환적접촉탐지 / 어구·어망 판별) 전부 실데이터 연동**`/api/analysis/*` 경유, MMSI prefix `412` 고정
- ✅ 중국 선박 전체 분석 결과 실시간 그리드 (최근 1h, 위험도순 상위 200건)
- ✅ 특이운항 판별 — riskScore ≥ 40 상위 목록 + 선박 클릭 시 24h 궤적 미니맵 + 판별 구간 패널
- ✅ 해역별 통항량 + 안전도 분석 (종합 위험/안전 지수) + 위험도 도넛
- ✅ 자동탐지 결과(어구 판별 탭) row 클릭 시 상단 입력 폼 자동 프리필
**구현 완료:**
- ✅ 중국 어선 분석 종합 대시보드 UI
- ✅ 지도 기반 활동 현황 시각화
**향후 구현 예정:**
- 🔲 관심영역 / VIIRS 위성영상 / 기상 예보 / VTS연계 (현재 "데모 데이터" 배지)
- 🔲 비허가 / 제재 / 관심 선박 탭 데이터 소스 연동 (현재 "준비중" 배지)
- 🔲 월별 집계 API 연동 (현재 통계 탭 "준비중")
- 🔲 AI 탐지 엔진 연동 (Dark Vessel + 어구 탐지 통합)
- 🔲 실시간 데이터 기반 분석 갱신
**보완 필요:**
- ⚠️ 현재 분석 데이터는 샘플이며, 실제 탐지 엔진 연동 필요
---
@ -436,17 +426,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 탐지 결과 상세 정보 (위치, 크기, 어구 유형, 위험도)
- 탐지 이미지 확인
**구현 완료 (2026-04-17 기준):**
- ✅ **DAR-03 G-01~G-06 실시간 탐지 결과** — prediction 5분 주기 + 한중어업협정 906척 레지스트리(V029) 매칭 53%+
- ✅ G코드별 탐지: G-01(수역-어구 불일치) / G-02(금어기) / G-03(미등록 어구) / G-04(MMSI cycling) / G-05(고정어구 drift) / G-06(쌍끌이 — STRONG/PROBABLE/SUSPECT tier)
- ✅ 어구 그룹 지도 (ZONE_I~IV 폴리곤 + GeoJsonLayer + IconLayer) + 세부 필터 패널(해역/판정/위험도/모선 상태/허가/멤버 수) + localStorage 영속화
- ✅ GearDetailPanel — 후보 클릭 → 점수 근거(관측 7종 + 보정 3종) + 모선 확정/제외 버튼
- ✅ 24h 궤적 리플레이 (GearReplayController + TripsLayer, SPEED_FACTOR=2880, 24h→30s)
- ✅ 어구/어망 판별 화면 — 허가코드/어구물리특성/발견위치 입력 → 국적 판별(한/중/미확인) + 판별 근거·경고·AI 탐지 Rule·교차 검증 파이프라인
**구현 완료:**
- ✅ 어구 6건 탐지 결과 목록/지도 UI
- ✅ 어구 식별 결정트리 시각화
**향후 구현 예정:**
- 🔲 영상(CCTV/SAR) 기반 어구 자동 분류
- 🔲 한·중 어구 5종 구조 비교 이미지 라이브러리 확장
- 🔲 AI 어구 탐지 모델 연동 (영상 분석 기반)
- 🔲 실시간 CCTV/SAR 영상 분석 연동
- 🔲 탐지 결과 자동 분류 및 알림
**보완 필요:**
- ⚠️ 현재 탐지 결과는 샘플 데이터이며, AI 탐지 모델 연동 후 실제 탐지 결과로 교체 필요
---
@ -465,17 +455,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 이력 상세 정보 조회 및 검색/필터
- 이력 데이터 엑셀 내보내기
**구현 완료 (2026-04-17 기준):**
- ✅ **실시간 이벤트 조회**`/api/events` 페이징/필터/확인(ACK)/상태 변경
- ✅ **단속 이력 CRUD**`/api/enforcement/records` (GET/POST/PATCH) + ENF-yyyyMMdd-NNNN UID 자동 발급
- ✅ 이벤트 발생 → 확인 → 단속 등록 → 오탐 처리 워크플로우 (액션 버튼 4종)
- ✅ 모든 쓰기 액션 `auth_audit_log` 자동 기록 (ENFORCEMENT_CREATE / ENFORCEMENT_UPDATE / ACK_EVENT / UPDATE_EVENT_STATUS)
- ✅ KPI 카운트 (CRITICAL/HIGH/MEDIUM/LOW) + 엑셀 내보내기 + 출력
- ✅ 단속 완료 시 prediction_events.status 자동 RESOLVED 갱신
**구현 완료:**
- ✅ 단속 이력 6건 목록/상세 UI
- ✅ AI 매칭 검증 결과 표시
**향후 구현 예정:**
- 🔲 증거 파일(사진/영상) 업로드 서버 연동
- 🔲 AI 매칭 검증 정량 지표 (탐지↔단속 confusion matrix)
- 🔲 단속 이력 DB 연동 (조회/등록/수정)
- 🔲 AI 매칭 검증 엔진 연동
- 🔲 탐지-단속 연계 자동 분석
**보완 필요:**
- ⚠️ 현재 이력 데이터는 샘플이며, DB 연동 후 실제 단속 데이터로 교체 필요
---
@ -497,15 +487,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 함정 배치 현황 요약
- 실시간 경보 알림 표시
**구현 완료 (2026-04-17 기준):**
- ✅ **실시간 KPI 카드**`/api/stats/kpi` 연동, prediction 5분 주기 결과 기반
- ✅ 실시간 상황 타임라인 — 최근 `prediction_events` 스트림 (긴급/경고 카운트 실시간)
- ✅ 함정 배치 현황 + 경보 알림 + 순찰 현황 통합
- ✅ 라이트/다크 모드 반응형 (2026-04-17 PR #C 하드코딩 색상 제거)
**구현 완료:**
- ✅ KPI 카드 + 히트맵 + 타임라인 + 함정 현황 통합 대시보드 UI
- ✅ 반응형 레이아웃 (화면 크기에 따른 자동 배치)
**향후 구현 예정:**
- 🔲 WebSocket 기반 실시간 push (현재는 주기 polling)
- 🔲 맞춤형 대시보드 레이아웃 (드래그/리사이즈)
- 🔲 실시간 데이터 연동 (WebSocket 등)
- 🔲 KPI 수치 실시간 갱신
- 🔲 히트맵/타임라인 실시간 업데이트
**보완 필요:**
- ⚠️ 현재 모든 수치는 샘플 데이터이며, 실시간 연동 후 정확한 운영 데이터로 교체 필요
---
@ -524,15 +516,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 경보 처리(확인/대응/종결) 워크플로우
- 경보 발생 이력 조회
**구현 완료 (2026-04-17 기준):**
- ✅ **실시간 경보 수신**`/api/events` + `/api/alerts` 실 API 연동, prediction event_generator 4룰 기반
- ✅ 경보 등급별(CRITICAL/HIGH/MEDIUM/LOW) 현황 + KPI 카운트
- ✅ 경보 처리 워크플로우 — 확인(ACK) → 단속 등록 → 오탐 처리 (DB 저장 + `auth_audit_log` 기록)
- ✅ 시스템 상태 패널 (백엔드/AI 분석 엔진/DB 상태 실시간 표시, 30초 자동 갱신)
**구현 완료:**
- ✅ 경보 등급별 현황판 UI
- ✅ 경보 목록/상세 조회 화면
**향후 구현 예정:**
- 🔲 경보 자동 에스컬레이션 정책
- 🔲 경보 룰 커스터마이즈 UI
- 🔲 실시간 경보 수신 연동
- 🔲 경보 처리 워크플로우 DB 연동
- 🔲 경보 자동 에스컬레이션
**보완 필요:**
- ⚠️ 현재 경보 데이터는 샘플이며, 실시간 연동 후 실제 경보 데이터로 교체 필요
---
@ -551,15 +545,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 선박/이벤트 클릭 시 상세 정보 팝업
- 지도 확대/축소 및 해역 필터링
**구현 완료 (2026-04-17 기준):**
- ✅ **실시간 선박 위치 + 이벤트 마커** — prediction 5분 주기 분석 결과(`vessel_analysis_results.lat/lon`) + `prediction_events` 기반
- ✅ MapLibre GL 5 + deck.gl 9 GPU 렌더링 (40만척+ 지원)
- ✅ 위험도별 마커 opacity/radius 차등 (2026-04-17 `ALERT_LEVEL_MARKER_OPACITY/RADIUS` 헬퍼 적용)
- ✅ 이벤트 상세 패널 + 고위험 사건 실시간 알림 (LIVE 표시)
**구현 완료:**
- ✅ LiveMap 기반 실시간 감시 지도 UI
- ✅ 선박/이벤트 마커 및 팝업 인터랙션
**향후 구현 예정:**
- 🔲 WebSocket 기반 실시간 push (현재는 주기 갱신)
- 🔲 SAR 위성영상 오버레이
- 🔲 실시간 AIS/VMS 데이터 연동
- 🔲 WebSocket 기반 실시간 위치 업데이트
- 🔲 이벤트 발생 시 자동 지도 포커스 이동
**보완 필요:**
- ⚠️ 현재 선박 위치는 샘플 데이터이며, 실시간 데이터 연동 필요
---
@ -605,15 +601,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 기간별/해역별/유형별 필터링
- 통계 데이터 엑셀 내보내기 및 인쇄
**구현 완료 (2026-04-17 기준):**
- ✅ **실시간 통계 데이터**`/api/stats/monthly|daily|hourly` 연동, prediction `stats_aggregator` 집계 결과 기반
- ✅ 월별/일별/시간별 추이 그래프 (ECharts, KST 기준)
- ✅ 해역별/유형별 필터링 + 엑셀 내보내기/인쇄
- ✅ 감사·보안 통계 — `/api/admin/stats/audit|access|login` (2026-04-17 AdminStatsService 계층 분리)
**구현 완료:**
- ✅ 월별 추이 차트 및 KPI 5개 대시보드 UI
- ✅ 필터링 및 엑셀 내보내기/인쇄 기능
**향후 구현 예정:**
- 🔲 보고서 자동 생성 (PDF/HWP) — 현재는 UI만
- 🔲 맞춤형 지표 대시보드 설정
- 🔲 통계 데이터 DB 연동
- 🔲 실제 운영 데이터 기반 KPI 자동 산출
- 🔲 맞춤형 보고서 생성 기능
**보완 필요:**
- ⚠️ 현재 KPI 수치(정확도 93.2%, 오탐율 7.8% 등)는 샘플 데이터이며, 실제 운영 데이터 기반으로 교체 필요
---
@ -745,15 +743,17 @@ AI가 분석한 결과를 기반으로 관련 담당자에게 알림을 발송
- 알림 수신자 설정 및 발송
- 알림 전송 결과(성공/실패) 확인
**구현 완료 (2026-04-17 기준):**
- ✅ **AI 알림 이력 실 API 조회**`/api/alerts` 연동 (2026-04-17 AlertService 계층 분리)
- ✅ prediction `alert_dispatcher` 모듈이 event_generator 결과 기반으로 `prediction_alerts` 테이블에 자동 기록
- ✅ 알림 유형별 분류 + DataTable 검색/정렬/페이징/엑셀 내보내기
**구현 완료:**
- ✅ 알림 5건 전송 현황 UI
- ✅ 알림 유형별 분류 및 상세 조회
**향후 구현 예정:**
- 🔲 실제 SMS/푸시 발송 게이트웨이 연동 (현재는 DB 기록만)
- 🔲 알림 템플릿 엔진
- 🔲 수신자 그룹 관리
- 🔲 실제 알림 발송 기능 구현 (SMS, 이메일, Push 등)
- 🔲 AI 분석 결과 기반 자동 알림 트리거
- 🔲 알림 발송 이력 DB 연동
**보완 필요:**
- ⚠️ 현재 알림은 실제 발송되지 않으며, 발송 채널(SMS/이메일/Push) 연동 필요
---
@ -857,27 +857,15 @@ AI에게 질문하고 답변을 받을 수 있는 대화형(채팅) 인터페이
---
## 부록: 현재 시스템 상태 요약 (2026-04-17 기준)
## 부록: 현재 시스템 상태 요약
| 항목 | 상태 |
|------|------|
| UI 구현 | 모든 SFR 완료 |
| **백엔드 연동** | **15+ 화면 실 API 연동 완료** (Auth/RBAC/Audit/Events/Alerts/Enforcement/Stats/Analysis/Master 등 65+ API) |
| **AI 분석 엔진 (prediction)** | **운영 중** — 5분 주기로 snpdb 분석 → kcgaidb 저장, 14 알고리즘 + DAR-03 G-01~G-06 |
| **데이터** | 실 AIS 원천(snpdb) + prediction 분석 결과 + 자체 DB 저장 데이터 (일부 화면은 여전히 Mock) |
| **인증 체계** | 자체 ID/PW + JWT + 트리 기반 RBAC + 5회 실패 잠금 (SSO/GPKI 미연동) |
| **실시간 기능** | prediction 5분 주기 + 프론트 30초 폴링 (WebSocket push 미도입) |
| **AI 모델** | Dark Vessel 11패턴 / DAR-03 G-01~G-06 / 환적 5단계 / 경량 risk 등 14종 운영 중 (일부 모델은 Mock 계획 단계) |
| **외부 시스템** | snpdb / gc-signal-batch 연동 완료. 유관기관 OpenAPI(GICOMS/MTIS 등)는 미연동 |
| **디자인 시스템** | `design-system.html` 쇼케이스 SSOT 전영역 준수, 라이트/다크 모드 완전 대응 |
| **다국어** | 한/영 alert/confirm/aria-label 전수 치환 완료 (JSX placeholder 35건은 후속 과제) |
| **모바일 앱** | 웹 시뮬레이션만 제공 (PWA/네이티브 앱 미개발) |
---
## 변경 이력
| 일자 | 내용 |
|------|------|
| 2026-04-06 | 초기 작성 (프론트엔드 프로토타입 v0.1.0 기준) |
| 2026-04-17 | 헤더 + SFR-01/02/09/10/11/12/13/17 주요 섹션 업데이트. 실 API 연동 / prediction 운영 상태 / 2026-04-17 PR #A/#B/#C 반영 |
| 백엔드 연동 | 미구현 (전체) |
| 데이터 | 시연용 샘플 데이터 |
| 인증 체계 | 데모 계정 5종 (SSO/GPKI 미연동) |
| 실시간 기능 | 미구현 (WebSocket 등 미연동) |
| AI 모델 | 미연동 (탐지/예측/최적화 등) |
| 외부 시스템 | 미연동 (GICOMS, MTIS 등) |
| 모바일 앱 | 웹 시뮬레이션만 제공 (네이티브 앱 미개발) |

파일 보기

@ -39,9 +39,6 @@ export const COMPONENT_REGISTRY: Record<string, LazyComponent> = {
'features/detection/ChinaFishing': lazy(() =>
import('@features/detection').then((m) => ({ default: m.ChinaFishing })),
),
'features/detection/GearCollisionDetection': lazy(() =>
import('@features/detection').then((m) => ({ default: m.GearCollisionDetection })),
),
// ── 단속·이벤트 ──
'features/enforcement/EnforcementHistory': lazy(() =>
import('@features/enforcement').then((m) => ({ default: m.EnforcementHistory })),
@ -131,9 +128,6 @@ export const COMPONENT_REGISTRY: Record<string, LazyComponent> = {
'features/admin/DataModelVerification': lazy(() =>
import('@features/admin').then((m) => ({ default: m.DataModelVerification })),
),
'features/admin/PerformanceMonitoring': lazy(() =>
import('@features/admin').then((m) => ({ default: m.PerformanceMonitoring })),
),
// ── 모선 워크플로우 ──
'features/parent-inference/ParentReview': lazy(() =>
import('@features/parent-inference/ParentReview').then((m) => ({

파일 보기

@ -282,9 +282,8 @@ export function MainLayout() {
{/* 언어 토글 */}
<button
onClick={toggleLanguage}
aria-label={t('aria.languageToggle')}
className="px-2 py-1 rounded-lg text-[10px] font-bold bg-surface-overlay border border-border text-label hover:text-heading transition-colors whitespace-nowrap"
title={language === 'ko' ? t('message.switchToEnglish') : t('message.switchToKorean')}
title={language === 'ko' ? 'Switch to English' : '한국어로 전환'}
>
{language === 'ko' ? 'EN' : '한국어'}
</button>
@ -339,7 +338,7 @@ export function MainLayout() {
<div className="relative flex items-center">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 text-hint pointer-events-none" />
<input
aria-label={t('aria.searchInPage')}
aria-label="페이지 내 검색"
value={pageSearch}
onChange={(e) => setPageSearch(e.target.value)}
onKeyDown={(e) => {

파일 보기

@ -41,11 +41,11 @@ const AGENTS = [
];
const AGENT_KPI = [
{ label: '활성 Agent', value: '4', color: 'text-label', bg: 'bg-green-500/10' },
{ label: '등록 Tool', value: '26', color: 'text-label', bg: 'bg-blue-500/10' },
{ label: '24h 호출', value: '2,656', color: 'text-heading', bg: 'bg-purple-500/10' },
{ label: '차단 건수', value: '3', color: 'text-heading', bg: 'bg-red-500/10' },
{ label: '승인 대기', value: '0', color: 'text-label', bg: 'bg-yellow-500/10' },
{ label: '활성 Agent', value: '4', color: 'text-green-400', bg: 'bg-green-500/10' },
{ label: '등록 Tool', value: '26', color: 'text-blue-400', bg: 'bg-blue-500/10' },
{ label: '24h 호출', value: '2,656', color: 'text-purple-400', bg: 'bg-purple-500/10' },
{ label: '차단 건수', value: '3', color: 'text-red-400', bg: 'bg-red-500/10' },
{ label: '승인 대기', value: '0', color: 'text-yellow-400', bg: 'bg-yellow-500/10' },
];
// ─── 화이트리스트 도구 ──────────────────
@ -110,7 +110,7 @@ export function AIAgentSecurityPage() {
<PageContainer>
<PageHeader
icon={Bot}
iconColor="text-heading"
iconColor="text-orange-400"
title="AI Agent 구축·운영 보안"
description="SER-11 | AI Agent 화이트리스트·자동중단·민감명령 승인·MCP 최소권한·감사로그"
demo
@ -192,7 +192,7 @@ export function AIAgentSecurityPage() {
<div className="space-y-2">
{KILL_SWITCH_RULES.map(r => (
<div key={r.rule} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
<AlertTriangle className="w-4 h-4 text-heading" />
<AlertTriangle className="w-4 h-4 text-red-400" />
<div className="flex-1">
<div className="text-[11px] text-heading font-medium">{r.rule}</div>
<div className="text-[9px] text-hint">{r.desc}</div>
@ -216,7 +216,7 @@ export function AIAgentSecurityPage() {
<td className="py-2 px-2 text-heading font-medium">{c.command}</td>
<td className="py-2 text-center"><Badge intent={getThreatLevelIntent(c.level)} size="sm">{c.level}</Badge></td>
<td className="py-2 text-muted-foreground">{c.approval}</td>
<td className="py-2 text-center">{c.hitl ? <Hand className="w-3.5 h-3.5 text-label mx-auto" /> : <span className="text-hint">-</span>}</td>
<td className="py-2 text-center">{c.hitl ? <Hand className="w-3.5 h-3.5 text-orange-400 mx-auto" /> : <span className="text-hint">-</span>}</td>
<td className="py-2 text-center"><Badge intent="success" size="sm">{c.status}</Badge></td>
</tr>
))}</tbody>
@ -232,7 +232,7 @@ export function AIAgentSecurityPage() {
<div className="space-y-2">
{IDENTITY_POLICIES.map(p => (
<div key={p.policy} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
<Key className="w-4 h-4 text-label" />
<Key className="w-4 h-4 text-orange-400" />
<div className="flex-1">
<div className="text-[11px] text-heading font-medium">{p.policy}</div>
<div className="text-[9px] text-hint">{p.desc}</div>

파일 보기

@ -31,11 +31,11 @@ const TABS: { key: Tab; icon: React.ComponentType<{ className?: string }>; label
// ─── 보안 현황 KPI ──────────────────
const SECURITY_KPI = [
{ label: '데이터 수집 보안', value: '정상', score: 95, icon: Database, color: 'text-label', bg: 'bg-green-500/10' },
{ label: 'AI 학습 보안', value: '정상', score: 92, icon: Brain, color: 'text-label', bg: 'bg-blue-500/10' },
{ label: '입출력 필터링', value: '정상', score: 98, icon: Lock, color: 'text-heading', bg: 'bg-purple-500/10' },
{ label: '경계 보안', value: '주의', score: 85, icon: Server, color: 'text-label', bg: 'bg-yellow-500/10' },
{ label: '취약점 점검', value: '정상', score: 90, icon: Search, color: 'text-label', bg: 'bg-cyan-500/10' },
{ label: '데이터 수집 보안', value: '정상', score: 95, icon: Database, color: 'text-green-400', bg: 'bg-green-500/10' },
{ label: 'AI 학습 보안', value: '정상', score: 92, icon: Brain, color: 'text-blue-400', bg: 'bg-blue-500/10' },
{ label: '입출력 필터링', value: '정상', score: 98, icon: Lock, color: 'text-purple-400', bg: 'bg-purple-500/10' },
{ label: '경계 보안', value: '주의', score: 85, icon: Server, color: 'text-yellow-400', bg: 'bg-yellow-500/10' },
{ label: '취약점 점검', value: '정상', score: 90, icon: Search, color: 'text-cyan-400', bg: 'bg-cyan-500/10' },
];
// ─── 데이터 수집 보안 ──────────────────
@ -111,7 +111,7 @@ export function AISecurityPage() {
<PageContainer>
<PageHeader
icon={Shield}
iconColor="text-heading"
iconColor="text-red-400"
title="AI 보안 관리"
description="SER-10 | AI 데이터 수집·학습·입출력·경계 보안 및 취약점 관리"
demo
@ -159,7 +159,7 @@ export function AISecurityPage() {
['복구 계획', '3/3 활성', '완료'],
].map(([k, v, s]) => (
<div key={k} className="flex items-center gap-2 px-2 py-1.5 bg-surface-overlay rounded">
{s === '완료' ? <CheckCircle className="w-3 h-3 text-label" /> : <AlertTriangle className="w-3 h-3 text-label" />}
{s === '완료' ? <CheckCircle className="w-3 h-3 text-green-500" /> : <AlertTriangle className="w-3 h-3 text-yellow-500" />}
<span className="text-heading flex-1">{k}</span>
<span className="text-hint">{v}</span>
</div>
@ -218,7 +218,7 @@ export function AISecurityPage() {
<div className="space-y-2">
{CONTAMINATION_CHECKS.map(c => (
<div key={c.check} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
<CheckCircle className="w-4 h-4 text-label" />
<CheckCircle className="w-4 h-4 text-green-500" />
<div className="flex-1">
<div className="text-[11px] text-heading font-medium">{c.check}</div>
<div className="text-[9px] text-hint">{c.desc}</div>
@ -239,7 +239,7 @@ export function AISecurityPage() {
<div className="space-y-2">
{TRAINING_POLICIES.map(p => (
<div key={p.policy} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
<Lock className="w-4 h-4 text-label" />
<Lock className="w-4 h-4 text-blue-400" />
<div className="flex-1">
<div className="text-[11px] text-heading font-medium">{p.policy}</div>
<div className="text-[9px] text-hint">{p.desc}</div>
@ -266,7 +266,7 @@ export function AISecurityPage() {
<td className="py-2.5 px-2 text-heading font-medium">{f.name}</td>
<td className="py-2.5 text-hint text-[9px]">{f.desc}</td>
<td className="py-2.5 text-center"><Badge intent="success" size="sm">{f.status}</Badge></td>
<td className="py-2.5 text-center text-heading font-bold">{f.blocked}</td>
<td className="py-2.5 text-center text-red-400 font-bold">{f.blocked}</td>
<td className="py-2.5 text-center text-muted-foreground">{f.total.toLocaleString()}</td>
<td className="py-2.5 text-center text-muted-foreground">{(f.blocked / f.total * 100).toFixed(2)}%</td>
</tr>
@ -283,7 +283,7 @@ export function AISecurityPage() {
<div className="space-y-2">
{BOUNDARY_ITEMS.map(b => (
<div key={b.item} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
<Server className="w-4 h-4 text-label" />
<Server className="w-4 h-4 text-yellow-400" />
<div className="flex-1">
<div className="text-[11px] text-heading font-medium">{b.item}</div>
<div className="text-[9px] text-hint">{b.desc}</div>
@ -298,7 +298,7 @@ export function AISecurityPage() {
<div className="space-y-2">
{EXPLAINABILITY.map(e => (
<div key={e.item} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
<Eye className="w-4 h-4 text-heading" />
<Eye className="w-4 h-4 text-purple-400" />
<div className="flex-1">
<div className="text-[11px] text-heading font-medium">{e.item}</div>
<div className="text-[9px] text-hint">{e.desc}</div>
@ -339,7 +339,7 @@ export function AISecurityPage() {
<div className="space-y-2">
{RECOVERY_PLANS.map(r => (
<div key={r.plan} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
<RefreshCw className="w-4 h-4 text-label" />
<RefreshCw className="w-4 h-4 text-cyan-400" />
<div className="flex-1">
<div className="text-[11px] text-heading font-medium">{r.plan}</div>
<div className="text-[9px] text-hint">{r.desc}</div>

파일 보기

@ -94,20 +94,20 @@ export function AccessControl() {
}, [tab, loadUsers, loadAudit]);
const handleUnlock = async (userId: string, acnt: string) => {
if (!confirm(`${acnt} ${tc('dialog.genericRemove')}`)) return;
if (!confirm(`계정 ${acnt} 잠금을 해제하시겠습니까?`)) return;
try {
await unlockUser(userId);
await loadUsers();
} catch (e: unknown) {
alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
}
};
// ── 사용자 테이블 컬럼 ──────────────
// eslint-disable-next-line react-hooks/exhaustive-deps
const userColumns: DataColumn<AdminUser & Record<string, unknown>>[] = useMemo(() => [
{ key: 'userAcnt', label: '계정', width: '90px',
render: (v) => <span className="text-label font-mono text-[11px]">{v as string}</span> },
render: (v) => <span className="text-cyan-400 font-mono text-[11px]">{v as string}</span> },
{ key: 'userNm', label: '이름', width: '80px', sortable: true,
render: (v) => <span className="text-heading font-medium">{v as string}</span> },
{ key: 'rnkpNm', label: '직급', width: '60px',
@ -133,7 +133,7 @@ export function AccessControl() {
},
},
{ key: 'failCnt', label: '실패', width: '50px', align: 'center',
render: (v) => <span className={`text-[10px] ${(v as number) > 0 ? 'text-heading' : 'text-hint'}`}>{v as number}</span> },
render: (v) => <span className={`text-[10px] ${(v as number) > 0 ? 'text-red-400' : 'text-hint'}`}>{v as number}</span> },
{ key: 'authProvider', label: '인증', width: '70px',
render: (v) => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
{ key: 'lastLoginDtm', label: '최종 로그인', width: '140px', sortable: true,
@ -146,23 +146,15 @@ export function AccessControl() {
{ key: 'userId', label: '관리', width: '90px', align: 'center', sortable: false,
render: (_v, row) => (
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setAssignTarget(row)}
aria-label="역할 배정"
title="역할 배정"
icon={<UserCog className="w-3 h-3" />}
/>
<button type="button" onClick={() => setAssignTarget(row)}
className="p-1 text-hint hover:text-purple-400" title="역할 배정">
<UserCog className="w-3 h-3" />
</button>
{row.userSttsCd === 'LOCKED' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleUnlock(row.userId, row.userAcnt)}
aria-label="잠금 해제"
title="잠금 해제"
icon={<Key className="w-3 h-3" />}
/>
<button type="button" onClick={() => handleUnlock(row.userId, row.userAcnt)}
className="p-1 text-hint hover:text-green-400" title="잠금 해제">
<Key className="w-3 h-3" />
</button>
)}
</div>
),
@ -174,7 +166,7 @@ export function AccessControl() {
{ key: 'createdAt', label: '일시', width: '160px', sortable: true,
render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{formatDateTime(v as string)}</span> },
{ key: 'userAcnt', label: '사용자', width: '90px', sortable: true,
render: (v) => <span className="text-label font-mono">{(v as string) || '-'}</span> },
render: (v) => <span className="text-cyan-400 font-mono">{(v as string) || '-'}</span> },
{ key: 'actionCd', label: '액션', width: '180px', sortable: true,
render: (v) => <span className="text-heading font-medium">{v as string}</span> },
{ key: 'resourceType', label: '리소스', width: '110px',
@ -188,24 +180,24 @@ export function AccessControl() {
},
},
{ key: 'failReason', label: '실패 사유',
render: (v) => <span className="text-heading text-[10px]">{(v as string) || '-'}</span> },
render: (v) => <span className="text-red-400 text-[10px]">{(v as string) || '-'}</span> },
], []);
return (
<PageContainer size="lg">
<PageHeader
icon={Shield}
iconColor="text-label"
iconColor="text-blue-400"
title={t('accessControl.title')}
description={t('accessControl.desc')}
actions={
<>
{userStats && (
<div className="flex items-center gap-2 text-[10px] text-hint">
<UserCheck className="w-3.5 h-3.5 text-label" />
<span className="text-label font-bold">{userStats.active}</span>
<UserCheck className="w-3.5 h-3.5 text-green-500" />
<span className="text-green-400 font-bold">{userStats.active}</span>
<span className="mx-1">|</span>
<span className="text-heading font-bold">{userStats.locked}</span>
<span className="text-red-400 font-bold">{userStats.locked}</span>
<span className="mx-1">|</span>
<span className="text-heading font-bold">{userStats.total}</span>
</div>
@ -245,7 +237,7 @@ export function AccessControl() {
))}
</div>
{error && <div className="text-xs text-heading">: {error}</div>}
{error && <div className="text-xs text-red-400">: {error}</div>}
{/* ── 역할 관리 (PermissionsPanel: 트리 + R/C/U/D 매트릭스) ── */}
{tab === 'roles' && <PermissionsPanel />}
@ -257,8 +249,8 @@ export function AccessControl() {
{userStats && (
<div className="grid grid-cols-4 gap-3">
<StatCard label="총 사용자" value={userStats.total} color="text-heading" />
<StatCard label="활성" value={userStats.active} color="text-label" />
<StatCard label="잠금" value={userStats.locked} color="text-heading" />
<StatCard label="활성" value={userStats.active} color="text-green-400" />
<StatCard label="잠금" value={userStats.locked} color="text-red-400" />
<StatCard label="비활성" value={userStats.inactive} color="text-gray-400" />
</div>
)}
@ -286,9 +278,9 @@ export function AccessControl() {
{auditStats && (
<div className="grid grid-cols-4 gap-3">
<StatCard label="전체 로그" value={auditStats.total} color="text-heading" />
<StatCard label="24시간" value={auditStats.last24h} color="text-label" />
<StatCard label="실패 (24시간)" value={auditStats.failed24h} color="text-heading" />
<StatCard label="액션 종류" value={auditStats.byAction.length} color="text-heading" />
<StatCard label="24시간" value={auditStats.last24h} color="text-blue-400" />
<StatCard label="실패 (24시간)" value={auditStats.failed24h} color="text-red-400" />
<StatCard label="액션 종류" value={auditStats.byAction.length} color="text-purple-400" />
</div>
)}
@ -300,7 +292,7 @@ export function AccessControl() {
<div className="flex flex-wrap gap-2">
{auditStats.byAction.map((a) => (
<Badge key={a.action} className="bg-surface-overlay text-muted-foreground border border-border text-[10px]">
{a.action} <span className="text-label font-bold ml-1">{a.count}</span>
{a.action} <span className="text-cyan-400 font-bold ml-1">{a.count}</span>
</Badge>
))}
</div>

파일 보기

@ -37,7 +37,7 @@ export function AccessLogs() {
<PageContainer size="lg">
<PageHeader
icon={Activity}
iconColor="text-label"
iconColor="text-cyan-400"
title="접근 이력"
description="AccessLogFilter가 모든 HTTP 요청 비동기 기록"
actions={
@ -50,10 +50,10 @@ export function AccessLogs() {
{stats && (
<div className="grid grid-cols-5 gap-3">
<MetricCard label="전체 요청" value={stats.total} color="text-heading" />
<MetricCard label="24시간 내" value={stats.last24h} color="text-label" />
<MetricCard label="4xx (24h)" value={stats.error4xx} color="text-heading" />
<MetricCard label="5xx (24h)" value={stats.error5xx} color="text-heading" />
<MetricCard label="평균 응답(ms)" value={Math.round(stats.avgDurationMs)} color="text-heading" />
<MetricCard label="24시간 내" value={stats.last24h} color="text-blue-400" />
<MetricCard label="4xx (24h)" value={stats.error4xx} color="text-orange-400" />
<MetricCard label="5xx (24h)" value={stats.error5xx} color="text-red-400" />
<MetricCard label="평균 응답(ms)" value={Math.round(stats.avgDurationMs)} color="text-purple-400" />
</div>
)}
@ -73,7 +73,7 @@ export function AccessLogs() {
{stats.topPaths.map((p) => (
<tr key={p.path} className="border-t border-border">
<td className="py-1.5 text-heading font-mono text-[10px]">{p.path}</td>
<td className="py-1.5 text-right text-label font-bold">{p.count}</td>
<td className="py-1.5 text-right text-cyan-400 font-bold">{p.count}</td>
<td className="py-1.5 text-right text-muted-foreground">{p.avg_ms}</td>
</tr>
))}
@ -83,7 +83,7 @@ export function AccessLogs() {
</Card>
)}
{error && <div className="text-xs text-heading">: {error}</div>}
{error && <div className="text-xs text-red-400">: {error}</div>}
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
@ -109,8 +109,8 @@ export function AccessLogs() {
<tr key={it.accessSn} className="border-t border-border hover:bg-surface-overlay/50">
<td className="px-3 py-2 text-hint font-mono">{it.accessSn}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.createdAt)}</td>
<td className="px-3 py-2 text-label">{it.userAcnt || '-'}</td>
<td className="px-3 py-2 text-heading font-mono">{it.httpMethod}</td>
<td className="px-3 py-2 text-cyan-400">{it.userAcnt || '-'}</td>
<td className="px-3 py-2 text-purple-400 font-mono">{it.httpMethod}</td>
<td className="px-3 py-2 text-heading font-mono text-[10px] max-w-md truncate">{it.requestPath}</td>
<td className="px-3 py-2 text-center">
<Badge intent={getHttpStatusIntent(it.statusCode)} size="sm">{it.statusCode}</Badge>

파일 보기

@ -67,7 +67,7 @@ export function AdminPanel() {
<div className="grid grid-cols-2 gap-3">
<Card>
<CardHeader className="px-4 pt-3 pb-2">
<CardTitle className="text-xs text-label flex items-center gap-1.5"><Database className="w-3.5 h-3.5 text-label" /></CardTitle>
<CardTitle className="text-xs text-label flex items-center gap-1.5"><Database className="w-3.5 h-3.5 text-blue-400" /></CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 space-y-2">
{[['PostgreSQL', 'v15.4 운영중'], ['TimescaleDB', 'v2.12 운영중'], ['Redis 캐시', 'v7.2 운영중'], ['Kafka', 'v3.6 클러스터 3노드']].map(([k, v]) => (
@ -77,7 +77,7 @@ export function AdminPanel() {
</Card>
<Card>
<CardHeader className="px-4 pt-3 pb-2">
<CardTitle className="text-xs text-label flex items-center gap-1.5"><Shield className="w-3.5 h-3.5 text-label" /> </CardTitle>
<CardTitle className="text-xs text-label flex items-center gap-1.5"><Shield className="w-3.5 h-3.5 text-green-400" /> </CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 space-y-2">
{[['SSL 인증서', '2027-03-15 만료'], ['방화벽', '정상 동작'], ['IDS/IPS', '실시간 감시중'], ['백업', '금일 03:00 완료']].map(([k, v]) => (

파일 보기

@ -36,7 +36,7 @@ export function AuditLogs() {
<PageContainer size="lg">
<PageHeader
icon={FileSearch}
iconColor="text-label"
iconColor="text-blue-400"
title="감사 로그"
description="@Auditable AOP가 모든 운영자 의사결정 자동 기록"
actions={
@ -50,9 +50,9 @@ export function AuditLogs() {
{stats && (
<div className="grid grid-cols-4 gap-3">
<MetricCard label="전체 로그" value={stats.total} color="text-heading" />
<MetricCard label="24시간 내" value={stats.last24h} color="text-label" />
<MetricCard label="실패 (24시간)" value={stats.failed24h} color="text-heading" />
<MetricCard label="액션 종류 (7일)" value={stats.byAction.length} color="text-heading" />
<MetricCard label="24시간 내" value={stats.last24h} color="text-blue-400" />
<MetricCard label="실패 (24시간)" value={stats.failed24h} color="text-red-400" />
<MetricCard label="액션 종류 (7일)" value={stats.byAction.length} color="text-purple-400" />
</div>
)}
@ -64,7 +64,7 @@ export function AuditLogs() {
<div className="flex flex-wrap gap-2">
{stats.byAction.map((a) => (
<Badge key={a.action} className="bg-surface-overlay text-muted-foreground border border-border text-[10px] px-2 py-1">
{a.action} <span className="text-label font-bold ml-1">{a.count}</span>
{a.action} <span className="text-cyan-400 font-bold ml-1">{a.count}</span>
</Badge>
))}
</div>
@ -72,7 +72,7 @@ export function AuditLogs() {
</Card>
)}
{error && <div className="text-xs text-heading">: {error}</div>}
{error && <div className="text-xs text-red-400">: {error}</div>}
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
@ -99,7 +99,7 @@ export function AuditLogs() {
<tr key={it.auditSn} className="border-t border-border hover:bg-surface-overlay/50">
<td className="px-3 py-2 text-hint font-mono">{it.auditSn}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.createdAt)}</td>
<td className="px-3 py-2 text-label">{it.userAcnt || '-'}</td>
<td className="px-3 py-2 text-cyan-400">{it.userAcnt || '-'}</td>
<td className="px-3 py-2 text-heading font-medium">{it.actionCd}</td>
<td className="px-3 py-2 text-muted-foreground">{it.resourceType ?? '-'} {it.resourceId ? `(${it.resourceId})` : ''}</td>
<td className="px-3 py-2">
@ -107,7 +107,7 @@ export function AuditLogs() {
{it.result || '-'}
</Badge>
</td>
<td className="px-3 py-2 text-heading text-[10px]">{it.failReason || '-'}</td>
<td className="px-3 py-2 text-red-400 text-[10px]">{it.failReason || '-'}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{it.ipAddress || '-'}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px] max-w-xs truncate">
{it.detail ? JSON.stringify(it.detail) : '-'}

파일 보기

@ -1,27 +1,29 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { TabBar, TabButton } from '@shared/components/ui/tabs';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { SaveButton } from '@shared/components/common/SaveButton';
import { getConnectionStatusHex } from '@shared/constants/connectionStatuses';
import type { BadgeIntent } from '@lib/theme/variants';
import {
Database, RefreshCw, Wifi, WifiOff, Radio,
Activity, Server, ArrowDownToLine, AlertTriangle,
CheckCircle, BarChart3, Layers, Plus, Play, Square,
Trash2, Edit2, Eye, FileText, HardDrive, FolderOpen,
Network,
} from 'lucide-react';
/** 수집/적재 작업 상태 → BadgeIntent 매핑 (DataHub 로컬 전용) */
function jobStatusIntent(s: string): BadgeIntent {
if (s === '수행중') return 'success';
if (s === '대기중') return 'warning';
if (s === '장애발생') return 'critical';
return 'muted';
}
import {
Database, RefreshCw, Calendar, Wifi, WifiOff, Radio,
Activity, Server, ArrowDownToLine, Clock, AlertTriangle,
CheckCircle, XCircle, BarChart3, Layers, Plus, Play, Square,
Trash2, Edit2, Eye, FileText, HardDrive, Upload, FolderOpen,
Network, X, ChevronRight, Info,
} from 'lucide-react';
/*
* SFR-03: 통합데이터 ·
@ -113,7 +115,7 @@ const channelColumns: DataColumn<ChannelRecord>[] = [
render: (v) => <span className="text-hint font-mono">{v as string}</span>,
},
{ key: 'system', label: '정보시스템명', width: '100px', sortable: true,
render: (v) => <span className="text-label font-medium">{v as string}</span>,
render: (v) => <span className="text-cyan-400 font-medium">{v as string}</span>,
},
{ key: 'linkInfo', label: '연계정보', width: '65px' },
{ key: 'storage', label: '저장장소', render: (v) => <span className="text-hint font-mono text-[9px]">{v as string}</span> },
@ -124,7 +126,7 @@ const channelColumns: DataColumn<ChannelRecord>[] = [
render: (v) => {
const s = v as string;
return s === '수신대기중'
? <Badge intent="warning" size="xs">{s}</Badge>
? <span className="text-orange-400 text-[9px]">{s}</span>
: <span className="text-muted-foreground font-mono text-[10px]">{s}</span>;
},
},
@ -155,7 +157,13 @@ function SignalTimeline({ source }: { source: SignalSource }) {
<div className="flex items-center gap-3">
{/* 라벨 */}
<div className="w-16 shrink-0 text-right">
<div className="text-[11px] font-bold text-label">{source.name}</div>
<div className="text-[11px] font-bold" style={{
color: source.name === 'VTS' ? '#22c55e'
: source.name === 'VTS-AIS' ? '#3b82f6'
: source.name === 'V-PASS' ? '#a855f7'
: source.name === 'E-NAVI' ? '#ef4444'
: '#eab308',
}}>{source.name}</div>
<div className="text-[10px] text-hint">{source.rate}%</div>
</div>
{/* 타임라인 바 */}
@ -224,21 +232,20 @@ const collectColumns: DataColumn<CollectJob>[] = [
{ key: 'successRate', label: '성공률', width: '70px', align: 'right', sortable: true,
render: (v) => {
const n = v as number;
if (n === 0) return <span className="font-bold text-[11px] text-hint">-</span>;
const intent: BadgeIntent = n >= 90 ? 'success' : n >= 70 ? 'warning' : 'critical';
return <Badge intent={intent} size="xs">{n}%</Badge>;
const c = n >= 90 ? 'text-green-400' : n >= 70 ? 'text-yellow-400' : n > 0 ? 'text-red-400' : 'text-hint';
return <span className={`font-bold text-[11px] ${c}`}>{n > 0 ? `${n}%` : '-'}</span>;
},
},
{ key: 'id', label: '', width: '70px', align: 'center', sortable: false,
render: (_v, row) => (
<div className="flex items-center gap-0.5">
{row.status === '정지' ? (
<button type="button" className="p-1 text-hint hover:text-heading" title="시작"><Play className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-green-400" title="시작"><Play className="w-3 h-3" /></button>
) : row.status !== '장애발생' ? (
<button type="button" className="p-1 text-hint hover:text-heading" title="정지"><Square className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-orange-400" title="정지"><Square className="w-3 h-3" /></button>
) : null}
<button type="button" className="p-1 text-hint hover:text-label" title="편집"><Edit2 className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-label" title="이력"><FileText className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-blue-400" title="편집"><Edit2 className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-cyan-400" title="이력"><FileText className="w-3 h-3" /></button>
</div>
),
},
@ -284,9 +291,9 @@ const loadColumns: DataColumn<LoadJob>[] = [
{ key: 'id', label: '', width: '70px', align: 'center', sortable: false,
render: () => (
<div className="flex items-center gap-0.5">
<button type="button" className="p-1 text-hint hover:text-label" title="편집"><Edit2 className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-label" title="이력"><FileText className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-heading" title="스토리지"><HardDrive className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-blue-400" title="편집"><Edit2 className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-cyan-400" title="이력"><FileText className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-orange-400" title="스토리지"><HardDrive className="w-3 h-3" /></button>
</div>
),
},
@ -341,7 +348,6 @@ type Tab = 'signal' | 'monitor' | 'collect' | 'load' | 'agents';
export function DataHub() {
const { t } = useTranslation('admin');
const { t: tc } = useTranslation('common');
const [tab, setTab] = useState<Tab>('signal');
const [selectedDate, setSelectedDate] = useState('2026-04-02');
const [statusFilter, setStatusFilter] = useState<'' | 'ON' | 'OFF'>('');
@ -381,7 +387,7 @@ export function DataHub() {
<PageContainer>
<PageHeader
icon={Database}
iconColor="text-label"
iconColor="text-cyan-400"
title={t('dataHub.title')}
description={t('dataHub.desc')}
demo
@ -396,11 +402,11 @@ export function DataHub() {
<div className="flex gap-2">
{[
{ label: '전체 채널', value: CHANNELS.length, icon: Layers, color: 'text-label', bg: 'bg-muted' },
{ label: 'ON', value: onCount, icon: Wifi, color: 'text-label', bg: 'bg-blue-500/10' },
{ label: 'OFF', value: offCount, icon: WifiOff, color: 'text-heading', bg: 'bg-red-500/10' },
{ label: '평균 수신율', value: '86.5%', icon: BarChart3, color: 'text-label', bg: 'bg-green-500/10' },
{ label: '데이터 소스', value: '5종', icon: Radio, color: 'text-heading', bg: 'bg-purple-500/10' },
{ label: '연계 방식', value: 'KAFKA', icon: Server, color: 'text-heading', bg: 'bg-orange-500/10' },
{ label: 'ON', value: onCount, icon: Wifi, color: 'text-blue-400', bg: 'bg-blue-500/10' },
{ label: 'OFF', value: offCount, icon: WifiOff, color: 'text-red-400', bg: 'bg-red-500/10' },
{ label: '평균 수신율', value: '86.5%', icon: BarChart3, color: 'text-green-400', bg: 'bg-green-500/10' },
{ label: '데이터 소스', value: '5종', icon: Radio, color: 'text-purple-400', bg: 'bg-purple-500/10' },
{ label: '연계 방식', value: 'KAFKA', icon: Server, color: 'text-orange-400', bg: 'bg-orange-500/10' },
].map((kpi) => (
<div key={kpi.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
<div className={`p-1.5 rounded-lg ${kpi.bg}`}>
@ -443,7 +449,7 @@ export function DataHub() {
<div className="flex items-center gap-2">
<div className="relative">
<input
aria-label={tc('aria.receiptDate')}
aria-label="수신 현황 기준일"
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
@ -503,11 +509,11 @@ export function DataHub() {
<div className="flex items-center gap-3 px-4 py-2 rounded-xl border border-border bg-card">
<div className="flex items-center gap-1.5">
{hasPartialOff ? (
<AlertTriangle className="w-3.5 h-3.5 text-heading" />
<AlertTriangle className="w-3.5 h-3.5 text-orange-400" />
) : (
<CheckCircle className="w-3.5 h-3.5 text-label" />
<CheckCircle className="w-3.5 h-3.5 text-green-400" />
)}
<span className="text-[11px] font-bold text-heading">
<span className={`text-[11px] font-bold ${hasPartialOff ? 'text-orange-400' : 'text-green-400'}`}>
{hasPartialOff ? `일부 OFF (${offCount}/${CHANNELS.length})` : '전체 정상'}
</span>
</div>
@ -647,9 +653,9 @@ export function DataHub() {
<div className="flex items-center justify-between mt-3 pt-2 border-t border-border">
<span className="text-[9px] text-hint"> {agent.taskCount} · heartbeat {agent.lastHeartbeat.slice(11)}</span>
<div className="flex gap-0.5">
<button type="button" className="p-1 text-hint hover:text-label" title="상태 상세"><Eye className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-heading" title="이름 변경"><Edit2 className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-heading" title="삭제"><Trash2 className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-blue-400" title="상태 상세"><Eye className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-yellow-400" title="이름 변경"><Edit2 className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-red-400" title="삭제"><Trash2 className="w-3 h-3" /></button>
</div>
</div>
</CardContent>

파일 보기

@ -1,10 +1,8 @@
import { useState } from 'react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { TabBar, TabButton } from '@shared/components/ui/tabs';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { getStatusIntent } from '@shared/constants/statusIntent';
import { useAuth } from '@/app/auth/AuthContext';
import {
Database, CheckCircle, AlertTriangle, FileText, Users,
Layers, Table2, Search, ChevronRight, GitBranch,
@ -107,37 +105,32 @@ const VERIFICATION_HISTORY = [
export function DataModelVerification() {
const [tab, setTab] = useState<Tab>('overview');
const { hasPermission } = useAuth();
// 향후 Phase 3 에서 검증 승인·이력 등록 버튼 추가 시 가드로 연결
void hasPermission('admin:data-model-verification', 'CREATE');
void hasPermission('admin:data-model-verification', 'UPDATE');
const TABS: Array<{ key: Tab; icon: typeof Eye; label: string }> = [
{ key: 'overview', icon: Eye, label: '검증 현황' },
{ key: 'logical', icon: GitBranch, label: '논리 모델 검증' },
{ key: 'physical', icon: Database, label: '물리 모델 검증' },
{ key: 'duplication', icon: Search, label: '중복·정합성 점검' },
{ key: 'history', icon: FileText, label: '검증 결과 이력' },
];
return (
<PageContainer>
<PageHeader
icon={ListChecks}
iconColor="text-green-600 dark:text-green-400"
iconColor="text-green-400"
title="데이터 모델 검증"
description="DAR-11 | 논리·물리 데이터 모델 검증 기준 정의·실시 및 결과 관리"
demo
/>
<TabBar variant="underline">
{TABS.map(t => (
<TabButton key={t.key} variant="underline" active={tab === t.key}
icon={<t.icon className="w-3.5 h-3.5" />} onClick={() => setTab(t.key)}>
{t.label}
</TabButton>
{/* 탭 */}
<div className="flex gap-0 border-b border-border">
{([
{ key: 'overview' as Tab, icon: Eye, label: '검증 현황' },
{ key: 'logical' as Tab, icon: GitBranch, label: '논리 모델 검증' },
{ key: 'physical' as Tab, icon: Database, label: '물리 모델 검증' },
{ key: 'duplication' as Tab, icon: Search, label: '중복·정합성 점검' },
{ key: 'history' as Tab, icon: FileText, label: '검증 결과 이력' },
]).map(t => (
<button type="button" key={t.key} onClick={() => setTab(t.key)}
className={`flex items-center gap-1.5 px-4 py-2.5 text-[11px] font-medium border-b-2 transition-colors ${tab === t.key ? 'text-green-400 border-green-400' : 'text-hint border-transparent hover:text-label'}`}>
<t.icon className="w-3.5 h-3.5" />{t.label}
</button>
))}
</TabBar>
</div>
{/* ── ① 검증 현황 ── */}
{tab === 'overview' && (
@ -157,7 +150,7 @@ export function DataModelVerification() {
{/* 검증 절차 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<ClipboardCheck className="w-4 h-4 text-green-600 dark:text-green-400" />
<ClipboardCheck className="w-4 h-4 text-green-400" />
<span className="text-[12px] font-bold text-heading"> (4)</span>
</div>
<div className="grid grid-cols-4 gap-3">
@ -168,14 +161,14 @@ export function DataModelVerification() {
)}
<div className="bg-surface-overlay rounded-lg p-3 h-full">
<div className="flex items-center gap-2 mb-2">
<s.icon className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-[11px] font-bold text-green-600 dark:text-green-400">{s.phase}</span>
<s.icon className="w-4 h-4 text-green-400" />
<span className="text-[11px] font-bold text-green-400">{s.phase}</span>
</div>
<div className="text-[9px] text-cyan-600 dark:text-cyan-400 mb-2">{s.responsible}</div>
<div className="text-[9px] text-cyan-400 mb-2">{s.responsible}</div>
<ul className="space-y-1.5">
{s.actions.map(a => (
<li key={a} className="flex items-start gap-1.5 text-[9px] text-muted-foreground">
<CheckCircle className="w-3 h-3 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<CheckCircle className="w-3 h-3 text-green-500 shrink-0 mt-0.5" />
<span>{a}</span>
</li>
))}
@ -190,7 +183,7 @@ export function DataModelVerification() {
<div className="grid grid-cols-2 gap-3">
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Users className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<Users className="w-4 h-4 text-blue-400" />
<span className="text-[12px] font-bold text-heading"> </span>
</div>
<div className="space-y-2">
@ -207,7 +200,7 @@ export function DataModelVerification() {
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Layers className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<Layers className="w-4 h-4 text-purple-400" />
<span className="text-[12px] font-bold text-heading"> ({SUBJECT_AREAS.reduce((s, a) => s + a.count, 0)} )</span>
</div>
<div className="space-y-1.5">
@ -229,7 +222,7 @@ export function DataModelVerification() {
<div className="space-y-3">
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<GitBranch className="w-4 h-4 text-green-600 dark:text-green-400" />
<GitBranch className="w-4 h-4 text-green-400" />
<span className="text-[12px] font-bold text-heading"> </span>
<Badge intent="success" size="xs">{LOGICAL_CHECKS.filter(c => c.status === '통과').length}/{LOGICAL_CHECKS.length} </Badge>
</div>
@ -249,7 +242,7 @@ export function DataModelVerification() {
<td className="py-2.5 px-2"><Badge intent="muted" size="xs">{c.category}</Badge></td>
<td className="py-2.5 text-heading font-medium">{c.item}</td>
<td className="py-2.5 text-muted-foreground text-[9px]">{c.desc}</td>
<td className="py-2.5 text-center text-cyan-600 dark:text-cyan-400 font-medium text-[9px]">{c.result}</td>
<td className="py-2.5 text-center text-cyan-400 font-medium text-[9px]">{c.result}</td>
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(c.status)} size="sm">{c.status}</Badge></td>
</tr>
))}
@ -279,7 +272,7 @@ export function DataModelVerification() {
{tab === 'physical' && (
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Database className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<Database className="w-4 h-4 text-purple-400" />
<span className="text-[12px] font-bold text-heading"> </span>
<Badge intent="success" size="xs">{PHYSICAL_CHECKS.filter(c => c.status === '통과').length}/{PHYSICAL_CHECKS.length} </Badge>
{PHYSICAL_CHECKS.some(c => c.status === '주의') && (
@ -302,7 +295,7 @@ export function DataModelVerification() {
<td className="py-2.5 px-2"><Badge intent="muted" size="xs">{c.category}</Badge></td>
<td className="py-2.5 text-heading font-medium">{c.item}</td>
<td className="py-2.5 text-muted-foreground text-[9px]">{c.desc}</td>
<td className="py-2.5 text-center text-cyan-600 dark:text-cyan-400 font-medium text-[9px]">{c.result}</td>
<td className="py-2.5 text-center text-cyan-400 font-medium text-[9px]">{c.result}</td>
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(c.status)} size="sm">{c.status}</Badge></td>
</tr>
))}
@ -315,7 +308,7 @@ export function DataModelVerification() {
{tab === 'duplication' && (
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Shield className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
<Shield className="w-4 h-4 text-cyan-400" />
<span className="text-[12px] font-bold text-heading"> · </span>
<Badge intent="success" size="xs">{DUPLICATION_CHECKS.filter(c => c.status === '통과').length}/{DUPLICATION_CHECKS.length} </Badge>
</div>
@ -335,7 +328,7 @@ export function DataModelVerification() {
<td className="py-2.5 px-2 text-heading font-medium">{c.target}</td>
<td className="py-2.5 text-muted-foreground text-[9px]">{c.desc}</td>
<td className="py-2.5 text-center"><Badge intent="muted" size="xs">{c.scope}</Badge></td>
<td className="py-2.5 text-center text-cyan-600 dark:text-cyan-400 font-medium text-[9px]">{c.result}</td>
<td className="py-2.5 text-center text-cyan-400 font-medium text-[9px]">{c.result}</td>
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(c.status)} size="sm">{c.status}</Badge></td>
</tr>
))}
@ -348,7 +341,7 @@ export function DataModelVerification() {
{tab === 'history' && (
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<FileText className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<FileText className="w-4 h-4 text-blue-400" />
<span className="text-[12px] font-bold text-heading"> </span>
<Badge intent="info" size="xs">{VERIFICATION_HISTORY.length}</Badge>
</div>
@ -372,7 +365,7 @@ export function DataModelVerification() {
<td className="py-2.5 text-center"><Badge intent="muted" size="xs">{h.phase}</Badge></td>
<td className="py-2.5 text-muted-foreground">{h.reviewer}</td>
<td className="py-2.5 text-heading font-medium text-[9px]">{h.target}</td>
<td className="py-2.5 text-center">{h.issues > 0 ? <span className="text-yellow-600 dark:text-yellow-400 font-bold">{h.issues}</span> : <span className="text-green-600 dark:text-green-400">0</span>}</td>
<td className="py-2.5 text-center">{h.issues > 0 ? <span className="text-yellow-400 font-bold">{h.issues}</span> : <span className="text-green-400">0</span>}</td>
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(h.result)} size="sm">{h.result}</Badge></td>
</tr>
))}

파일 보기

@ -1,10 +1,8 @@
import { useState } from 'react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { TabBar, TabButton } from '@shared/components/ui/tabs';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { getStatusIntent } from '@shared/constants/statusIntent';
import { useAuth } from '@/app/auth/AuthContext';
import {
Database, Clock, Trash2, ShieldCheck, FileText, AlertTriangle,
CheckCircle, Archive, CalendarClock, UserCheck, Search,
@ -91,37 +89,32 @@ const STORAGE_ARCHITECTURE = [
export function DataRetentionPolicy() {
const [tab, setTab] = useState<Tab>('overview');
const { hasPermission } = useAuth();
// 향후 Phase 3 에서 파기 승인/예외 등록 시 disabled 가드로 활용
void hasPermission('admin:data-retention', 'UPDATE');
void hasPermission('admin:data-retention', 'DELETE');
const TABS: Array<{ key: Tab; icon: typeof Eye; label: string }> = [
{ key: 'overview', icon: Eye, label: '보관 현황' },
{ key: 'retention', icon: CalendarClock, label: '유형별 보관기간' },
{ key: 'disposal', icon: Trash2, label: '파기 절차' },
{ key: 'exception', icon: ShieldCheck, label: '예외·연장' },
{ key: 'audit', icon: FileText, label: '파기 감사 대장' },
];
return (
<PageContainer>
<PageHeader
icon={Database}
iconColor="text-blue-600 dark:text-blue-400"
iconColor="text-blue-400"
title="데이터 보관기간 및 파기 정책"
description="DAR-10 | 데이터 유형별 보관기간 기준표, 파기 절차, 보존 연장 예외 관리"
demo
/>
<TabBar variant="underline">
{TABS.map(t => (
<TabButton key={t.key} variant="underline" active={tab === t.key}
icon={<t.icon className="w-3.5 h-3.5" />} onClick={() => setTab(t.key)}>
{t.label}
</TabButton>
{/* 탭 */}
<div className="flex gap-0 border-b border-border">
{([
{ key: 'overview' as Tab, icon: Eye, label: '보관 현황' },
{ key: 'retention' as Tab, icon: CalendarClock, label: '유형별 보관기간' },
{ key: 'disposal' as Tab, icon: Trash2, label: '파기 절차' },
{ key: 'exception' as Tab, icon: ShieldCheck, label: '예외·연장' },
{ key: 'audit' as Tab, icon: FileText, label: '파기 감사 대장' },
]).map(t => (
<button type="button" key={t.key} onClick={() => setTab(t.key)}
className={`flex items-center gap-1.5 px-4 py-2.5 text-[11px] font-medium border-b-2 transition-colors ${tab === t.key ? 'text-blue-400 border-blue-400' : 'text-hint border-transparent hover:text-label'}`}>
<t.icon className="w-3.5 h-3.5" />{t.label}
</button>
))}
</TabBar>
</div>
{/* ── ① 보관 현황 ── */}
{tab === 'overview' && (
@ -141,14 +134,14 @@ export function DataRetentionPolicy() {
{/* 보관 구조 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Settings className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<Settings className="w-4 h-4 text-blue-400" />
<span className="text-[12px] font-bold text-heading"> (4-Tier)</span>
</div>
<div className="grid grid-cols-4 gap-3">
{STORAGE_ARCHITECTURE.map(s => (
<div key={s.tier} className="bg-surface-overlay rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<s.icon className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<s.icon className="w-4 h-4 text-blue-400" />
<span className="text-[11px] font-bold text-heading">{s.tier}</span>
</div>
<p className="text-[9px] text-hint mb-2">{s.desc}</p>
@ -184,7 +177,7 @@ export function DataRetentionPolicy() {
['CCTV 30일 보관 준수', '미채택 영상 30일 초과 1건', '주의'],
].map(([k, v, s]) => (
<div key={k} className="flex items-center gap-2 px-2 py-1.5 bg-surface-overlay rounded">
{s === '완료' || s === '정상' ? <CheckCircle className="w-3 h-3 text-green-600 dark:text-green-500" /> : <AlertTriangle className="w-3 h-3 text-yellow-600 dark:text-yellow-500" />}
{s === '완료' || s === '정상' ? <CheckCircle className="w-3 h-3 text-green-500" /> : <AlertTriangle className="w-3 h-3 text-yellow-500" />}
<span className="text-heading flex-1">{k}</span>
<span className="text-hint">{v}</span>
</div>
@ -213,7 +206,7 @@ export function DataRetentionPolicy() {
<div className="space-y-3">
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<CalendarClock className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<CalendarClock className="w-4 h-4 text-blue-400" />
<span className="text-[12px] font-bold text-heading"> </span>
<Badge intent="info" size="xs">{RETENTION_TABLE.length} </Badge>
</div>
@ -235,7 +228,7 @@ export function DataRetentionPolicy() {
<td className="py-2.5 px-2 text-heading font-medium">{r.type}</td>
<td className="py-2.5"><Badge intent="muted" size="xs">{r.category}</Badge></td>
<td className="py-2.5 text-muted-foreground text-[9px]">{r.basis}</td>
<td className="py-2.5 text-center text-cyan-600 dark:text-cyan-400 font-bold">{r.period}</td>
<td className="py-2.5 text-center text-cyan-400 font-bold">{r.period}</td>
<td className="py-2.5 text-muted-foreground text-[9px]">{r.format}</td>
<td className="py-2.5 text-center text-muted-foreground">{r.volume}</td>
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(r.status)} size="sm">{r.status}</Badge></td>
@ -273,7 +266,7 @@ export function DataRetentionPolicy() {
{/* 파기 승인 워크플로우 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
<Trash2 className="w-4 h-4 text-red-400" />
<span className="text-[12px] font-bold text-heading"> (4)</span>
</div>
<div className="grid grid-cols-4 gap-3">
@ -284,14 +277,14 @@ export function DataRetentionPolicy() {
)}
<div className="bg-surface-overlay rounded-lg p-3 h-full">
<div className="flex items-center gap-2 mb-2">
<s.icon className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<span className="text-[11px] font-bold text-blue-600 dark:text-blue-400">{s.phase}</span>
<s.icon className="w-4 h-4 text-blue-400" />
<span className="text-[11px] font-bold text-blue-400">{s.phase}</span>
</div>
<div className="text-[9px] text-cyan-600 dark:text-cyan-400 mb-2">{s.responsible}</div>
<div className="text-[9px] text-cyan-400 mb-2">{s.responsible}</div>
<ul className="space-y-1.5">
{s.actions.map(a => (
<li key={a} className="flex items-start gap-1.5 text-[9px] text-muted-foreground">
<CheckCircle className="w-3 h-3 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<CheckCircle className="w-3 h-3 text-green-500 shrink-0 mt-0.5" />
<span>{a}</span>
</li>
))}
@ -305,7 +298,7 @@ export function DataRetentionPolicy() {
{/* 파기 방식 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Lock className="w-4 h-4 text-red-600 dark:text-red-400" />
<Lock className="w-4 h-4 text-red-400" />
<span className="text-[12px] font-bold text-heading"> </span>
</div>
<table className="w-full text-[10px]">
@ -326,7 +319,7 @@ export function DataRetentionPolicy() {
<td className="py-2.5 text-muted-foreground text-[9px]">{m.desc}</td>
<td className="py-2.5 text-muted-foreground">{m.target}</td>
<td className="py-2.5 text-center"><Badge intent={m.encryption.includes('AES') ? 'success' : 'muted'} size="xs">{m.encryption}</Badge></td>
<td className="py-2.5 text-center text-red-600 dark:text-red-400 text-[9px] font-medium">{m.recovery}</td>
<td className="py-2.5 text-center text-red-400 text-[9px] font-medium">{m.recovery}</td>
<td className="py-2.5 text-center"><Badge intent="success" size="sm">{m.status}</Badge></td>
</tr>
))}
@ -341,7 +334,7 @@ export function DataRetentionPolicy() {
<div className="space-y-3">
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<ShieldCheck className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<ShieldCheck className="w-4 h-4 text-purple-400" />
<span className="text-[12px] font-bold text-heading"> </span>
<Badge intent="warning" size="xs">{EXCEPTIONS.filter(e => e.status === '연장 중').length} </Badge>
</div>
@ -364,7 +357,7 @@ export function DataRetentionPolicy() {
<td className="py-2.5 text-heading font-medium">{e.dataType}</td>
<td className="py-2.5 text-muted-foreground text-[9px]">{e.reason}</td>
<td className="py-2.5 text-center text-muted-foreground">{e.originalExpiry}</td>
<td className="py-2.5 text-center text-cyan-600 dark:text-cyan-400 font-medium text-[9px]">{e.extendedTo}</td>
<td className="py-2.5 text-center text-cyan-400 font-medium text-[9px]">{e.extendedTo}</td>
<td className="py-2.5 text-center text-muted-foreground">{e.approver}</td>
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(e.status)} size="sm">{e.status}</Badge></td>
</tr>
@ -375,13 +368,13 @@ export function DataRetentionPolicy() {
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<AlertTriangle className="w-4 h-4 text-yellow-600 dark:text-yellow-400" />
<AlertTriangle className="w-4 h-4 text-yellow-400" />
<span className="text-[12px] font-bold text-heading"> </span>
</div>
<div className="space-y-2">
{EXCEPTION_RULES.map(r => (
<div key={r.rule} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
<ShieldCheck className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<ShieldCheck className="w-4 h-4 text-purple-400" />
<div className="flex-1">
<div className="text-[11px] text-heading font-medium">{r.rule}</div>
<div className="text-[9px] text-hint">{r.desc}</div>
@ -398,7 +391,7 @@ export function DataRetentionPolicy() {
{tab === 'audit' && (
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<FileText className="w-4 h-4 text-green-600 dark:text-green-400" />
<FileText className="w-4 h-4 text-green-400" />
<span className="text-[12px] font-bold text-heading"> </span>
<Badge intent="info" size="xs">{DISPOSAL_AUDIT_LOG.length}</Badge>
</div>
@ -424,7 +417,7 @@ export function DataRetentionPolicy() {
<td className="py-2.5 text-heading font-medium text-[9px]">{d.target}</td>
<td className="py-2.5 text-center"><Badge intent="muted" size="xs">{d.type}</Badge></td>
<td className="py-2.5 text-center text-muted-foreground">{d.method}</td>
<td className="py-2.5 text-center text-cyan-600 dark:text-cyan-400 font-mono">{d.volume}</td>
<td className="py-2.5 text-center text-cyan-400 font-mono">{d.volume}</td>
<td className="py-2.5 text-center text-muted-foreground">{d.operator}</td>
<td className="py-2.5 text-center text-muted-foreground">{d.approver}</td>
<td className="py-2.5 text-center"><Badge intent="success" size="sm">{d.result}</Badge></td>

파일 보기

@ -41,7 +41,7 @@ export function LoginHistoryView() {
<PageContainer size="lg">
<PageHeader
icon={LogIn}
iconColor="text-label"
iconColor="text-green-400"
title="로그인 이력"
description="성공/실패 로그인 시도 기록 (5회 실패 시 자동 잠금)"
actions={
@ -55,10 +55,10 @@ export function LoginHistoryView() {
{stats && (
<div className="grid grid-cols-5 gap-3">
<MetricCard label="전체 시도" value={stats.total} color="text-heading" />
<MetricCard label="성공 (24h)" value={stats.success24h} color="text-label" />
<MetricCard label="실패 (24h)" value={stats.failed24h} color="text-label" />
<MetricCard label="잠금 (24h)" value={stats.locked24h} color="text-heading" />
<MetricCard label="성공률 (24h)" value={stats.successRate} color="text-label" suffix="%" />
<MetricCard label="성공 (24h)" value={stats.success24h} color="text-green-400" />
<MetricCard label="실패 (24h)" value={stats.failed24h} color="text-orange-400" />
<MetricCard label="잠금 (24h)" value={stats.locked24h} color="text-red-400" />
<MetricCard label="성공률 (24h)" value={stats.successRate} color="text-cyan-400" suffix="%" />
</div>
)}
@ -71,7 +71,7 @@ export function LoginHistoryView() {
<CardContent className="px-4 pb-4 space-y-1">
{stats.byUser.map((u) => (
<div key={u.user_acnt} className="flex items-center justify-between text-[11px]">
<span className="text-label font-mono">{u.user_acnt}</span>
<span className="text-cyan-400 font-mono">{u.user_acnt}</span>
<span className="text-heading font-bold">{u.count}</span>
</div>
))}
@ -86,9 +86,9 @@ export function LoginHistoryView() {
<div key={d.day} className="flex items-center justify-between text-[11px]">
<span className="text-muted-foreground font-mono">{formatDate(d.day)}</span>
<div className="flex gap-3">
<span className="text-label"> {d.success}</span>
<span className="text-label"> {d.failed}</span>
<span className="text-heading"> {d.locked}</span>
<span className="text-green-400"> {d.success}</span>
<span className="text-orange-400"> {d.failed}</span>
<span className="text-red-400"> {d.locked}</span>
</div>
</div>
))}
@ -98,7 +98,7 @@ export function LoginHistoryView() {
</div>
)}
{error && <div className="text-xs text-heading">: {error}</div>}
{error && <div className="text-xs text-red-400">: {error}</div>}
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
@ -123,11 +123,11 @@ export function LoginHistoryView() {
<tr key={it.histSn} className="border-t border-border hover:bg-surface-overlay/50">
<td className="px-3 py-2 text-hint font-mono">{it.histSn}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.loginDtm)}</td>
<td className="px-3 py-2 text-label">{it.userAcnt}</td>
<td className="px-3 py-2 text-cyan-400">{it.userAcnt}</td>
<td className="px-3 py-2">
<Badge intent={getLoginResultIntent(it.result)} size="sm">{getLoginResultLabel(it.result, tc, lang)}</Badge>
</td>
<td className="px-3 py-2 text-heading text-[10px]">{it.failReason || '-'}</td>
<td className="px-3 py-2 text-red-400 text-[10px]">{it.failReason || '-'}</td>
<td className="px-3 py-2 text-muted-foreground">{it.authProvider || '-'}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{it.loginIp || '-'}</td>
</tr>

파일 보기

@ -1,16 +1,17 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { useAuth } from '@/app/auth/AuthContext';
import type { BadgeIntent } from '@lib/theme/variants';
import {
Bell, Plus, Edit2, Trash2, Eye,
Megaphone, AlertTriangle, Info,
Clock, Pin, Monitor, MessageSquare, X,
Bell, Plus, Edit2, Trash2, Eye, EyeOff, Calendar,
Users, Megaphone, AlertTriangle, Info, Search, Filter,
CheckCircle, Clock, Pin, Monitor, MessageSquare, X,
} from 'lucide-react';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { toDateParam } from '@shared/utils/dateFormat';
import { SaveButton } from '@shared/components/common/SaveButton';
import type { SystemNotice, NoticeType, NoticeDisplay } from '@shared/components/common/NotificationBanner';
@ -58,10 +59,10 @@ const INITIAL_NOTICES: SystemNotice[] = [
];
const TYPE_OPTIONS: { key: NoticeType; label: string; icon: React.ElementType; color: string }[] = [
{ key: 'info', label: '정보', icon: Info, color: 'text-label' },
{ key: 'warning', label: '경고', icon: AlertTriangle, color: 'text-label' },
{ key: 'urgent', label: '긴급', icon: Bell, color: 'text-label' },
{ key: 'maintenance', label: '점검', icon: Megaphone, color: 'text-label' },
{ key: 'info', label: '정보', icon: Info, color: 'text-blue-400' },
{ key: 'warning', label: '경고', icon: AlertTriangle, color: 'text-yellow-400' },
{ key: 'urgent', label: '긴급', icon: Bell, color: 'text-red-400' },
{ key: 'maintenance', label: '점검', icon: Megaphone, color: 'text-orange-400' },
];
const DISPLAY_OPTIONS: { key: NoticeDisplay; label: string; icon: React.ElementType }[] = [
@ -74,7 +75,6 @@ const ROLE_OPTIONS = ['ADMIN', 'OPERATOR', 'ANALYST', 'FIELD', 'VIEWER'];
export function NoticeManagement() {
const { t } = useTranslation('admin');
const { t: tc } = useTranslation('common');
const { hasPermission } = useAuth();
const canCreate = hasPermission('admin:notices', 'CREATE');
const canUpdate = hasPermission('admin:notices', 'UPDATE');
@ -146,7 +146,7 @@ export function NoticeManagement() {
<PageContainer>
<PageHeader
icon={Bell}
iconColor="text-label"
iconColor="text-yellow-400"
title={t('notices.title')}
description={t('notices.desc')}
demo
@ -161,9 +161,9 @@ export function NoticeManagement() {
<div className="flex gap-2">
{[
{ label: '전체 알림', count: notices.length, icon: Bell, color: 'text-label', bg: 'bg-muted' },
{ label: '현재 노출 중', count: activeCount, icon: Eye, color: 'text-label', bg: 'bg-green-500/10' },
{ label: '예약됨', count: scheduledCount, icon: Clock, color: 'text-label', bg: 'bg-blue-500/10' },
{ label: '긴급 알림', count: urgentCount, icon: AlertTriangle, color: 'text-label', bg: 'bg-red-500/10' },
{ label: '현재 노출 중', count: activeCount, icon: Eye, color: 'text-green-400', bg: 'bg-green-500/10' },
{ label: '예약됨', count: scheduledCount, icon: Clock, color: 'text-blue-400', bg: 'bg-blue-500/10' },
{ label: '긴급 알림', count: urgentCount, icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-500/10' },
].map((kpi) => (
<div key={kpi.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
<div className={`p-1.5 rounded-lg ${kpi.bg}`}>
@ -238,14 +238,14 @@ export function NoticeManagement() {
)}
</td>
<td className="px-1 py-1.5 text-center">
{n.pinned && <Pin className="w-3 h-3 text-label inline" />}
{n.pinned && <Pin className="w-3 h-3 text-yellow-400 inline" />}
</td>
<td className="px-1 py-1.5">
<div className="flex items-center justify-center gap-0.5">
<button type="button" onClick={() => openEdit(n)} disabled={!canUpdate} className="p-1 text-hint hover:text-heading disabled:opacity-30 disabled:cursor-not-allowed" title={canUpdate ? '수정' : '수정 권한이 필요합니다'}>
<button type="button" onClick={() => openEdit(n)} disabled={!canUpdate} className="p-1 text-hint hover:text-blue-400 disabled:opacity-30 disabled:cursor-not-allowed" title={canUpdate ? '수정' : '수정 권한이 필요합니다'}>
<Edit2 className="w-3 h-3" />
</button>
<button type="button" onClick={() => handleDelete(n.id)} disabled={!canDelete} className="p-1 text-hint hover:text-heading disabled:opacity-30 disabled:cursor-not-allowed" title={canDelete ? '삭제' : '삭제 권한이 필요합니다'}>
<button type="button" onClick={() => handleDelete(n.id)} disabled={!canDelete} className="p-1 text-hint hover:text-red-400 disabled:opacity-30 disabled:cursor-not-allowed" title={canDelete ? '삭제' : '삭제 권한이 필요합니다'}>
<Trash2 className="w-3 h-3" />
</button>
</div>
@ -266,7 +266,7 @@ export function NoticeManagement() {
<span className="text-sm font-bold text-heading">
{editingId ? '알림 수정' : '새 알림 등록'}
</span>
<button type="button" aria-label={tc('aria.close')} onClick={() => setShowForm(false)} className="text-hint hover:text-heading">
<button type="button" aria-label="닫기" onClick={() => setShowForm(false)} className="text-hint hover:text-heading">
<X className="w-4 h-4" />
</button>
</div>
@ -276,7 +276,7 @@ export function NoticeManagement() {
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<input
aria-label={tc('aria.noticeTitle')}
aria-label="알림 제목"
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/50"
@ -288,7 +288,7 @@ export function NoticeManagement() {
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<textarea
aria-label={tc('aria.noticeContent')}
aria-label="알림 내용"
value={form.message}
onChange={(e) => setForm({ ...form, message: e.target.value })}
rows={3}
@ -327,7 +327,7 @@ export function NoticeManagement() {
onClick={() => setForm({ ...form, display: opt.key })}
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg text-[10px] transition-colors ${
form.display === opt.key
? 'bg-blue-600/20 text-label font-bold'
? 'bg-blue-600/20 text-blue-400 font-bold'
: 'text-hint hover:bg-surface-overlay'
}`}
>
@ -344,7 +344,7 @@ export function NoticeManagement() {
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<input
aria-label={tc('aria.dateFrom')}
aria-label="시작일"
type="date"
value={form.startDate}
onChange={(e) => setForm({ ...form, startDate: e.target.value })}
@ -354,7 +354,7 @@ export function NoticeManagement() {
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<input
aria-label={tc('aria.dateTo')}
aria-label="종료일"
type="date"
value={form.endDate}
onChange={(e) => setForm({ ...form, endDate: e.target.value })}
@ -375,7 +375,7 @@ export function NoticeManagement() {
onClick={() => toggleRole(role)}
className={`px-3 py-1.5 rounded-lg text-[10px] transition-colors ${
form.targetRoles.includes(role)
? 'bg-surface-overlay text-heading border border-border font-bold'
? 'bg-blue-600/20 text-blue-400 border border-blue-500/30 font-bold'
: 'text-hint border border-slate-700/30 hover:bg-surface-overlay'
}`}
>

파일 보기

@ -1,651 +0,0 @@
import { useState } from 'react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { TabBar, TabButton } from '@shared/components/ui/tabs';
import { PageContainer, PageHeader } from '@shared/components/layout';
import {
getPerformanceStatusHex,
getPerformanceStatusIntent,
utilizationStatus,
type PerformanceStatus,
} from '@shared/constants/performanceStatus';
import { useAuth } from '@/app/auth/AuthContext';
import {
Activity, Gauge, Users, Database, Brain, Server,
CheckCircle, AlertTriangle, TrendingUp, Clock,
Zap, Shield, BarChart3, Cpu, HardDrive, Wifi,
} from 'lucide-react';
/*
* (PER-01 ~ PER-06)
*
* 3,000 ( 200· 100 24/7 + ··)
* (V-PASS·VTS·E-nav) + S&P Global AIS A/B
*
* (PER-01) (PER-02·03)
* AI (PER-04) ·(PER-05·06)
*/
type Tab = 'overview' | 'response' | 'capacity' | 'aiModel' | 'availability';
// ─── 성능 KPI ──────────────────
const PERF_KPI: Array<{ label: string; value: string; unit: string; icon: typeof Users; status: PerformanceStatus }> = [
{ label: '현재 동시접속', value: '342', unit: '명', icon: Users, status: 'normal' },
{ label: '대시보드 p95', value: '1.8', unit: '초', icon: Gauge, status: 'good' },
{ label: '시스템 가동률', value: '99.87', unit: '%', icon: Shield, status: 'good' },
{ label: 'AI 추론 p95', value: '1.4', unit: '초', icon: Brain, status: 'good' },
{ label: '배치 SLA 준수', value: '100', unit: '%', icon: CheckCircle, status: 'good' },
{ label: '이벤트 경보', value: '0', unit: '건', icon: AlertTriangle, status: 'normal' },
];
// ─── SLO 적용 그룹 ──────────────────
const USER_GROUPS = [
{ group: '본청 상황실', users: 100, concurrent: '100 (100%)', sla: '≤ 2초 대시보드 / ≤ 1초 지도', priority: 'critical' as const, note: '24/7 상시 접속 · 최상위 SLO' },
{ group: '본청 기타', users: 100, concurrent: '50 (50%)', sla: '≤ 3초 대시보드', priority: 'high' as const, note: '주간 업무 시간' },
{ group: '지방청(5개)', users: 400, concurrent: '120 (30%)', sla: '≤ 3초 대시보드', priority: 'high' as const, note: '관할해역 상황실' },
{ group: '관할서', users: 1500, concurrent: '300 (20%)', sla: '≤ 1.5초 조회', priority: 'info' as const, note: '주간 피크' },
{ group: '함정·파출소', users: 800, concurrent: '120 (15%)', sla: '≤ 1초 API', priority: 'info' as const, note: '모바일 Agent · 저대역폭' },
];
// ─── PER-01 응답성 SLO vs 실측 ──────────────────
const RESPONSE_SLO = [
{ target: '메인 대시보드 초기 로드', slo: '2.0초', p50: '0.9초', p95: '1.8초', p99: '2.4초', status: 'good' as const },
{ target: '위험도 지도 격자 조회', slo: '2.0초', p50: '0.7초', p95: '1.6초', p99: '2.1초', status: 'good' as const },
{ target: '의심 선박·어구 단순 조회', slo: '1.5초', p50: '0.4초', p95: '1.1초', p99: '1.7초', status: 'good' as const },
{ target: '복합 분석·시각화', slo: '5.0초', p50: '2.1초', p95: '4.2초', p99: '5.8초', status: 'warn' as const },
{ target: 'AI 추론 API (단건)', slo: '2.0초', p50: '0.6초', p95: '1.4초', p99: '1.9초', status: 'good' as const },
{ target: '연계 API (read)', slo: '500ms', p50: '120ms', p95: '380ms', p99: '510ms', status: 'warn' as const },
{ target: '함정 모바일 Agent API', slo: '1.0초', p50: '0.3초', p95: '0.8초', p99: '1.2초', status: 'good' as const },
{ target: 'AI 탐지 알림 End-to-End', slo: '3.0초', p50: '1.1초', p95: '2.3초', p99: '2.8초', status: 'good' as const },
];
// ─── PER-02 동시접속·TPS ──────────────────
const CAPACITY_METRICS = [
{ metric: '현재 동시접속', current: 342, target: '600 (정상 피크)', max: '900 (작전 피크)', utilization: 57, intent: 'info' as const },
{ metric: '현재 TPS', current: 185, target: '400 TPS', max: '600 TPS', utilization: 46, intent: 'info' as const },
{ metric: '활성 세션', current: 287, target: '500', max: '750', utilization: 57, intent: 'info' as const },
{ metric: 'WebSocket 연결', current: 142, target: '300', max: '500', utilization: 47, intent: 'info' as const },
];
// ─── PER-03 배치 처리 현황 ──────────────────
const BATCH_JOBS = [
{ name: 'AIS 국내 정제·적재', schedule: '매 5분', volume: '~5 GB/일', sla: '5분', avg: '2분 18초', lastRun: '성공', status: 'success' as const },
{ name: 'S&P 글로벌 AIS 집계·격자', schedule: '00:00 야간', volume: '~500 GB/일 (압축 후)', sla: '3시간', avg: '2시간 12분', lastRun: '성공', status: 'success' as const },
{ name: '위성영상 타일링·인덱싱', schedule: '수신 직후', volume: '건당 2~10 GB', sla: '2시간/건', avg: '1시간 24분', lastRun: '성공', status: 'success' as const },
{ name: '피처 스토어 갱신', schedule: '매시 정각', volume: '~200 MB', sla: '1시간', avg: '8분 42초', lastRun: '성공', status: 'success' as const },
{ name: 'AI 모델 재학습 (주간)', schedule: '일요일 02:00', volume: '학습셋 전체', sla: '8시간', avg: '6시간 18분', lastRun: '성공', status: 'success' as const },
{ name: '통계·리포트 집계', schedule: '매시 정각', volume: '~50 MB', sla: '30분', avg: '6분 12초', lastRun: '성공', status: 'success' as const },
{ name: '해양기상·환경 수집', schedule: '매시 정각', volume: '~500 MB', sla: '10분', avg: '3분 48초', lastRun: '지연', status: 'warn' as const },
];
// ─── PER-04 AI 모델 성능 ──────────────────
const AI_MODELS = [
{ model: '불법조업 위험도 예측', accuracy: 92.4, precision: 89.1, recall: 87.6, f1: 88.3, rocAuc: 0.948, target: '≥ 85% 정확도', status: 'good' as const },
{ model: '순찰 경로 추천 (단일)', accuracy: 94.1, precision: 91.2, recall: 90.5, f1: 90.8, rocAuc: 0.961, target: '≥ 90% 정확도', status: 'good' as const },
{ model: '다함정 협력 경로 최적화', accuracy: 91.8, precision: 88.4, recall: 87.9, f1: 88.1, rocAuc: 0.936, target: '≥ 85% 정확도', status: 'good' as const },
{ model: '불법 어선 (Dark Vessel) 탐지', accuracy: 96.2, precision: 94.8, recall: 92.3, f1: 93.5, rocAuc: 0.978, target: '≥ 92% 정확도', status: 'good' as const },
{ model: '불법 어망·어구 탐지', accuracy: 88.7, precision: 85.2, recall: 83.6, f1: 84.4, rocAuc: 0.912, target: '≥ 85% 정확도', status: 'warn' as const },
{ model: 'AIS 조작 패턴 감지', accuracy: 93.5, precision: 91.7, recall: 89.4, f1: 90.5, rocAuc: 0.954, target: '≥ 90% 정확도', status: 'good' as const },
];
// ─── PER-05 가용성·장애복구 ──────────────────
const AVAILABILITY_METRICS = [
{ component: '애플리케이션 서버 (K8s)', uptime: '99.98%', rto: '≤ 30초', rpo: '0 (stateless)', lastIncident: '없음', status: 'good' as const },
{ component: 'DB (PostgreSQL HA)', uptime: '99.95%', rto: '≤ 60초', rpo: '≤ 5초', lastIncident: '2026-03-28', status: 'good' as const },
{ component: 'TimescaleDB (Hot)', uptime: '99.92%', rto: '≤ 120초', rpo: '≤ 15초', lastIncident: '2026-04-02', status: 'good' as const },
{ component: '벡터 DB (RAG)', uptime: '99.87%', rto: '≤ 180초', rpo: '≤ 30초', lastIncident: '2026-04-08', status: 'warn' as const },
{ component: 'NAS 스토리지', uptime: '99.99%', rto: '≤ 60초', rpo: '0 (이중화)', lastIncident: '없음', status: 'good' as const },
{ component: '통합게이트웨이', uptime: '99.89%', rto: '≤ 60초', rpo: '≤ 10초', lastIncident: '2026-04-05', status: 'good' as const },
{ component: 'S&P Global AIS API', uptime: '99.41%', rto: 'Fallback 즉시', rpo: '국내 신호 대체', lastIncident: '2026-04-12', status: 'warn' as const },
{ component: 'LLM Q&A 서버 (H200)', uptime: '99.76%', rto: '≤ 120초', rpo: '0 (stateless)', lastIncident: '2026-04-09', status: 'good' as const },
];
// ─── PER-06 확장성·자원 사용률 ──────────────────
const RESOURCE_USAGE = [
{ resource: '워커 노드 CPU', current: 48, threshold: 70, max: 80, scalePolicy: 'HPA 자동 확장', unit: '%' },
{ resource: '워커 노드 메모리', current: 52, threshold: 75, max: 85, scalePolicy: 'HPA 자동 확장', unit: '%' },
{ resource: 'AI 서버 GPU (RTX pro 6000)', current: 61, threshold: 80, max: 90, scalePolicy: '추론 큐잉', unit: '%' },
{ resource: 'LLM 서버 GPU (H200)', current: 44, threshold: 75, max: 85, scalePolicy: '요청 병합·배치', unit: '%' },
{ resource: 'DB 연결 풀', current: 128, threshold: 300, max: 400, scalePolicy: 'PgBouncer 풀 확대', unit: '개' },
{ resource: 'NAS 사용량', current: 28, threshold: 75, max: 90, scalePolicy: '콜드 티어 이관', unit: '% (100TB)' },
{ resource: 'Kafka 컨슈머 Lag', current: 142, threshold: 5000, max: 10000, scalePolicy: '파티션 증설', unit: 'msg' },
{ resource: 'Redis 캐시 메모리', current: 38, threshold: 70, max: 85, scalePolicy: 'Eviction + 클러스터 확장', unit: '%' },
];
// ─── 성능 영향 최소화 전략 (S&P 글로벌 대응) ──────────────────
const IMPACT_REDUCTION = [
{ strategy: '이중 수집 파이프라인 물리 분리', target: '국내 vs 글로벌 격리', effect: '글로벌 장애 → 국내 무영향', per: 'PER-01·05' },
{ strategy: '경계 조기 필터링', target: '지리·선박 클래스 필터', effect: '원본 50~80% 감축', per: 'PER-03' },
{ strategy: '스트림·백프레셔 (Kafka)', target: 'Lag 임계 초과 시 다운샘플링', effect: '온라인 무영향', per: 'PER-01·03' },
{ strategy: '티어드 스토리지 (Hot/Warm/Cold)', target: '1~7일 / 30일 / 이후', effect: '쿼리 비용 최소화', per: 'PER-03·06' },
{ strategy: '공간 사전 집계 (H3 격자)', target: 'Materialized View', effect: '대시보드 Redis만 조회', per: 'PER-01' },
{ strategy: 'Circuit Breaker (S&P)', target: '실패율 50% 차단', effect: '국내 신호 Fallback', per: 'PER-05' },
{ strategy: 'K8s PriorityClass 격리', target: '온라인 vs 배치', effect: '상황실 SLO 절대 보장', per: 'PER-01·03' },
{ strategy: 'HPA 자동 확장', target: 'CPU/메모리 70% 임계', effect: '피크 자동 대응', per: 'PER-02·06' },
];
// 로컬 status 문자열을 카탈로그 PerformanceStatus로 매핑
const toStatus = (s: 'good' | 'warn' | 'critical' | 'success'): PerformanceStatus => {
if (s === 'good' || s === 'success') return 'good';
if (s === 'warn') return 'warning';
return 'critical';
};
const statusIntent = (s: 'good' | 'warn' | 'critical' | 'success') =>
getPerformanceStatusIntent(toStatus(s));
const barColor = (ratio: number): string =>
getPerformanceStatusHex(utilizationStatus(ratio));
export function PerformanceMonitoring() {
const [tab, setTab] = useState<Tab>('overview');
const { hasPermission } = useAuth();
// 향후 Phase 3 에서 EXPORT 버튼 추가 시 disabled={!canExport} 로 연결
void hasPermission('admin:performance-monitoring', 'EXPORT');
const TABS: Array<{ key: Tab; icon: typeof BarChart3; label: string }> = [
{ key: 'overview', icon: BarChart3, label: '성능 현황' },
{ key: 'response', icon: Gauge, label: '응답성 (PER-01)' },
{ key: 'capacity', icon: Users, label: '처리용량 (PER-02·03)' },
{ key: 'aiModel', icon: Brain, label: 'AI 모델 (PER-04)' },
{ key: 'availability', icon: Shield, label: '가용성·확장성 (PER-05·06)' },
];
return (
<PageContainer>
<PageHeader
icon={Activity}
iconColor="text-cyan-600 dark:text-cyan-400"
title="성능 모니터링"
description="PER-01~06 | 응답성·처리용량·AI 모델·가용성·확장성 실시간 현황"
demo
/>
<TabBar variant="underline">
{TABS.map(t => (
<TabButton key={t.key} variant="underline" active={tab === t.key}
icon={<t.icon className="w-3.5 h-3.5" />} onClick={() => setTab(t.key)}>
{t.label}
</TabButton>
))}
</TabBar>
{/* ── ① 성능 현황 ── */}
{tab === 'overview' && (
<div className="space-y-3">
{/* KPI */}
<div className="grid grid-cols-6 gap-2">
{PERF_KPI.map(k => {
const hex = getPerformanceStatusHex(k.status);
return (
<div key={k.label} className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border bg-card" style={{ borderLeftColor: hex, borderLeftWidth: 3 }}>
<k.icon className="w-5 h-5" style={{ color: hex }} />
<div>
<div className="text-lg font-bold" style={{ color: hex }}>
{k.value}<span className="text-[10px] ml-1 text-hint">{k.unit}</span>
</div>
<div className="text-[9px] text-hint">{k.label}</div>
</div>
</div>
);
})}
</div>
{/* 사용자 그룹별 SLO */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Users className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
<span className="text-[12px] font-bold text-heading"> SLO ( 2,900 + )</span>
<Badge intent="info" size="xs"> 200 · 100 </Badge>
</div>
<table className="w-full text-[11px]">
<thead className="text-hint text-[10px] border-b border-border">
<tr>
<th className="text-left py-2 px-2 font-medium"></th>
<th className="text-right py-2 px-2 font-medium"> </th>
<th className="text-right py-2 px-2 font-medium"> </th>
<th className="text-left py-2 px-2 font-medium">SLA</th>
<th className="text-center py-2 px-2 font-medium"></th>
<th className="text-left py-2 px-2 font-medium"></th>
</tr>
</thead>
<tbody>
{USER_GROUPS.map(g => (
<tr key={g.group} className="border-b border-border/40 hover:bg-surface-overlay/40">
<td className="py-2 px-2 text-label font-medium">{g.group}</td>
<td className="py-2 px-2 text-right text-label">{g.users.toLocaleString()}</td>
<td className="py-2 px-2 text-right text-heading font-medium">{g.concurrent}</td>
<td className="py-2 px-2 text-label">{g.sla}</td>
<td className="py-2 px-2 text-center"><Badge intent={g.priority} size="xs">{g.priority === 'critical' ? '최상' : g.priority === 'high' ? '높음' : '일반'}</Badge></td>
<td className="py-2 px-2 text-hint">{g.note}</td>
</tr>
))}
</tbody>
</table>
</CardContent></Card>
{/* 성능 영향 최소화 전략 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Zap className="w-4 h-4 text-amber-600 dark:text-amber-400" />
<span className="text-[12px] font-bold text-heading"> ( AIS )</span>
</div>
<div className="grid grid-cols-2 gap-2">
{IMPACT_REDUCTION.map((s, i) => (
<div key={s.strategy} className="flex items-start gap-2 px-3 py-2 bg-surface-overlay rounded-lg">
<div className="w-5 h-5 rounded-full bg-amber-500/20 flex items-center justify-center shrink-0 text-[9px] font-bold text-amber-600 dark:text-amber-400">{i + 1}</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-[11px] text-heading font-medium">{s.strategy}</span>
<Badge intent="info" size="xs">{s.per}</Badge>
</div>
<div className="text-[9px] text-hint mb-0.5">: {s.target}</div>
<div className="text-[9px] text-green-600 dark:text-green-400">: {s.effect}</div>
</div>
</div>
))}
</div>
</CardContent></Card>
</div>
)}
{/* ── ② 응답성 (PER-01) ── */}
{tab === 'response' && (
<div className="space-y-3">
<Card><CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Gauge className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
<span className="text-[12px] font-bold text-heading">PER-01 SLO vs (p50/p95/p99)</span>
</div>
<Badge intent="success" size="sm">TER-03 </Badge>
</div>
<table className="w-full text-[11px]">
<thead className="text-hint text-[10px] border-b border-border">
<tr>
<th className="text-left py-2 px-2 font-medium"></th>
<th className="text-right py-2 px-2 font-medium">SLO </th>
<th className="text-right py-2 px-2 font-medium">p50</th>
<th className="text-right py-2 px-2 font-medium">p95</th>
<th className="text-right py-2 px-2 font-medium">p99</th>
<th className="text-center py-2 px-2 font-medium"></th>
</tr>
</thead>
<tbody>
{RESPONSE_SLO.map(r => (
<tr key={r.target} className="border-b border-border/40 hover:bg-surface-overlay/40">
<td className="py-2 px-2 text-label font-medium">{r.target}</td>
<td className="py-2 px-2 text-right text-cyan-600 dark:text-cyan-400 font-medium">{r.slo}</td>
<td className="py-2 px-2 text-right text-hint">{r.p50}</td>
<td className="py-2 px-2 text-right text-heading font-medium">{r.p95}</td>
<td className="py-2 px-2 text-right text-label">{r.p99}</td>
<td className="py-2 px-2 text-center"><Badge intent={statusIntent(r.status)} size="xs">{r.status === 'good' ? '정상' : '주의'}</Badge></td>
</tr>
))}
</tbody>
</table>
</CardContent></Card>
<div className="grid grid-cols-2 gap-3">
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Shield className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<span className="text-[12px] font-bold text-heading"> SLO (24/7 100)</span>
</div>
<div className="space-y-2">
{[
{ item: '메인 대시보드 초기 로드', target: '≤ 2초', current: '1.8초', met: true },
{ item: '위험도 지도 실시간 갱신', target: '≤ 1초', current: '0.7초', met: true },
{ item: 'AI 탐지 알림 수신 → 표출', target: '≤ 3초 E2E', current: '2.3초', met: true },
{ item: '단속 계획·경로 조회', target: '≤ 1.5초', current: '1.1초', met: true },
{ item: '장애 시 세션 유지', target: '< 30초 복구', current: '18초', met: true },
].map(s => (
<div key={s.item} className="flex items-center justify-between px-3 py-2 bg-surface-overlay rounded-lg">
<div>
<div className="text-[11px] text-heading font-medium">{s.item}</div>
<div className="text-[9px] text-hint">: {s.target}</div>
</div>
<div className="flex items-center gap-2">
<span className="text-[11px] text-green-600 dark:text-green-400 font-bold">{s.current}</span>
{s.met ? <CheckCircle className="w-4 h-4 text-green-600 dark:text-green-500" /> : <AlertTriangle className="w-4 h-4 text-amber-600 dark:text-amber-500" />}
</div>
</div>
))}
</div>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Clock className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<span className="text-[12px] font-bold text-heading"> </span>
</div>
<ul className="space-y-2 text-[11px]">
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading">:</strong> <span className="text-label">1 p50/p95/p99 </span></div>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading">:</strong> <span className="text-label">OpenTelemetry + Prometheus + Grafana</span></div>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading">APM:</strong> <span className="text-label"> + Trace ID </span></div>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading">API :</strong> <span className="text-label">3 · Exponential Backoff · 3</span></div>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading">:</strong> <span className="text-label">SLO 5 PagerDuty</span></div>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading"> :</strong> <span className="text-label">RED/USE + ·· </span></div>
</li>
</ul>
</CardContent></Card>
</div>
</div>
)}
{/* ── ③ 처리용량 (PER-02·03) ── */}
{tab === 'capacity' && (
<div className="space-y-3">
{/* 동시접속·TPS */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Users className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<span className="text-[12px] font-bold text-heading">PER-02 · ( 600 / 900)</span>
</div>
<div className="grid grid-cols-4 gap-3">
{CAPACITY_METRICS.map(c => (
<div key={c.metric} className="px-3 py-3 bg-surface-overlay rounded-lg">
<div className="text-[10px] text-hint mb-1">{c.metric}</div>
<div className="text-xl font-bold text-heading mb-1">{c.current.toLocaleString()}</div>
<div className="text-[9px] text-label mb-2"> {c.target} / {c.max}</div>
<div className="h-1.5 bg-surface-raised rounded-full overflow-hidden">
<div className="h-full rounded-full transition-all" style={{ width: `${c.utilization}%`, backgroundColor: barColor(c.utilization / 100) }} />
</div>
<div className="text-[9px] text-hint mt-1">{c.utilization}% </div>
</div>
))}
</div>
</CardContent></Card>
{/* 배치 작업 현황 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Database className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<span className="text-[12px] font-bold text-heading">PER-03 · </span>
<Badge intent="success" size="xs">SLA 6/7</Badge>
</div>
<table className="w-full text-[11px]">
<thead className="text-hint text-[10px] border-b border-border">
<tr>
<th className="text-left py-2 px-2 font-medium"> </th>
<th className="text-left py-2 px-2 font-medium"></th>
<th className="text-right py-2 px-2 font-medium"> </th>
<th className="text-right py-2 px-2 font-medium">SLA</th>
<th className="text-right py-2 px-2 font-medium"> </th>
<th className="text-center py-2 px-2 font-medium"> </th>
</tr>
</thead>
<tbody>
{BATCH_JOBS.map(j => (
<tr key={j.name} className="border-b border-border/40 hover:bg-surface-overlay/40">
<td className="py-2 px-2 text-label font-medium">{j.name}</td>
<td className="py-2 px-2 text-hint">{j.schedule}</td>
<td className="py-2 px-2 text-right text-label">{j.volume}</td>
<td className="py-2 px-2 text-right text-cyan-600 dark:text-cyan-400">{j.sla}</td>
<td className="py-2 px-2 text-right text-heading font-medium">{j.avg}</td>
<td className="py-2 px-2 text-center"><Badge intent={statusIntent(j.status)} size="xs">{j.lastRun}</Badge></td>
</tr>
))}
</tbody>
</table>
</CardContent></Card>
{/* 처리 볼륨 산정 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<HardDrive className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
<span className="text-[12px] font-bold text-heading"> ( + S&P )</span>
</div>
<div className="grid grid-cols-3 gap-3 text-[11px]">
<div className="px-3 py-3 bg-surface-overlay rounded-lg">
<div className="text-[10px] text-hint mb-1"> ()</div>
<div className="text-xl font-bold text-heading">1.6 ~ 3.2 TB</div>
<div className="text-[9px] text-label mt-1">AIS() + S&P A/B + + </div>
</div>
<div className="px-3 py-3 bg-surface-overlay rounded-lg">
<div className="text-[10px] text-hint mb-1"> (· )</div>
<div className="text-xl font-bold text-heading">330 ~ 900 GB</div>
<div className="text-[9px] text-green-600 dark:text-green-400 mt-1"> 50~80% </div>
</div>
<div className="px-3 py-3 bg-surface-overlay rounded-lg">
<div className="text-[10px] text-hint mb-1">3 ()</div>
<div className="text-xl font-bold text-heading">~360 TB ~ 1 PB</div>
<div className="text-[9px] text-amber-600 dark:text-amber-400 mt-1">NAS 100TB </div>
</div>
</div>
</CardContent></Card>
</div>
)}
{/* ── ④ AI 모델 성능 (PER-04) ── */}
{tab === 'aiModel' && (
<div className="space-y-3">
<Card><CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Brain className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<span className="text-[12px] font-bold text-heading">PER-04 AI </span>
</div>
<div className="flex items-center gap-2">
<Badge intent="success" size="xs">6 </Badge>
<Badge intent="warning" size="xs">· </Badge>
</div>
</div>
<table className="w-full text-[11px]">
<thead className="text-hint text-[10px] border-b border-border">
<tr>
<th className="text-left py-2 px-2 font-medium"></th>
<th className="text-right py-2 px-2 font-medium"></th>
<th className="text-right py-2 px-2 font-medium"></th>
<th className="text-right py-2 px-2 font-medium"></th>
<th className="text-right py-2 px-2 font-medium">F1</th>
<th className="text-right py-2 px-2 font-medium">ROC-AUC</th>
<th className="text-left py-2 px-2 font-medium"></th>
<th className="text-center py-2 px-2 font-medium"></th>
</tr>
</thead>
<tbody>
{AI_MODELS.map(m => (
<tr key={m.model} className="border-b border-border/40 hover:bg-surface-overlay/40">
<td className="py-2 px-2 text-label font-medium">{m.model}</td>
<td className="py-2 px-2 text-right text-heading font-medium">{m.accuracy}%</td>
<td className="py-2 px-2 text-right text-label">{m.precision}%</td>
<td className="py-2 px-2 text-right text-label">{m.recall}%</td>
<td className="py-2 px-2 text-right text-label">{m.f1}</td>
<td className="py-2 px-2 text-right text-cyan-600 dark:text-cyan-400">{m.rocAuc}</td>
<td className="py-2 px-2 text-hint text-[10px]">{m.target}</td>
<td className="py-2 px-2 text-center"><Badge intent={statusIntent(m.status)} size="xs">{m.status === 'good' ? '통과' : '주의'}</Badge></td>
</tr>
))}
</tbody>
</table>
</CardContent></Card>
<div className="grid grid-cols-2 gap-3">
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<TrendingUp className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-[12px] font-bold text-heading"> </span>
</div>
<ul className="space-y-2 text-[11px]">
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading">// :</strong> <span className="text-label">70/15/15 , K-Fold 5</span></div>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading"> :</strong> <span className="text-label"> KL divergence </span></div>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading"> :</strong> <span className="text-label">F1 3%p </span></div>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading">:</strong> <span className="text-label">Feature Importance + SHAP </span></div>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading">A/B :</strong> <span className="text-label">Shadow Canary 5% 50% 100% </span></div>
</li>
</ul>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Cpu className="w-4 h-4 text-amber-600 dark:text-amber-400" />
<span className="text-[12px] font-bold text-heading"> (GPU )</span>
</div>
<div className="space-y-3">
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[11px] text-label">AI (RTX pro 6000 Blackwell ×2)</span>
<span className="text-[11px] text-heading font-bold">61%</span>
</div>
<div className="h-2 bg-surface-raised rounded-full overflow-hidden">
<div className="h-full bg-amber-500 rounded-full" style={{ width: '61%' }} />
</div>
<div className="text-[9px] text-hint mt-1"> 1.4 · &lt;200ms</div>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[11px] text-label">LLM (H200 ×2)</span>
<span className="text-[11px] text-heading font-bold">44%</span>
</div>
<div className="h-2 bg-surface-raised rounded-full overflow-hidden">
<div className="h-full bg-green-500 rounded-full" style={{ width: '44%' }} />
</div>
<div className="text-[9px] text-hint mt-1">Q&A 380ms</div>
</div>
<div className="pt-2 border-t border-border/40">
<div className="text-[10px] text-hint mb-1"> </div>
<div className="flex items-center gap-2">
<Badge intent="info" size="xs"> 100 /</Badge>
<Badge intent="success" size="xs"> &lt;1%</Badge>
</div>
</div>
</div>
</CardContent></Card>
</div>
</div>
)}
{/* ── ⑤ 가용성·확장성 (PER-05·06) ── */}
{tab === 'availability' && (
<div className="space-y-3">
{/* 가용성 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Shield className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
<span className="text-[12px] font-bold text-heading">PER-05 ( 99.9%)</span>
</div>
<table className="w-full text-[11px]">
<thead className="text-hint text-[10px] border-b border-border">
<tr>
<th className="text-left py-2 px-2 font-medium"></th>
<th className="text-right py-2 px-2 font-medium"></th>
<th className="text-right py-2 px-2 font-medium">RTO</th>
<th className="text-right py-2 px-2 font-medium">RPO</th>
<th className="text-left py-2 px-2 font-medium"> </th>
<th className="text-center py-2 px-2 font-medium"></th>
</tr>
</thead>
<tbody>
{AVAILABILITY_METRICS.map(a => (
<tr key={a.component} className="border-b border-border/40 hover:bg-surface-overlay/40">
<td className="py-2 px-2 text-label font-medium">{a.component}</td>
<td className="py-2 px-2 text-right text-heading font-medium">{a.uptime}</td>
<td className="py-2 px-2 text-right text-cyan-600 dark:text-cyan-400">{a.rto}</td>
<td className="py-2 px-2 text-right text-label">{a.rpo}</td>
<td className="py-2 px-2 text-hint">{a.lastIncident}</td>
<td className="py-2 px-2 text-center"><Badge intent={statusIntent(a.status)} size="xs">{a.status === 'good' ? '정상' : '주의'}</Badge></td>
</tr>
))}
</tbody>
</table>
</CardContent></Card>
{/* 확장성 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Server className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<span className="text-[12px] font-bold text-heading">PER-06 </span>
<Badge intent="info" size="xs">2(6,000) </Badge>
</div>
<div className="grid grid-cols-2 gap-3">
{RESOURCE_USAGE.map(r => {
const ratio = r.current / r.max;
return (
<div key={r.resource} className="px-3 py-3 bg-surface-overlay rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] text-label font-medium">{r.resource}</span>
<span className="text-[11px] text-heading font-bold">{r.current}{r.unit === '%' || r.unit.startsWith('%') ? '%' : ' ' + r.unit}</span>
</div>
<div className="h-2 bg-surface-raised rounded-full overflow-hidden mb-2">
<div className="h-full rounded-full transition-all" style={{ width: `${(ratio) * 100}%`, backgroundColor: barColor(ratio) }} />
</div>
<div className="flex items-center justify-between text-[9px]">
<span className="text-hint"> {r.threshold}{r.unit === '%' || r.unit.startsWith('%') ? '%' : ''} · {r.max}{r.unit === '%' || r.unit.startsWith('%') ? '%' : ''}</span>
<Badge intent="muted" size="xs">{r.scalePolicy}</Badge>
</div>
</div>
);
})}
</div>
</CardContent></Card>
{/* 요약 지표 */}
<div className="grid grid-cols-4 gap-3">
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<Wifi className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
<span className="text-[11px] font-bold text-heading"> </span>
</div>
<div className="text-2xl font-bold text-cyan-600 dark:text-cyan-400">99.9%</div>
<div className="text-[9px] text-hint mt-1"> 43</div>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<Clock className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<span className="text-[11px] font-bold text-heading">RTO </span>
</div>
<div className="text-2xl font-bold text-purple-600 dark:text-purple-400"> 60</div>
<div className="text-[9px] text-hint mt-1"> · Self-healing</div>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<Database className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-[11px] font-bold text-heading">RPO </span>
</div>
<div className="text-2xl font-bold text-green-600 dark:text-green-400"> 10</div>
<div className="text-[9px] text-hint mt-1"> + </div>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<TrendingUp className="w-4 h-4 text-amber-600 dark:text-amber-400" />
<span className="text-[11px] font-bold text-heading">Scale-out </span>
</div>
<div className="text-2xl font-bold text-amber-600 dark:text-amber-400">×2</div>
<div className="text-[9px] text-hint mt-1">6,000 </div>
</CardContent></Card>
</div>
</div>
)}
</PageContainer>
);
}

파일 보기

@ -6,7 +6,6 @@ import {
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { Input } from '@shared/components/ui/input';
import {
fetchRoles, fetchPermTree, createRole, deleteRole, updateRolePermissions,
type RoleWithPermissions, type PermTreeNode, type PermEntry,
@ -20,7 +19,6 @@ import { useSettingsStore } from '@stores/settingsStore';
import { getRoleBadgeStyle, ROLE_DEFAULT_PALETTE } from '@shared/constants/userRoles';
import { ColorPicker } from '@shared/components/common/ColorPicker';
import { updateRole as apiUpdateRole } from '@/services/adminApi';
import { useTranslation } from 'react-i18next';
/**
* (wing ).
@ -47,7 +45,6 @@ type DraftPerms = Map<string, 'Y' | 'N' | null>; // null = 명시 권한 제거
function makeKey(rsrcCd: string, operCd: string) { return `${rsrcCd}::${operCd}`; }
export function PermissionsPanel() {
const { t: tc } = useTranslation('common');
const { hasPermission } = useAuth();
const canCreateRole = hasPermission('admin:role-management', 'CREATE');
const canDeleteRole = hasPermission('admin:role-management', 'DELETE');
@ -233,7 +230,7 @@ export function PermissionsPanel() {
await updateRolePermissions(selectedRole.roleSn, changes);
await load(); // 새로 가져와서 동기화
alert(`${tc('success.permissionUpdated')} (${changes.length})`);
alert(`권한 ${changes.length}건 갱신되었습니다.`);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'unknown');
} finally {
@ -250,7 +247,7 @@ export function PermissionsPanel() {
setNewRoleColor(ROLE_DEFAULT_PALETTE[0]);
await load();
} catch (e: unknown) {
alert(tc('error.createFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
alert('생성 실패: ' + (e instanceof Error ? e.message : 'unknown'));
}
};
@ -260,23 +257,23 @@ export function PermissionsPanel() {
await load();
setEditingColor(null);
} catch (e: unknown) {
alert(tc('error.updateFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
alert('색상 변경 실패: ' + (e instanceof Error ? e.message : 'unknown'));
}
};
const handleDeleteRole = async () => {
if (!selectedRole) return;
if (selectedRole.builtinYn === 'Y') {
alert(tc('message.builtinRoleCannotDelete'));
alert('내장 역할은 삭제할 수 없습니다.');
return;
}
if (!confirm(`"${selectedRole.roleNm}" ${tc('dialog.deleteRole')}`)) return;
if (!confirm(`"${selectedRole.roleNm}" 역할을 삭제하시겠습니까?`)) return;
try {
await deleteRole(selectedRole.roleSn);
setSelectedRoleSn(null);
await load();
} catch (e: unknown) {
alert(tc('error.deleteFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
alert('삭제 실패: ' + (e instanceof Error ? e.message : 'unknown'));
}
};
@ -361,17 +358,14 @@ export function PermissionsPanel() {
</p>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={load}
aria-label={tc('aria.refresh')}
icon={<RefreshCw className="w-3.5 h-3.5" />}
/>
<button type="button" onClick={load}
className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
</div>
{error && <div className="text-xs text-heading">{tc('error.errorPrefix', { msg: error })}</div>}
{error && <div className="text-xs text-red-400">: {error}</div>}
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
@ -384,44 +378,28 @@ export function PermissionsPanel() {
<div className="text-xs text-label font-bold"></div>
<div className="flex items-center gap-1">
{canCreateRole && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowCreate(!showCreate)}
aria-label="신규 역할"
title="신규 역할"
icon={<Plus className="w-3.5 h-3.5" />}
/>
<button type="button" onClick={() => setShowCreate(!showCreate)}
className="p-1 text-hint hover:text-green-400" title="신규 역할">
<Plus className="w-3.5 h-3.5" />
</button>
)}
{canDeleteRole && selectedRole && selectedRole.builtinYn !== 'Y' && (
<Button
variant="ghost"
size="sm"
onClick={handleDeleteRole}
aria-label="역할 삭제"
title="역할 삭제"
icon={<Trash2 className="w-3.5 h-3.5" />}
/>
<button type="button" onClick={handleDeleteRole}
className="p-1 text-hint hover:text-red-400" title="역할 삭제">
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{showCreate && (
<div className="mb-2 p-2 bg-surface-overlay rounded space-y-1.5">
<Input
aria-label={tc('aria.roleCode')}
size="sm"
value={newRoleCd}
onChange={(e) => setNewRoleCd(e.target.value.toUpperCase())}
placeholder="ROLE_CD (대문자)"
/>
<Input
aria-label={tc('aria.roleName')}
size="sm"
value={newRoleNm}
onChange={(e) => setNewRoleNm(e.target.value)}
placeholder={tc('aria.roleName')}
/>
<input aria-label="역할 코드" value={newRoleCd} onChange={(e) => setNewRoleCd(e.target.value.toUpperCase())}
placeholder="ROLE_CD (대문자)"
className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" />
<input aria-label="역할 이름" value={newRoleNm} onChange={(e) => setNewRoleNm(e.target.value)}
placeholder="역할 이름"
className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" />
<ColorPicker label="배지 색상" value={newRoleColor} onChange={setNewRoleColor} />
<div className="flex gap-1 pt-1">
<Button variant="primary" size="sm" onClick={handleCreateRole} disabled={!newRoleCd || !newRoleNm} className="flex-1">
@ -464,7 +442,7 @@ export function PermissionsPanel() {
<button
type="button"
onClick={() => setEditingColor(isEditingColor ? null : String(r.roleSn))}
className="text-[8px] text-hint hover:text-label"
className="text-[8px] text-hint hover:text-blue-400"
title="색상 변경"
>
@ -501,9 +479,9 @@ export function PermissionsPanel() {
{selectedRole ? `${selectedRole.roleNm} (${selectedRole.roleCd})` : '역할 선택'}
</div>
<div className="text-[10px] text-hint mt-0.5">
: <span className="text-label"> </span> /
: <span className="text-blue-400"> </span> /
<span className="text-blue-300/80 ml-1"> </span> /
<span className="text-heading ml-1"> </span> /
<span className="text-red-400 ml-1"> </span> /
<span className="text-gray-500 ml-1">× </span> /
<span className="text-hint ml-1">· </span>
</div>

파일 보기

@ -6,9 +6,9 @@ import { Button } from '@shared/components/ui/button';
import { PageContainer, PageHeader } from '@shared/components/layout';
import type { BadgeIntent } from '@lib/theme/variants';
import {
Settings, Database, Search,
Settings, Database, Search, ChevronDown, ChevronRight,
Map, Fish, Anchor, Ship, Globe, BarChart3, Download,
Filter, RefreshCw, Hash, Info,
Filter, RefreshCw, BookOpen, Layers, Hash, Info,
} from 'lucide-react';
import {
AREA_CODES, SPECIES_CODES, FISHERY_CODES, VESSEL_TYPE_CODES,
@ -77,7 +77,6 @@ const SYSTEM_SETTINGS = {
export function SystemConfig() {
const { t } = useTranslation('admin');
const { t: tc } = useTranslation('common');
const [tab, setTab] = useState<CodeTab>('areas');
const [query, setQuery] = useState('');
const [majorFilter, setMajorFilter] = useState('');
@ -150,7 +149,7 @@ export function SystemConfig() {
<PageContainer>
<PageHeader
icon={Database}
iconColor="text-label"
iconColor="text-cyan-400"
title={t('systemConfig.title')}
description={t('systemConfig.desc')}
demo
@ -169,11 +168,11 @@ export function SystemConfig() {
{/* KPI 카드 */}
<div className="grid grid-cols-5 gap-3">
{[
{ icon: Map, label: '해역분류', count: CODE_STATS.areas, color: 'text-label', bg: 'bg-blue-500/10', desc: '해양경찰청 관할 기준' },
{ icon: Fish, label: '어종 코드', count: CODE_STATS.species, color: 'text-label', bg: 'bg-green-500/10', desc: '국립수산과학원 기준' },
{ icon: Anchor, label: '어업유형', count: CODE_STATS.fishery, color: 'text-heading', bg: 'bg-purple-500/10', desc: '수산업법 허가·면허' },
{ icon: Ship, label: '선박유형', count: CODE_STATS.vesselTypes, color: 'text-label', bg: 'bg-orange-500/10', desc: 'MDA 5개출처 통합' },
{ icon: Globe, label: '전체 코드', count: CODE_STATS.total, color: 'text-label', bg: 'bg-cyan-500/10', desc: '공통코드 총계' },
{ icon: Map, label: '해역분류', count: CODE_STATS.areas, color: 'text-blue-400', bg: 'bg-blue-500/10', desc: '해양경찰청 관할 기준' },
{ icon: Fish, label: '어종 코드', count: CODE_STATS.species, color: 'text-green-400', bg: 'bg-green-500/10', desc: '국립수산과학원 기준' },
{ icon: Anchor, label: '어업유형', count: CODE_STATS.fishery, color: 'text-purple-400', bg: 'bg-purple-500/10', desc: '수산업법 허가·면허' },
{ icon: Ship, label: '선박유형', count: CODE_STATS.vesselTypes, color: 'text-orange-400', bg: 'bg-orange-500/10', desc: 'MDA 5개출처 통합' },
{ icon: Globe, label: '전체 코드', count: CODE_STATS.total, color: 'text-cyan-400', bg: 'bg-cyan-500/10', desc: '공통코드 총계' },
].map((kpi) => (
<Card key={kpi.label} className="bg-surface-raised border-border">
<CardContent className="p-3">
@ -219,7 +218,7 @@ export function SystemConfig() {
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
<input
aria-label={tc('aria.searchCode')}
aria-label="코드 검색"
value={query}
onChange={(e) => { setQuery(e.target.value); setPage(0); }}
placeholder={
@ -234,7 +233,7 @@ export function SystemConfig() {
<div className="flex items-center gap-1.5">
<Filter className="w-3.5 h-3.5 text-hint" />
<select
aria-label={tc('aria.categoryFilter')}
aria-label="대분류 필터"
value={majorFilter}
onChange={(e) => { setMajorFilter(e.target.value); setPage(0); }}
className="bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-[11px] text-label focus:outline-none focus:border-cyan-500/50"
@ -271,7 +270,7 @@ export function SystemConfig() {
<tbody>
{(pagedData as AreaCode[]).map((a) => (
<tr key={a.code} className="border-b border-border hover:bg-surface-overlay">
<td className="px-4 py-2 text-label font-mono font-medium">{a.code}</td>
<td className="px-4 py-2 text-cyan-400 font-mono font-medium">{a.code}</td>
<td className="px-4 py-2">
{(() => {
const intent: BadgeIntent = a.major === '서해' ? 'info' : a.major === '남해' ? 'success' : a.major === '동해' ? 'purple' : a.major === '제주' ? 'high' : 'cyan';
@ -314,7 +313,7 @@ export function SystemConfig() {
className="border-b border-border hover:bg-surface-overlay cursor-pointer"
onClick={() => setExpandedRow(expandedRow === s.code ? null : s.code)}
>
<td className="px-4 py-2 text-label font-mono font-medium">{s.code}</td>
<td className="px-4 py-2 text-cyan-400 font-mono font-medium">{s.code}</td>
<td className="px-4 py-2">
<Badge intent="muted" size="sm">{s.major}</Badge>
</td>
@ -324,12 +323,12 @@ export function SystemConfig() {
<td className="px-4 py-2 text-muted-foreground text-[10px]">{s.area}</td>
<td className="px-4 py-2 text-center">
{s.active
? <span className="text-label text-[9px]">Y</span>
? <span className="text-green-400 text-[9px]">Y</span>
: <span className="text-hint text-[9px]">N</span>
}
</td>
<td className="px-4 py-2 text-center">
{s.fishing && <span className="text-label"></span>}
{s.fishing && <span className="text-yellow-400"></span>}
</td>
</tr>
))}
@ -358,7 +357,7 @@ export function SystemConfig() {
<tbody>
{(pagedData as FisheryCode[]).map((f) => (
<tr key={f.code} className="border-b border-border hover:bg-surface-overlay">
<td className="px-4 py-2 text-label font-mono font-medium">{f.code}</td>
<td className="px-4 py-2 text-cyan-400 font-mono font-medium">{f.code}</td>
<td className="px-4 py-2">
{(() => {
const intent: BadgeIntent = f.major === '근해어업' ? 'info' : f.major === '연안어업' ? 'success' : f.major === '양식어업' ? 'cyan' : f.major === '원양어업' ? 'purple' : f.major === '구획어업' ? 'high' : f.major === '마을어업' ? 'warning' : 'muted';
@ -400,7 +399,7 @@ export function SystemConfig() {
<tbody>
{(pagedData as VesselTypeCode[]).map((v) => (
<tr key={v.code} className="border-b border-border hover:bg-surface-overlay">
<td className="px-3 py-2 text-label font-mono font-medium">{v.code}</td>
<td className="px-3 py-2 text-cyan-400 font-mono font-medium">{v.code}</td>
<td className="px-3 py-2">
{(() => {
const intent: BadgeIntent = v.major === '어선' ? 'info' : v.major === '여객선' ? 'success' : v.major === '화물선' ? 'high' : v.major === '유조선' ? 'critical' : v.major === '관공선' ? 'purple' : v.major === '함정' ? 'cyan' : 'muted';
@ -410,7 +409,13 @@ export function SystemConfig() {
<td className="px-3 py-2 text-label text-[10px]">{v.mid}</td>
<td className="px-3 py-2 text-heading font-medium">{v.name}</td>
<td className="px-3 py-2">
<span className="text-[9px] font-mono text-label">{v.source}</span>
<span className={`text-[9px] font-mono ${
v.source === 'AIS' ? 'text-cyan-400'
: v.source === 'GIC' ? 'text-green-400'
: v.source === 'RRA' ? 'text-blue-400'
: v.source === 'PMS' ? 'text-orange-400'
: 'text-muted-foreground'
}`}>{v.source}</span>
</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{v.tonnage}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{v.purpose}</td>
@ -426,25 +431,23 @@ export function SystemConfig() {
{/* 페이지네이션 (코드 탭에서만) */}
{tab !== 'settings' && totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<Button
variant="secondary"
size="sm"
<button type="button"
onClick={() => setPage(Math.max(0, page - 1))}
disabled={page === 0}
className="px-3 py-1.5 rounded-lg text-[11px] bg-surface-overlay border border-border text-muted-foreground hover:text-heading disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
</Button>
</button>
<span className="text-[11px] text-hint">
{page + 1} / {totalPages} ({totalItems.toLocaleString()})
</span>
<Button
variant="secondary"
size="sm"
<button type="button"
onClick={() => setPage(Math.min(totalPages - 1, page + 1))}
disabled={page >= totalPages - 1}
className="px-3 py-1.5 rounded-lg text-[11px] bg-surface-overlay border border-border text-muted-foreground hover:text-heading disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
</Button>
</button>
</div>
)}
@ -463,7 +466,7 @@ export function SystemConfig() {
<Card key={section} className="bg-surface-raised border-border">
<CardHeader className="px-4 pt-3 pb-2">
<CardTitle className="text-xs text-label flex items-center gap-1.5">
<meta.icon className="w-3.5 h-3.5 text-label" />
<meta.icon className="w-3.5 h-3.5 text-cyan-400" />
{meta.title}
</CardTitle>
</CardHeader>
@ -471,7 +474,9 @@ export function SystemConfig() {
{items.map((item) => (
<div key={item.key} className="flex justify-between items-center text-[11px]">
<span className="text-hint">{item.label}</span>
<span className="font-medium text-label">{item.value}</span>
<span className={`font-medium ${
item.value === '활성' ? 'text-green-400' : 'text-label'
}`}>{item.value}</span>
</div>
))}
</CardContent>

파일 보기

@ -1,8 +1,6 @@
import { useEffect, useState } from 'react';
import { X, Check, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { fetchRoles, assignUserRoles, type RoleWithPermissions, type AdminUser } from '@/services/adminApi';
import { getRoleBadgeStyle } from '@shared/constants/userRoles';
@ -13,7 +11,6 @@ interface Props {
}
export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) {
const { t: tc } = useTranslation('common');
const [roles, setRoles] = useState<RoleWithPermissions[]>([]);
const [selected, setSelected] = useState<Set<number>>(new Set());
const [loading, setLoading] = useState(true);
@ -47,7 +44,7 @@ export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) {
onSaved();
onClose();
} catch (e: unknown) {
alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
} finally {
setSaving(false);
}
@ -63,7 +60,7 @@ export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) {
{user.userAcnt} ({user.userNm}) - (OR )
</div>
</div>
<button type="button" aria-label={tc('aria.closeDialog')} onClick={onClose} className="text-hint hover:text-heading">
<button type="button" aria-label="닫기" onClick={onClose} className="text-hint hover:text-heading">
<X className="w-4 h-4" />
</button>
</div>
@ -102,18 +99,15 @@ export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) {
</div>
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-border">
<Button variant="secondary" size="sm" onClick={onClose}>
<button type="button" onClick={onClose}
className="px-4 py-1.5 bg-surface-overlay text-muted-foreground text-xs rounded hover:text-heading">
</Button>
<Button
variant="primary"
size="sm"
onClick={handleSave}
disabled={saving}
icon={saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
>
</button>
<button type="button" onClick={handleSave} disabled={saving}
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/40 text-white text-xs rounded flex items-center gap-1">
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
</Button>
</button>
</div>
</div>
</div>

파일 보기

@ -7,4 +7,3 @@ export { AISecurityPage } from './AISecurityPage';
export { AIAgentSecurityPage } from './AIAgentSecurityPage';
export { DataRetentionPolicy } from './DataRetentionPolicy';
export { DataModelVerification } from './DataModelVerification';
export { PerformanceMonitoring } from './PerformanceMonitoring';

파일 보기

@ -1,7 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@shared/components/ui/button';
import { Input } from '@shared/components/ui/input';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
@ -46,7 +44,6 @@ const INITIAL_MESSAGES: Message[] = [
export function AIAssistant() {
const { t } = useTranslation('ai');
const { t: tc } = useTranslation('common');
const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES);
const [input, setInput] = useState('');
const [selectedConv, setSelectedConv] = useState('1');
@ -82,7 +79,7 @@ export function AIAssistant() {
<PageContainer className="h-full flex flex-col">
<PageHeader
icon={MessageSquare}
iconColor="text-green-600 dark:text-green-400"
iconColor="text-green-400"
title={t('assistant.title')}
description={t('assistant.desc')}
/>
@ -94,7 +91,7 @@ export function AIAssistant() {
<div className="space-y-1">
{SAMPLE_CONVERSATIONS.map(c => (
<div key={c.id} onClick={() => setSelectedConv(c.id)}
className={`px-2 py-1.5 rounded-lg cursor-pointer text-[10px] ${selectedConv === c.id ? 'bg-green-600/15 text-green-600 dark:text-green-400 border border-green-500/20' : 'text-muted-foreground hover:bg-surface-overlay'}`}>
className={`px-2 py-1.5 rounded-lg cursor-pointer text-[10px] ${selectedConv === c.id ? 'bg-green-600/15 text-green-400 border border-green-500/20' : 'text-muted-foreground hover:bg-surface-overlay'}`}>
<div className="truncate">{c.title}</div>
<div className="text-[8px] text-hint mt-0.5">{c.time}</div>
</div>
@ -114,7 +111,7 @@ export function AIAssistant() {
<div key={i} className={`flex gap-2 ${msg.role === 'user' ? 'justify-end' : ''}`}>
{msg.role === 'assistant' && (
<div className="w-7 h-7 rounded-full bg-green-600/20 border border-green-500/30 flex items-center justify-center shrink-0">
<Bot className="w-4 h-4 text-green-600 dark:text-green-400" />
<Bot className="w-4 h-4 text-green-400" />
</div>
)}
<div className={`max-w-[70%] rounded-xl px-4 py-3 ${
@ -126,7 +123,7 @@ export function AIAssistant() {
{msg.refs && msg.refs.length > 0 && (
<div className="mt-2 pt-2 border-t border-border flex flex-wrap gap-1">
{msg.refs.map(r => (
<Badge key={r} className="bg-green-500/10 text-green-600 dark:text-green-400 border-0 text-[8px] flex items-center gap-0.5">
<Badge key={r} className="bg-green-500/10 text-green-400 border-0 text-[8px] flex items-center gap-0.5">
<FileText className="w-2.5 h-2.5" />{r}
</Badge>
))}
@ -135,7 +132,7 @@ export function AIAssistant() {
</div>
{msg.role === 'user' && (
<div className="w-7 h-7 rounded-full bg-blue-600/20 border border-blue-500/30 flex items-center justify-center shrink-0">
<User className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<User className="w-4 h-4 text-blue-400" />
</div>
)}
</div>
@ -143,22 +140,17 @@ export function AIAssistant() {
</div>
{/* 입력창 */}
<div className="flex gap-2 shrink-0">
<Input
<input
aria-label="AI 어시스턴트 질의"
size="md"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSend()}
placeholder="질의를 입력하세요... (법령, 단속 절차, AI 분석 결과 등)"
className="flex-1"
/>
<Button
variant="primary"
size="md"
onClick={handleSend}
aria-label={tc('aria.send')}
icon={<Send className="w-4 h-4" />}
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded-xl px-4 py-2.5 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-green-500/50"
/>
<button type="button" aria-label="전송" onClick={handleSend} className="px-4 py-2.5 bg-green-600 hover:bg-green-500 text-on-vivid rounded-xl transition-colors">
<Send className="w-4 h-4" />
</button>
</div>
</div>
</div>

파일 보기

@ -2,7 +2,6 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import {
@ -58,7 +57,7 @@ const MODELS: ModelVersion[] = [
];
const modelColumns: DataColumn<ModelVersion>[] = [
{ key: 'version', label: '버전', width: '80px', sortable: true, render: (v) => <span className="text-cyan-600 dark:text-cyan-400 font-bold">{v as string}</span> },
{ key: 'version', label: '버전', width: '80px', sortable: true, render: (v) => <span className="text-cyan-400 font-bold">{v as string}</span> },
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
render: (v) => {
const s = v as string;
@ -69,7 +68,7 @@ const modelColumns: DataColumn<ModelVersion>[] = [
{ key: 'recall', label: 'Recall', width: '70px', align: 'right', sortable: true, render: (v) => <span className="text-label">{v as number}%</span> },
{ key: 'f1', label: 'F1', width: '70px', align: 'right', sortable: true, render: (v) => <span className="text-label">{v as number}%</span> },
{ key: 'falseAlarm', label: '오탐률', width: '70px', align: 'right', sortable: true,
render: (v) => { const n = v as number; return <span className={n < 10 ? 'text-green-600 dark:text-green-400' : n < 15 ? 'text-yellow-600 dark:text-yellow-400' : 'text-red-600 dark:text-red-400'}>{n}%</span>; },
render: (v) => { const n = v as number; return <span className={n < 10 ? 'text-green-400' : n < 15 ? 'text-yellow-400' : 'text-red-400'}>{n}%</span>; },
},
{ key: 'trainData', label: '학습데이터', width: '100px', align: 'right' },
{ key: 'deployDate', label: '배포일', width: '100px', sortable: true, render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
@ -176,7 +175,7 @@ const GEAR_CODES: GearCode[] = [
];
const gearColumns: DataColumn<GearCode>[] = [
{ key: 'code', label: '코드', width: '60px', render: (v) => <span className="text-cyan-600 dark:text-cyan-400 font-mono font-bold">{v as string}</span> },
{ key: 'code', label: '코드', width: '60px', render: (v) => <span className="text-cyan-400 font-mono font-bold">{v as string}</span> },
{ key: 'name', label: '어구명 (유형)', sortable: true, render: (v) => <span className="text-heading font-medium">{v as string}</span> },
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
render: (v) => {
@ -397,14 +396,14 @@ export function AIModelManagement() {
<PageContainer>
<PageHeader
icon={Brain}
iconColor="text-purple-600 dark:text-purple-400"
iconColor="text-purple-400"
title={t('modelManagement.title')}
description={t('modelManagement.desc')}
demo
actions={
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-green-500/10 border border-green-500/20 rounded-lg">
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
<span className="text-[10px] text-green-600 dark:text-green-400 font-bold"> : {currentModel.version}</span>
<span className="text-[10px] text-green-400 font-bold"> : {currentModel.version}</span>
<span className="text-[10px] text-hint">Accuracy {currentModel.accuracy}%</span>
</div>
}
@ -413,12 +412,12 @@ export function AIModelManagement() {
{/* KPI */}
<div className="flex gap-2">
{[
{ label: '탐지 정확도', value: `${currentModel.accuracy}%`, icon: Target, color: 'text-green-600 dark:text-green-400', bg: 'bg-green-500/10' },
{ label: '오탐률', value: `${currentModel.falseAlarm}%`, icon: AlertTriangle, color: 'text-yellow-600 dark:text-yellow-400', bg: 'bg-yellow-500/10' },
{ label: '평균 리드타임', value: '12min', icon: Clock, color: 'text-cyan-600 dark:text-cyan-400', bg: 'bg-cyan-500/10' },
{ label: '학습 데이터', value: currentModel.trainData, icon: Database, color: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-500/10' },
{ label: '모델 버전', value: MODELS.length + '개', icon: GitBranch, color: 'text-purple-600 dark:text-purple-400', bg: 'bg-purple-500/10' },
{ label: '탐지 규칙', value: rules.filter((r) => r.enabled).length + '/' + rules.length, icon: Shield, color: 'text-orange-600 dark:text-orange-400', bg: 'bg-orange-500/10' },
{ label: '탐지 정확도', value: `${currentModel.accuracy}%`, icon: Target, color: 'text-green-400', bg: 'bg-green-500/10' },
{ label: '오탐률', value: `${currentModel.falseAlarm}%`, icon: AlertTriangle, color: 'text-yellow-400', bg: 'bg-yellow-500/10' },
{ label: '평균 리드타임', value: '12min', icon: Clock, color: 'text-cyan-400', bg: 'bg-cyan-500/10' },
{ label: '학습 데이터', value: currentModel.trainData, icon: Database, color: 'text-blue-400', bg: 'bg-blue-500/10' },
{ label: '모델 버전', value: MODELS.length + '개', icon: GitBranch, color: 'text-purple-400', bg: 'bg-purple-500/10' },
{ label: '탐지 규칙', value: rules.filter((r) => r.enabled).length + '/' + rules.length, icon: Shield, color: 'text-orange-400', bg: 'bg-orange-500/10' },
].map((kpi) => (
<div key={kpi.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
<div className={`p-1.5 rounded-lg ${kpi.bg}`}>
@ -455,13 +454,13 @@ export function AIModelManagement() {
{/* 업데이트 알림 */}
<div className="bg-blue-950/20 border border-blue-900/30 rounded-xl p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<Zap className="w-5 h-5 text-blue-600 dark:text-blue-400 shrink-0" />
<Zap className="w-5 h-5 text-blue-400 shrink-0" />
<div>
<div className="text-sm text-blue-300 font-bold"> v2.4.0 </div>
<div className="text-[10px] text-muted-foreground"> 93.2% (+3.1%) · 7.8% (-2.1%) · </div>
</div>
</div>
<Button variant="primary" size="sm" className="shrink-0"> </Button>
<button type="button" className="bg-blue-600 hover:bg-blue-500 text-on-vivid text-[11px] font-bold px-4 py-2 rounded-lg transition-colors shrink-0"> </button>
</div>
<DataTable data={MODELS} columns={modelColumns} pageSize={10} searchPlaceholder="버전, 비고 검색..." searchKeys={['version', 'note']} exportFilename="AI모델_버전이력" />
</div>
@ -496,7 +495,7 @@ export function AIModelManagement() {
</div>
<div className="text-right">
<div className="text-[9px] text-hint"></div>
<div className="text-[12px] font-bold text-cyan-600 dark:text-cyan-400">{rule.weight}%</div>
<div className="text-[12px] font-bold text-cyan-400">{rule.weight}%</div>
</div>
</div>
</CardContent>
@ -506,7 +505,7 @@ export function AIModelManagement() {
{/* 가중치 합계 */}
<Card>
<CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-4 flex items-center gap-1.5"><Zap className="w-4 h-4 text-yellow-600 dark:text-yellow-400" /> </div>
<div className="text-[12px] font-bold text-label mb-4 flex items-center gap-1.5"><Zap className="w-4 h-4 text-yellow-400" /> </div>
<div className="space-y-4">
{rules.filter((r) => r.enabled).map((r, i) => (
<div key={i}>
@ -565,7 +564,7 @@ export function AIModelManagement() {
{/* 파이프라인 스테이지 */}
<div className="flex gap-2">
{PIPELINE_STAGES.map((stage, i) => {
const stColor = stage.status === '정상' ? 'text-green-600 dark:text-green-400' : stage.status === '진행중' ? 'text-blue-600 dark:text-blue-400' : 'text-hint';
const stColor = stage.status === '정상' ? 'text-green-400' : stage.status === '진행중' ? 'text-blue-400' : 'text-hint';
return (
<div key={stage.stage} className="flex-1 flex items-start gap-2">
<Card className="flex-1 bg-surface-raised border-border">
@ -696,7 +695,7 @@ export function AIModelManagement() {
<div key={kpi.label}>
<div className="flex justify-between text-[10px] mb-1">
<span className="text-muted-foreground">{kpi.label}</span>
<span className={achieved ? 'text-green-600 dark:text-green-400 font-bold' : 'text-red-600 dark:text-red-400 font-bold'}>
<span className={achieved ? 'text-green-400 font-bold' : 'text-red-400 font-bold'}>
{kpi.current}{kpi.unit} {achieved ? '(달성)' : `(목표 ${kpi.target}${kpi.unit})`}
</span>
</div>
@ -762,7 +761,7 @@ export function AIModelManagement() {
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Anchor className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
<Anchor className="w-4 h-4 text-cyan-400" />
5
</CardTitle>
</CardHeader>
@ -782,7 +781,7 @@ export function AIModelManagement() {
{DAR03_GEAR_SUMMARY.map((g) => (
<tr key={g.no} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2 px-2">
<span className="text-cyan-600 dark:text-cyan-400 font-mono mr-2">{g.no}</span>
<span className="text-cyan-400 font-mono mr-2">{g.no}</span>
<span className="text-heading font-medium">{g.name}</span>
</td>
<td className="py-2 text-center text-label font-mono">{g.faoCode}</td>
@ -791,7 +790,7 @@ export function AIModelManagement() {
<Badge intent={DAR03_IUU_INTENT[g.iuuRisk]} size="xs">{g.iuuRisk}</Badge>
</td>
<td className="py-2 text-center text-muted-foreground">{g.aisType}</td>
<td className="py-2 px-2 text-cyan-600 dark:text-cyan-400 font-mono text-[9px]">{g.gCodes}</td>
<td className="py-2 px-2 text-cyan-400 font-mono text-[9px]">{g.gCodes}</td>
</tr>
))}
</tbody>
@ -803,7 +802,7 @@ export function AIModelManagement() {
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Eye className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<Eye className="w-4 h-4 text-blue-400" />
</CardTitle>
<p className="text-[9px] text-hint italic">
@ -816,7 +815,7 @@ export function AIModelManagement() {
<Card key={g.no} className="bg-surface-raised border-border">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<span className="text-cyan-600 dark:text-cyan-400 font-mono font-bold text-sm">{g.no}</span>
<span className="text-cyan-400 font-mono font-bold text-sm">{g.no}</span>
<div className="flex-1">
<div className="text-[12px] font-bold text-heading">{g.name}</div>
<div className="text-[9px] text-hint">{g.nameEn}</div>
@ -855,7 +854,7 @@ export function AIModelManagement() {
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Radio className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<Radio className="w-4 h-4 text-purple-400" />
AIS
</CardTitle>
</CardHeader>
@ -874,7 +873,7 @@ export function AIModelManagement() {
{DAR03_AIS_SIGNALS.map((s) => (
<tr key={s.no} className="border-b border-border/50 hover:bg-surface-overlay transition-colors align-top">
<td className="py-2.5 px-2">
<span className="text-cyan-600 dark:text-cyan-400 font-mono mr-1">{s.no}</span>
<span className="text-cyan-400 font-mono mr-1">{s.no}</span>
<span className="text-heading font-medium">{s.name}</span>
</td>
<td className="py-2.5 text-label">{s.aisType}</td>
@ -892,13 +891,13 @@ export function AIModelManagement() {
<ul className="space-y-0.5">
{s.threshold.map((th) => (
<li key={th} className="text-muted-foreground flex items-start gap-1">
<AlertTriangle className="w-3 h-3 text-orange-600 dark:text-orange-400 shrink-0 mt-0.5" />
<AlertTriangle className="w-3 h-3 text-orange-400 shrink-0 mt-0.5" />
<span>{th}</span>
</li>
))}
</ul>
</td>
<td className="py-2.5 px-2 text-cyan-600 dark:text-cyan-400 font-mono text-[9px]">{s.gCodes}</td>
<td className="py-2.5 px-2 text-cyan-400 font-mono text-[9px]">{s.gCodes}</td>
</tr>
))}
</tbody>
@ -922,8 +921,8 @@ export function AIModelManagement() {
</div>
<div className="ml-auto flex gap-3 shrink-0 text-center">
<div><div className="text-lg font-bold text-heading">906</div><div className="text-[9px] text-hint"> </div></div>
<div><div className="text-lg font-bold text-cyan-600 dark:text-cyan-400">7</div><div className="text-[9px] text-hint"> </div></div>
<div><div className="text-lg font-bold text-green-600 dark:text-green-400">5</div><div className="text-[9px] text-hint"> </div></div>
<div><div className="text-lg font-bold text-cyan-400">7</div><div className="text-[9px] text-hint"> </div></div>
<div><div className="text-lg font-bold text-green-400">5</div><div className="text-[9px] text-hint"> </div></div>
</div>
</div>
@ -975,7 +974,7 @@ export function AIModelManagement() {
<Card>
<CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
<Ship className="w-4 h-4 text-cyan-600 dark:text-cyan-400" /> (906, 6 )
<Ship className="w-4 h-4 text-cyan-400" /> (906, 6 )
</div>
<table className="w-full text-[10px]">
<thead>
@ -992,7 +991,7 @@ export function AIModelManagement() {
<tbody>
{TARGET_VESSELS.map((v) => (
<tr key={v.code} className="border-b border-border">
<td className="py-1.5 text-cyan-600 dark:text-cyan-400 font-mono font-bold">{v.code}</td>
<td className="py-1.5 text-cyan-400 font-mono font-bold">{v.code}</td>
<td className="py-1.5 text-label">{v.name}</td>
<td className="py-1.5 text-heading font-bold text-right">{v.count}</td>
<td className="py-1.5 text-muted-foreground pl-3">{v.zones}</td>
@ -1015,7 +1014,7 @@ export function AIModelManagement() {
<Card>
<CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
<AlertTriangle className="w-4 h-4 text-yellow-600 dark:text-yellow-400" />
<AlertTriangle className="w-4 h-4 text-yellow-400" />
</div>
<div className="space-y-2">
{ALARM_SEVERITY.map((a) => (
@ -1065,8 +1064,8 @@ export function AIModelManagement() {
</div>
<div className="flex gap-4 shrink-0 text-center">
<div><div className="text-lg font-bold text-emerald-400">12</div><div className="text-[9px] text-hint">API </div></div>
<div><div className="text-lg font-bold text-cyan-600 dark:text-cyan-400">3</div><div className="text-[9px] text-hint"> </div></div>
<div><div className="text-lg font-bold text-blue-600 dark:text-blue-400">99.7%</div><div className="text-[9px] text-hint"></div></div>
<div><div className="text-lg font-bold text-cyan-400">3</div><div className="text-[9px] text-hint"> </div></div>
<div><div className="text-lg font-bold text-blue-400">99.7%</div><div className="text-[9px] text-hint"></div></div>
</div>
</div>
@ -1115,7 +1114,7 @@ export function AIModelManagement() {
<Card>
<CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
<Code className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
<Code className="w-4 h-4 text-cyan-400" />
RESTful API
</div>
<table className="w-full text-[10px] table-fixed">
@ -1156,7 +1155,7 @@ export function AIModelManagement() {
<td className="py-1.5">
<Badge intent={api.method === 'GET' ? 'success' : 'info'} size="xs">{api.method}</Badge>
</td>
<td className="py-1.5 font-mono text-cyan-600 dark:text-cyan-400">{api.endpoint}</td>
<td className="py-1.5 font-mono text-cyan-400">{api.endpoint}</td>
<td className="py-1.5 text-hint">{api.unit}</td>
<td className="py-1.5 text-label">{api.desc}</td>
<td className="py-1.5 text-muted-foreground">{api.sfr}</td>
@ -1176,7 +1175,7 @@ export function AIModelManagement() {
<Card>
<CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
<Code className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
<Code className="w-4 h-4 text-cyan-400" />
API
</div>
<div className="space-y-3">
@ -1184,7 +1183,7 @@ export function AIModelManagement() {
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-muted-foreground"> (파라미터: 좌표 , )</span>
<button type="button" aria-label={tcCommon('aria.copyExampleUrl')} onClick={() => navigator.clipboard.writeText('GET /api/v1/predictions/grid?lat_min=36.0&lat_max=38.0&lon_min=124.0&lon_max=126.0&time=2026-04-03T09:00Z')} className="text-hint hover:text-muted-foreground"><Copy className="w-3 h-3" /></button>
<button type="button" aria-label="예시 URL 복사" onClick={() => navigator.clipboard.writeText('GET /api/v1/predictions/grid?lat_min=36.0&lat_max=38.0&lon_min=124.0&lon_max=126.0&time=2026-04-03T09:00Z')} className="text-hint hover:text-muted-foreground"><Copy className="w-3 h-3" /></button>
</div>
<pre className="bg-background border border-border rounded-lg p-3 text-[9px] font-mono text-muted-foreground overflow-x-auto">
{`GET /api/v1/predictions/grid
@ -1232,7 +1231,7 @@ export function AIModelManagement() {
<Card>
<CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
<ExternalLink className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<ExternalLink className="w-4 h-4 text-purple-400" />
</div>
<div className="space-y-2">
@ -1256,7 +1255,7 @@ export function AIModelManagement() {
<div className="text-[9px] text-hint mb-1.5">{s.desc}</div>
<div className="flex gap-1 flex-wrap">
{s.apis.map((a) => (
<span key={a} className="text-[8px] font-mono px-1.5 py-0.5 rounded bg-switch-background/50 text-cyan-600 dark:text-cyan-400">{a}</span>
<span key={a} className="text-[8px] font-mono px-1.5 py-0.5 rounded bg-switch-background/50 text-cyan-400">{a}</span>
))}
</div>
</div>
@ -1273,13 +1272,13 @@ export function AIModelManagement() {
<div className="flex gap-3">
{[
{ label: '총 호출', value: '142,856', color: 'text-heading' },
{ label: 'grid 조회', value: '68,420', color: 'text-blue-600 dark:text-blue-400' },
{ label: 'zone 조회', value: '32,115', color: 'text-green-600 dark:text-green-400' },
{ label: 'time 조회', value: '18,903', color: 'text-yellow-600 dark:text-yellow-400' },
{ label: 'vessel 조회', value: '15,210', color: 'text-orange-600 dark:text-orange-400' },
{ label: 'alarms', value: '8,208', color: 'text-red-600 dark:text-red-400' },
{ label: '평균 응답', value: '23ms', color: 'text-cyan-600 dark:text-cyan-400' },
{ label: '오류율', value: '0.03%', color: 'text-green-600 dark:text-green-400' },
{ label: 'grid 조회', value: '68,420', color: 'text-blue-400' },
{ label: 'zone 조회', value: '32,115', color: 'text-green-400' },
{ label: 'time 조회', value: '18,903', color: 'text-yellow-400' },
{ label: 'vessel 조회', value: '15,210', color: 'text-orange-400' },
{ label: 'alarms', value: '8,208', color: 'text-red-400' },
{ label: '평균 응답', value: '23ms', color: 'text-cyan-400' },
{ label: '오류율', value: '0.03%', color: 'text-green-400' },
].map((s) => (
<div key={s.label} className="flex-1 text-center px-3 py-2 rounded-lg bg-surface-overlay">
<div className={`text-sm font-bold ${s.color}`}>{s.value}</div>

파일 보기

@ -1,6 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@shared/components/ui/button';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
@ -108,7 +107,6 @@ const WORKERS = [
export function MLOpsPage() {
const { t } = useTranslation('ai');
const { t: tc } = useTranslation('common');
const [tab, setTab] = useState<Tab>('dashboard');
const [llmSub, setLlmSub] = useState<LLMSubTab>('train');
const [selectedTmpl, setSelectedTmpl] = useState(0);
@ -118,7 +116,7 @@ export function MLOpsPage() {
<PageContainer>
<PageHeader
icon={Cpu}
iconColor="text-purple-600 dark:text-purple-400"
iconColor="text-purple-400"
title={t('mlops.title')}
description={t('mlops.desc')}
demo
@ -136,7 +134,7 @@ export function MLOpsPage() {
{ key: 'platform' as Tab, icon: Settings, label: '플랫폼 관리' },
]).map(t => (
<button type="button" key={t.key} onClick={() => setTab(t.key)}
className={`flex items-center gap-1.5 px-4 py-2.5 text-[11px] font-medium border-b-2 transition-colors ${tab === t.key ? 'text-blue-600 dark:text-blue-400 border-blue-400' : 'text-hint border-transparent hover:text-label'}`}>
className={`flex items-center gap-1.5 px-4 py-2.5 text-[11px] font-medium border-b-2 transition-colors ${tab === t.key ? 'text-blue-400 border-blue-400' : 'text-hint border-transparent hover:text-label'}`}>
<t.icon className="w-3.5 h-3.5" />{t.label}
</button>
))}
@ -161,7 +159,7 @@ export function MLOpsPage() {
<Badge intent="success" size="sm">DEPLOYED</Badge>
<span className="text-[11px] text-heading font-medium flex-1">{m.name}</span>
<span className="text-[10px] text-hint">{m.ver}</span>
<span className="text-[10px] text-green-600 dark:text-green-400 font-bold">F1 {m.f1}%</span>
<span className="text-[10px] text-green-400 font-bold">F1 {m.f1}%</span>
</div>
))}</div>
</CardContent></Card>
@ -189,7 +187,7 @@ export function MLOpsPage() {
{TEMPLATES.map((t, i) => (
<div key={t.name} onClick={() => setSelectedTmpl(i)}
className={`p-3 rounded-lg text-center cursor-pointer transition-colors ${selectedTmpl === i ? 'bg-blue-600/15 border border-blue-500/30' : 'bg-surface-overlay border border-transparent hover:border-border'}`}>
<t.icon className="w-6 h-6 mx-auto mb-2 text-blue-600 dark:text-blue-400" />
<t.icon className="w-6 h-6 mx-auto mb-2 text-blue-400" />
<div className="text-[10px] font-bold text-heading">{t.name}</div>
<div className="text-[8px] text-hint mt-0.5">{t.desc}</div>
</div>
@ -199,7 +197,7 @@ export function MLOpsPage() {
<Card><CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="text-[12px] font-bold text-heading"> </div>
<Button variant="primary" size="sm" icon={<Play className="w-3 h-3" />}> </Button>
<button type="button" className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg"><Play className="w-3 h-3" /> </button>
</div>
<div className="space-y-2">
{EXPERIMENTS.map(e => (
@ -210,7 +208,7 @@ export function MLOpsPage() {
<div className="w-24 h-1.5 bg-switch-background rounded-full overflow-hidden"><div className={`h-full rounded-full ${e.status === 'done' ? 'bg-green-500' : e.status === 'fail' ? 'bg-red-500' : 'bg-blue-500'}`} style={{ width: `${e.progress}%` }} /></div>
<span className="text-[10px] text-muted-foreground w-12">{e.epoch}</span>
<span className="text-[10px] text-muted-foreground w-16">{e.time}</span>
{e.f1 > 0 && <span className="text-[10px] text-cyan-600 dark:text-cyan-400 font-bold">F1 {e.f1}</span>}
{e.f1 > 0 && <span className="text-[10px] text-cyan-400 font-bold">F1 {e.f1}</span>}
</div>
))}
</div>
@ -263,7 +261,7 @@ export function MLOpsPage() {
<tbody>{DEPLOYS.map((d, i) => (
<tr key={i} className="border-b border-border hover:bg-surface-overlay">
<td className="px-3 py-2 text-heading font-medium">{d.model}</td>
<td className="px-3 py-2 text-cyan-600 dark:text-cyan-400 font-mono">{d.ver}</td>
<td className="px-3 py-2 text-cyan-400 font-mono">{d.ver}</td>
<td className="px-3 py-2 text-muted-foreground font-mono text-[10px]">{d.endpoint}</td>
<td className="px-3 py-2"><div className="flex items-center gap-1.5"><div className="w-16 h-2 bg-switch-background rounded-full overflow-hidden"><div className="h-full bg-blue-500 rounded-full" style={{ width: `${d.traffic}%` }} /></div><span className="text-heading font-bold">{d.traffic}%</span></div></td>
<td className="px-3 py-2 text-label">{d.latency}</td>
@ -290,7 +288,7 @@ export function MLOpsPage() {
{MODELS.filter(m => m.status === 'APPROVED').map(m => (
<div key={m.name} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
<span className="text-[11px] text-heading font-medium flex-1">{m.name} {m.ver}</span>
<Button variant="primary" size="sm" icon={<Rocket className="w-3 h-3" />}></Button>
<button type="button" className="flex items-center gap-1 px-2.5 py-1 bg-green-600 hover:bg-green-500 text-on-vivid text-[9px] font-bold rounded"><Rocket className="w-3 h-3" /></button>
</div>
))}
</div>
@ -315,15 +313,15 @@ export function MLOpsPage() {
"version": "v2.1.0"
}`} />
<div className="flex gap-2 mt-2">
<Button variant="primary" size="sm" icon={<Zap className="w-3 h-3" />}></Button>
<button type="button" className="flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg"><Zap className="w-3 h-3" /></button>
<button type="button" className="px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground"></button>
</div>
</CardContent></Card>
<Card className="bg-surface-raised border-border flex flex-col"><CardContent className="p-4 flex-1 flex flex-col">
<div className="text-[9px] font-bold text-hint mb-2">RESPONSE</div>
<div className="flex gap-4 text-[10px] px-2 py-1.5 bg-green-500/10 rounded mb-2">
<span className="text-muted-foreground"> <span className="text-green-600 dark:text-green-400 font-bold">200 OK</span></span>
<span className="text-muted-foreground"> <span className="text-green-600 dark:text-green-400 font-bold">23ms</span></span>
<span className="text-muted-foreground"> <span className="text-green-400 font-bold">200 OK</span></span>
<span className="text-muted-foreground"> <span className="text-green-400 font-bold">23ms</span></span>
</div>
<pre className="flex-1 bg-background border border-border rounded-lg p-3 text-[10px] text-green-300 font-mono overflow-auto">{`{
"risk_score": 87.5,
@ -355,7 +353,7 @@ export function MLOpsPage() {
{ key: 'llmtest' as LLMSubTab, label: 'LLM 테스트' },
]).map(t => (
<button type="button" key={t.key} onClick={() => setLlmSub(t.key)}
className={`px-4 py-2 text-[11px] font-medium border-b-2 ${llmSub === t.key ? 'text-blue-600 dark:text-blue-400 border-blue-400' : 'text-hint border-transparent hover:text-label'}`}>{t.label}</button>
className={`px-4 py-2 text-[11px] font-medium border-b-2 ${llmSub === t.key ? 'text-blue-400 border-blue-400' : 'text-hint border-transparent hover:text-label'}`}>{t.label}</button>
))}
</div>
@ -369,7 +367,7 @@ export function MLOpsPage() {
{LLM_MODELS.map((m, i) => (
<div key={m.name} onClick={() => setSelectedLLM(i)}
className={`p-3 rounded-lg text-center cursor-pointer ${selectedLLM === i ? 'bg-blue-600/15 border border-blue-500/30' : 'bg-surface-overlay border border-transparent'}`}>
<m.icon className="w-5 h-5 mx-auto mb-1 text-purple-600 dark:text-purple-400" />
<m.icon className="w-5 h-5 mx-auto mb-1 text-purple-400" />
<div className="text-[10px] font-bold text-heading">{m.name}</div>
<div className="text-[8px] text-hint">{m.sub}</div>
</div>
@ -383,7 +381,7 @@ export function MLOpsPage() {
<div key={k} className="flex flex-col gap-1"><span className="text-[9px] text-hint">{k}</span><div className="bg-background border border-border rounded px-2.5 py-1.5 text-label">{v}</div></div>
))}
</div>
<Button variant="primary" size="sm" className="mt-3 w-full" icon={<Play className="w-3 h-3" />}> </Button>
<button type="button" className="mt-3 flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg w-full justify-center"><Play className="w-3 h-3" /> </button>
</CardContent></Card>
</div>
<Card><CardContent className="p-4">
@ -419,10 +417,10 @@ export function MLOpsPage() {
<div key={k} className="flex justify-between px-2 py-1 bg-surface-overlay rounded"><span className="text-hint font-mono">{k}</span><span className="text-label">{v}</span></div>
))}
</div>
<Button variant="primary" size="sm" className="mt-3 w-full" icon={<Search className="w-3 h-3" />}> </Button>
<button type="button" className="mt-3 flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg w-full justify-center"><Search className="w-3 h-3" /> </button>
</CardContent></Card>
<Card className="col-span-2 bg-surface-raised border-border"><CardContent className="p-4">
<div className="flex justify-between mb-3"><div className="text-[12px] font-bold text-heading">HPS </div><span className="text-[10px] text-green-600 dark:text-green-400 font-bold">Best: Trial #3 (F1=0.912)</span></div>
<div className="flex justify-between mb-3"><div className="text-[12px] font-bold text-heading">HPS </div><span className="text-[10px] text-green-400 font-bold">Best: Trial #3 (F1=0.912)</span></div>
<table className="w-full text-[10px]">
<thead><tr className="border-b border-border text-hint">{['Trial', 'LR', 'Batch', 'Dropout', 'Hidden', 'F1 Score', ''].map(h => <th key={h} className="py-2 px-2 text-left font-medium">{h}</th>)}</tr></thead>
<tbody>{HPS_TRIALS.map(t => (
@ -507,7 +505,7 @@ export function MLOpsPage() {
</div>
<div className="flex gap-2 shrink-0">
<input aria-label="LLM 질의 입력" className="flex-1 bg-background border border-border rounded-xl px-4 py-2 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/40" placeholder="질의를 입력하세요..." />
<Button variant="primary" size="md" aria-label={tc('aria.send')} icon={<Send className="w-4 h-4" />} />
<button type="button" aria-label="전송" className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-on-vivid rounded-xl"><Send className="w-4 h-4" /></button>
</div>
</CardContent></Card>
</div>

파일 보기

@ -2,7 +2,6 @@ import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Shield, Eye, EyeOff, Lock, User, Fingerprint, KeyRound, AlertCircle } from 'lucide-react';
import { Button } from '@shared/components/ui/button';
import { useAuth } from '@/app/auth/AuthContext';
import { LoginError } from '@/services/authApi';
import { DemoQuickLogin, type DemoAccount } from './DemoQuickLogin';
@ -106,7 +105,7 @@ export function LoginPage() {
{/* 로고 영역 */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-blue-600/20 border border-blue-500/30 mb-4">
<Shield className="w-8 h-8 text-blue-600 dark:text-blue-400" />
<Shield className="w-8 h-8 text-blue-400" />
</div>
<h1 className="text-xl font-bold text-heading">{t('title')}</h1>
<p className="text-[11px] text-hint mt-1">{t('subtitle')}</p>
@ -123,7 +122,7 @@ export function LoginPage() {
disabled={m.disabled}
className={`flex-1 flex flex-col items-center gap-1 py-3 transition-colors ${
authMethod === m.key
? 'bg-blue-600/10 border-b-2 border-blue-500 text-blue-600 dark:text-blue-400'
? 'bg-blue-600/10 border-b-2 border-blue-500 text-blue-400'
: 'text-hint hover:bg-surface-overlay hover:text-label'
} ${m.disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
title={m.disabled ? '향후 도입 예정' : ''}
@ -189,18 +188,16 @@ export function LoginPage() {
</div>
{error && (
<div className="flex items-center gap-2 text-red-600 dark:text-red-400 text-[11px] bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
<div className="flex items-center gap-2 text-red-400 text-[11px] bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
{error}
</div>
)}
<Button
<button
type="submit"
variant="primary"
size="md"
disabled={loading}
className="w-full font-bold"
className="w-full py-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/50 text-on-vivid text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2 whitespace-nowrap"
>
{loading ? (
<>
@ -208,7 +205,7 @@ export function LoginPage() {
{t('button.authenticating')}
</>
) : t('button.login')}
</Button>
</button>
{/* 데모 퀵로그인 (VITE_SHOW_DEMO_LOGIN=true일 때만 렌더링) */}
<DemoQuickLogin onSelect={handleDemoSelect} disabled={loading} />
@ -218,7 +215,7 @@ export function LoginPage() {
{/* GPKI 인증 (Phase 9 도입 예정) */}
{authMethod === 'gpki' && (
<div className="space-y-4 text-center py-12">
<Fingerprint className="w-12 h-12 text-blue-600 dark:text-blue-400 mx-auto mb-3" />
<Fingerprint className="w-12 h-12 text-blue-400 mx-auto mb-3" />
<p className="text-sm text-heading font-medium">{t('gpki.title')}</p>
<p className="text-[10px] text-hint mt-1"> (Phase 9)</p>
</div>
@ -227,7 +224,7 @@ export function LoginPage() {
{/* SSO 연동 (Phase 9 도입 예정) */}
{authMethod === 'sso' && (
<div className="space-y-4 text-center py-12">
<KeyRound className="w-12 h-12 text-green-600 dark:text-green-400 mx-auto mb-3" />
<KeyRound className="w-12 h-12 text-green-400 mx-auto mb-3" />
<p className="text-sm text-heading font-medium">{t('sso.title')}</p>
<p className="text-[10px] text-hint mt-1"> (Phase 9)</p>
</div>

파일 보기

@ -55,7 +55,7 @@ function RiskBar({ value, size = 'default' }: { value: number; size?: 'default'
// backend riskScore.score는 0~100 정수. 0~1 범위로 들어오는 경우도 호환.
const pct = Math.max(0, Math.min(100, value <= 1 ? value * 100 : value));
const color = pct > 70 ? 'bg-red-500' : pct > 50 ? 'bg-orange-500' : pct > 30 ? 'bg-yellow-500' : 'bg-blue-500';
const textColor = pct > 70 ? 'text-red-600 dark:text-red-400' : pct > 50 ? 'text-orange-600 dark:text-orange-400' : pct > 30 ? 'text-yellow-600 dark:text-yellow-400' : 'text-blue-600 dark:text-blue-400';
const textColor = pct > 70 ? 'text-red-400' : pct > 50 ? 'text-orange-400' : pct > 30 ? 'text-yellow-400' : 'text-blue-400';
const barW = size === 'sm' ? 'w-16' : 'w-24';
return (
<div className="flex items-center gap-2">
@ -78,7 +78,7 @@ function KpiCard({ label, value, prev, icon: Icon, color, desc }: KpiCardProps)
<div className="p-2 rounded-lg" style={{ background: `${color}15` }}>
<Icon className="w-4 h-4" style={{ color }} />
</div>
<div className={`flex items-center gap-0.5 text-[10px] font-medium ${isUp ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'}`}>
<div className={`flex items-center gap-0.5 text-[10px] font-medium ${isUp ? 'text-red-400' : 'text-green-400'}`}>
{isUp ? <ArrowUpRight className="w-3 h-3" /> : <ArrowDownRight className="w-3 h-3" />}
{Math.abs(diff)}
</div>
@ -207,16 +207,16 @@ function SeaAreaMap() {
<div className="absolute bottom-2 left-2 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-2 py-1.5">
<div className="text-[8px] text-muted-foreground font-bold mb-1"> </div>
<div className="flex items-center gap-1">
<span className="text-[7px] text-blue-600 dark:text-blue-400"></span>
<span className="text-[7px] text-blue-400"></span>
<div className="w-16 h-1.5 rounded-full" style={{ background: 'linear-gradient(to right, #1e40af, #3b82f6, #eab308, #f97316, #ef4444)' }} />
<span className="text-[7px] text-red-600 dark:text-red-400"></span>
<span className="text-[7px] text-red-400"></span>
</div>
</div>
{/* LIVE 인디케이터 */}
<div className="absolute top-2 left-2 z-[1000] flex items-center gap-1.5 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-2 py-1">
<div className="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse" />
<Radar className="w-3 h-3 text-blue-500" />
<span className="text-[9px] text-blue-600 dark:text-blue-400 font-medium"> </span>
<span className="text-[9px] text-blue-400 font-medium"> </span>
</div>
</div>
);
@ -468,8 +468,8 @@ export function Dashboard() {
<span className="text-[10px] font-bold tabular-nums w-7 text-right" style={{
color: area.risk > 85 ? '#ef4444' : area.risk > 60 ? '#f97316' : area.risk > 40 ? '#eab308' : '#3b82f6'
}}>{area.risk}</span>
{area.trend === 'up' && <ArrowUpRight className="w-3 h-3 text-red-600 dark:text-red-400" />}
{area.trend === 'down' && <ArrowDownRight className="w-3 h-3 text-green-600 dark:text-green-400" />}
{area.trend === 'up' && <ArrowUpRight className="w-3 h-3 text-red-400" />}
{area.trend === 'down' && <ArrowDownRight className="w-3 h-3 text-green-400" />}
{area.trend === 'stable' && <span className="w-3 h-3 flex items-center justify-center text-hint text-[8px]"></span>}
</div>
))}
@ -544,7 +544,7 @@ export function Dashboard() {
<Card>
<CardHeader className="px-4 pt-3 pb-0">
<CardTitle className="text-xs text-label flex items-center gap-1.5">
<Waves className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
<Waves className="w-3.5 h-3.5 text-blue-400" />
</CardTitle>
</CardHeader>
@ -557,19 +557,19 @@ export function Dashboard() {
<div className="text-[8px] text-hint"> {WEATHER_DATA.wind.gust}m/s</div>
</div>
<div className="text-center p-2 rounded-lg bg-surface-overlay">
<Waves className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400 mx-auto mb-1" />
<Waves className="w-3.5 h-3.5 text-blue-400 mx-auto mb-1" />
<div className="text-[10px] text-heading font-bold">{WEATHER_DATA.wave.height}m</div>
<div className="text-[8px] text-hint"></div>
<div className="text-[8px] text-hint"> {WEATHER_DATA.wave.period}s</div>
</div>
<div className="text-center p-2 rounded-lg bg-surface-overlay">
<Thermometer className="w-3.5 h-3.5 text-orange-600 dark:text-orange-400 mx-auto mb-1" />
<Thermometer className="w-3.5 h-3.5 text-orange-400 mx-auto mb-1" />
<div className="text-[10px] text-heading font-bold">{WEATHER_DATA.temp.air}°C</div>
<div className="text-[8px] text-hint"></div>
<div className="text-[8px] text-hint"> {WEATHER_DATA.temp.water}°C</div>
</div>
<div className="text-center p-2 rounded-lg bg-surface-overlay">
<Eye className="w-3.5 h-3.5 text-green-600 dark:text-green-400 mx-auto mb-1" />
<Eye className="w-3.5 h-3.5 text-green-400 mx-auto mb-1" />
<div className="text-[10px] text-heading font-bold">{WEATHER_DATA.visibility}km</div>
<div className="text-[8px] text-hint"></div>
<div className="text-[8px] text-hint">{WEATHER_DATA.seaState}</div>

파일 보기

@ -1,41 +1,35 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { Input } from '@shared/components/ui/input';
import { Select } from '@shared/components/ui/select';
import { TabBar, TabButton } from '@shared/components/ui/tabs';
import { PageContainer } from '@shared/components/layout';
import {
Search, Clock, ChevronRight, ChevronLeft, Cloud,
Search, Ship, Clock, ChevronRight, ChevronLeft, Cloud,
Eye, AlertTriangle, Radio, RotateCcw,
Brain, RefreshCw, Crosshair as CrosshairIcon, Loader2
MapPin, Brain, RefreshCw, Crosshair as CrosshairIcon, Loader2
} from 'lucide-react';
import { formatDateTime } from '@shared/utils/dateFormat';
import { ALERT_LEVELS, getAlertLevelLabel, type AlertLevel } from '@shared/constants/alertLevels';
import { getVesselTypeLabel } from '@shared/constants/vesselTypes';
import { getVesselRingMeta } from '@shared/constants/vesselAnalysisStatuses';
import { useSettingsStore } from '@stores/settingsStore';
import { useTranslation } from 'react-i18next';
import { GearIdentification } from './GearIdentification';
import { RealAllVessels, RealTransshipSuspects } from './RealVesselAnalysis';
import { VesselMiniMap } from './components/VesselMiniMap';
import { VesselAnomalyPanel } from './components/VesselAnomalyPanel';
import { extractAnomalies, groupAnomaliesToSegments } from './components/vesselAnomaly';
import { PieChart as EcPieChart } from '@lib/charts';
import type { VesselAnalysisItem } from '@/services/vesselAnalysisApi';
import {
getAnalysisStats,
getAnalysisVessels,
getAnalysisHistory,
type AnalysisStats,
type VesselAnalysis,
} from '@/services/analysisApi';
import { toVesselItem } from '@/services/analysisAdapter';
fetchVesselAnalysis,
filterDarkVessels,
filterTransshipSuspects,
type VesselAnalysisItem,
type VesselAnalysisStats,
} from '@/services/vesselAnalysisApi';
// ─── 중국 MMSI prefix ─────────────
const CHINA_MMSI_PREFIX = '412';
function isChinaVessel(mmsi: string): boolean {
return mmsi.startsWith(CHINA_MMSI_PREFIX);
}
// ─── 특이운항 선박 리스트 타입 ────────────────
type VesselStatus = '의심' | '양호' | '경고';
interface VesselItem {
@ -57,29 +51,16 @@ function deriveVesselStatus(score: number): VesselStatus {
return '양호';
}
function mapToVesselItem(
item: VesselAnalysisItem,
idx: number,
t: (k: string, opts?: { defaultValue?: string }) => string,
lang: 'ko' | 'en',
): VesselItem {
function mapToVesselItem(item: VesselAnalysisItem, idx: number): VesselItem {
const score = item.algorithms.riskScore.score;
const vt = item.classification.vesselType;
const hasType = vt && vt !== 'UNKNOWN' && vt !== '';
// 이름: fleet_vessels 매핑으로 vessel_type 이 채워진 경우 한글 유형 라벨, 아니면 '중국어선'
const name = hasType ? getVesselTypeLabel(vt, t, lang) : '중국어선';
// 타입 뱃지: fishingPct 기반 Fishing / 그 외는 vessel_type 라벨
const type = item.classification.fishingPct > 0.5
? 'Fishing'
: hasType ? getVesselTypeLabel(vt, t, lang) : getVesselTypeLabel('UNKNOWN', t, lang);
return {
id: String(idx + 1),
mmsi: item.mmsi,
callSign: '-',
channel: '',
source: 'AIS',
name,
type,
name: item.classification.vesselType || item.mmsi,
type: item.classification.fishingPct > 0.5 ? 'Fishing' : 'Cargo',
country: 'China',
status: deriveVesselStatus(score),
riskPct: score,
@ -221,14 +202,10 @@ export function ChinaFishing() {
const [mode, setMode] = useState<'dashboard' | 'transfer' | 'gear'>('dashboard');
const [vesselTab, setVesselTab] = useState<'특이운항' | '비허가 선박' | '제재 선박' | '관심 선박'>('특이운항');
const [statsTab, setStatsTab] = useState<'불법조업 통계' | '특이선박 통계' | '위험선박 통계'>('불법조업 통계');
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
const [history, setHistory] = useState<VesselAnalysis[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyError, setHistoryError] = useState('');
// API state
const [topVessels, setTopVessels] = useState<VesselAnalysisItem[]>([]);
const [apiStats, setApiStats] = useState<AnalysisStats | null>(null);
const [allItems, setAllItems] = useState<VesselAnalysisItem[]>([]);
const [apiStats, setApiStats] = useState<VesselAnalysisStats | null>(null);
const [serviceAvailable, setServiceAvailable] = useState(true);
const [apiLoading, setApiLoading] = useState(false);
const [apiError, setApiError] = useState('');
@ -237,18 +214,10 @@ export function ChinaFishing() {
setApiLoading(true);
setApiError('');
try {
const [stats, topPage] = await Promise.all([
getAnalysisStats({ hours: 1, mmsiPrefix: CHINA_MMSI_PREFIX }),
getAnalysisVessels({
hours: 1,
mmsiPrefix: CHINA_MMSI_PREFIX,
minRiskScore: 40,
size: 20,
}),
]);
setApiStats(stats);
setTopVessels(topPage.content.map(toVesselItem));
setServiceAvailable(true);
const res = await fetchVesselAnalysis();
setServiceAvailable(res.serviceAvailable);
setAllItems(res.items);
setApiStats(res.stats);
} catch (e: unknown) {
setApiError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다');
setServiceAvailable(false);
@ -259,77 +228,55 @@ export function ChinaFishing() {
useEffect(() => { loadApi(); }, [loadApi]);
// 선박 선택 시 24h 분석 이력 로드 (미니맵 anomaly 포인트 + 판별 패널 공통 데이터)
useEffect(() => {
if (!selectedMmsi) { setHistory([]); setHistoryError(''); return; }
let cancelled = false;
setHistoryLoading(true); setHistoryError('');
getAnalysisHistory(selectedMmsi, 24)
.then((rows) => { if (!cancelled) setHistory(rows); })
.catch((e: unknown) => {
if (cancelled) return;
setHistory([]);
setHistoryError(e instanceof Error ? e.message : '이력 조회 실패');
})
.finally(() => { if (!cancelled) setHistoryLoading(false); });
return () => { cancelled = true; };
}, [selectedMmsi]);
const anomalySegments = useMemo(
() => groupAnomaliesToSegments(extractAnomalies(history)),
[history],
// 중국어선 필터
const chinaVessels = useMemo(
() => allItems.filter((i) => isChinaVessel(i.mmsi)),
[allItems],
);
// ─ 파생 계산 (서버 집계 우선) ─
// Tab 1 '분석 대상' 및 카운터는 apiStats 값이 SSOT.
// topVessels 는 minRiskScore=40 으로 필터된 상위 20척 (특이운항 리스트 전용).
const countersRow1 = useMemo(() => {
if (!apiStats) return [];
return [
{ label: '통합', count: apiStats.total, color: '#6b7280' },
{ label: 'AIS', count: apiStats.total, color: '#3b82f6' },
{ label: 'EEZ 내', count: apiStats.territorialCount + apiStats.contiguousCount, color: '#8b5cf6' },
{ label: '어업선', count: apiStats.fishingCount, color: '#10b981' },
];
}, [apiStats]);
const chinaDark = useMemo(() => filterDarkVessels(chinaVessels), [chinaVessels]);
const chinaTransship = useMemo(() => filterTransshipSuspects(chinaVessels), [chinaVessels]);
const countersRow2 = useMemo(() => {
if (!apiStats) return [];
return [
{ label: '중국어선', count: apiStats.total, color: '#f97316' },
{ label: 'Dark Vessel', count: apiStats.darkCount, color: '#ef4444' },
{ label: '환적 의심', count: apiStats.transshipCount, color: '#06b6d4' },
{ label: '고위험', count: apiStats.criticalCount + apiStats.highCount, color: '#ef4444' },
];
}, [apiStats]);
// 센서 카운터 (API 기반)
const countersRow1 = useMemo(() => [
{ label: '통합', count: allItems.length, color: '#6b7280' },
{ label: 'AIS', count: allItems.length, color: '#3b82f6' },
{ label: 'EEZ 내', count: allItems.filter((i) => i.algorithms.location.zone !== 'EEZ_OR_BEYOND').length, color: '#8b5cf6' },
{ label: '어업선', count: allItems.filter((i) => i.classification.fishingPct > 0.5).length, color: '#10b981' },
], [allItems]);
// 특이운항 선박 리스트 (서버에서 이미 riskScore >= 40 로 필터링된 상위 20척)
const countersRow2 = useMemo(() => [
{ label: '중국어선', count: chinaVessels.length, color: '#f97316' },
{ label: 'Dark Vessel', count: chinaDark.length, color: '#ef4444' },
{ label: '환적 의심', count: chinaTransship.length, color: '#06b6d4' },
{ label: '고위험', count: chinaVessels.filter((i) => i.algorithms.riskScore.score >= 70).length, color: '#ef4444' },
], [chinaVessels, chinaDark, chinaTransship]);
// 특이운항 선박 리스트 (중국어선 중 riskScore >= 40)
const vesselList: VesselItem[] = useMemo(
() => topVessels.map((item, idx) => mapToVesselItem(item, idx, tcCommon, lang)),
[topVessels, tcCommon, lang],
() => chinaVessels
.filter((i) => i.algorithms.riskScore.score >= 40)
.sort((a, b) => b.algorithms.riskScore.score - a.algorithms.riskScore.score)
.slice(0, 20)
.map((item, idx) => mapToVesselItem(item, idx)),
[chinaVessels],
);
// 위험도별 분포 (도넛 차트용) — apiStats 기반
// 위험도별 분포 (도넛 차트용)
const riskDistribution = useMemo(() => {
if (!apiStats) return { critical: 0, high: 0, medium: 0, low: 0, total: 0 };
return {
critical: apiStats.criticalCount,
high: apiStats.highCount,
medium: apiStats.mediumCount,
low: apiStats.lowCount,
total: apiStats.total,
};
}, [apiStats]);
const critical = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'CRITICAL').length;
const high = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'HIGH').length;
const medium = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'MEDIUM').length;
const low = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'LOW').length;
return { critical, high, medium, low, total: chinaVessels.length };
}, [chinaVessels]);
// 안전도 지수 계산 (avgRiskScore 0~100 → 0~10 스케일)
// 안전도 지수 계산
const safetyIndex = useMemo(() => {
const avgRisk = apiStats ? Number(apiStats.avgRiskScore) : 0;
if (!apiStats || apiStats.total === 0) return { risk: 0, safety: 100 };
return {
risk: Number((avgRisk / 10).toFixed(2)),
safety: Number(((100 - avgRisk) / 10).toFixed(2)),
};
}, [apiStats]);
if (chinaVessels.length === 0) return { risk: 0, safety: 100 };
const avgRisk = chinaVessels.reduce((s, i) => s + i.algorithms.riskScore.score, 0) / chinaVessels.length;
return { risk: Number((avgRisk / 10).toFixed(2)), safety: Number(((100 - avgRisk) / 10).toFixed(2)) };
}, [chinaVessels]);
const vesselTabs = ['특이운항', '비허가 선박', '제재 선박', '관심 선박'] as const;
const statsTabs = ['불법조업 통계', '특이선박 통계', '위험선박 통계'] as const;
@ -343,19 +290,22 @@ export function ChinaFishing() {
return (
<PageContainer size="sm">
{/* ── 모드 탭 (AI 대시보드 / 환적 탐지) ── */}
<TabBar variant="segmented">
<div className="flex items-center gap-1 bg-surface-raised rounded-lg p-1 border border-slate-700/30 w-fit">
{modeTabs.map((tab) => (
<TabButton
<button type="button"
key={tab.key}
variant="segmented"
active={mode === tab.key}
onClick={() => setMode(tab.key)}
icon={<tab.icon className="w-3.5 h-3.5" />}
className={`flex items-center gap-1.5 px-4 py-2 rounded-md text-[11px] font-medium transition-colors ${
mode === tab.key
? 'bg-blue-600 text-on-vivid'
: 'text-muted-foreground hover:text-foreground hover:bg-surface-overlay'
}`}
>
<tab.icon className="w-3.5 h-3.5" />
{tab.label}
</TabButton>
</button>
))}
</TabBar>
</div>
{/* 환적 탐지 모드 */}
{mode === 'transfer' && <TransferView />}
@ -369,11 +319,11 @@ export function ChinaFishing() {
{!serviceAvailable && (
<div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 text-yellow-400 text-xs">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span> API - </span>
<span>iran - </span>
</div>
)}
{apiError && <div className="text-xs text-red-600 dark:text-red-400">{tcCommon('error.errorPrefix', { msg: apiError })}</div>}
{apiError && <div className="text-xs text-red-400">: {apiError}</div>}
{apiLoading && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
@ -381,7 +331,7 @@ export function ChinaFishing() {
</div>
)}
{/* 중국 선박 실시간 분석 결과 */}
{/* iran 백엔드 실시간 분석 결과 */}
<RealAllVessels />
{/* ── 상단 바: 기준일 + 검색 ── */}
@ -390,21 +340,16 @@ export function ChinaFishing() {
<Clock className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-[11px] text-label"> : {formatDateTime(new Date())}</span>
</div>
<Button
variant="secondary"
size="sm"
onClick={loadApi}
aria-label={tcCommon('aria.refresh')}
icon={<RotateCcw className="w-3.5 h-3.5" />}
/>
<button type="button" onClick={loadApi} className="p-1.5 rounded-lg bg-surface-overlay border border-slate-700/40 text-muted-foreground hover:text-heading transition-colors" title="새로고침">
<RotateCcw className="w-3.5 h-3.5" />
</button>
<div className="flex-1 flex items-center bg-surface-overlay border border-slate-700/40 rounded-lg px-3 py-1.5">
<Search className="w-3.5 h-3.5 text-hint mr-2" />
<input
aria-label={tcCommon('aria.searchAreaOrZone')}
<input aria-label="해역 또는 해구 번호 검색"
placeholder="해역 또는 해구 번호 검색"
className="bg-transparent text-[11px] text-label placeholder:text-hint flex-1 focus:outline-none"
/>
<Search className="w-4 h-4 text-blue-600 dark:text-blue-500 cursor-pointer" />
<Search className="w-4 h-4 text-blue-500 cursor-pointer" />
</div>
</div>
@ -426,7 +371,7 @@ export function ChinaFishing() {
</div>
<div className="flex items-center gap-2 mb-4 text-[10px] text-muted-foreground">
<span> </span>
<span className="text-lg font-extrabold text-heading">{(apiStats?.total ?? 0).toLocaleString()}</span>
<span className="text-lg font-extrabold text-heading">{allItems.length.toLocaleString()}</span>
<span className="text-hint">()</span>
</div>
@ -462,13 +407,13 @@ export function ChinaFishing() {
<div className="flex items-center justify-around mt-4">
<div>
<div className="text-[10px] text-muted-foreground mb-2 text-center">
<span className="text-orange-600 dark:text-orange-400 font-medium"></span>
<span className="text-orange-400 font-medium"></span>
</div>
<SemiGauge value={safetyIndex.risk} label="" color="#f97316" />
</div>
<div>
<div className="text-[10px] text-muted-foreground mb-2 text-center">
<span className="text-blue-600 dark:text-blue-400 font-medium"></span>
<span className="text-blue-400 font-medium"></span>
</div>
<SemiGauge value={safetyIndex.safety} label="" color="#3b82f6" />
</div>
@ -477,51 +422,38 @@ export function ChinaFishing() {
</Card>
</div>
{/* 관심영역 안전도 (해역 지오펜스 미구축 → 데모) */}
{/* 관심영역 안전도 */}
<div className="col-span-4">
<Card className="bg-surface-raised border-slate-700/30 h-full">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-heading"> </span>
<Badge intent="warning" size="xs" className="font-normal"> </Badge>
</div>
<Select
size="sm"
aria-label={tcCommon('aria.areaOfInterestSelect')}
>
<span className="text-sm font-bold text-heading"> </span>
<select aria-label="관심영역 선택" className="bg-secondary border border-slate-700/50 rounded px-2 py-0.5 text-[10px] text-label focus:outline-none">
<option> A</option>
<option> B</option>
</Select>
</select>
</div>
<p className="text-[9px] text-hint mb-3"> .</p>
<div className="flex items-center gap-4">
<div className="space-y-2 flex-1">
<div className="flex items-center gap-2 text-[11px]">
<Eye className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
<Eye className="w-3.5 h-3.5 text-blue-400" />
<span className="text-muted-foreground"></span>
<span className="text-green-600 dark:text-green-400 font-bold ml-auto"></span>
<span className="text-green-400 font-bold ml-auto"></span>
</div>
<div className="flex items-center gap-2 text-[11px]">
<AlertTriangle className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
<AlertTriangle className="w-3.5 h-3.5 text-red-400" />
<span className="text-muted-foreground"></span>
<span className="text-green-600 dark:text-green-400 font-bold ml-auto"></span>
<span className="text-green-400 font-bold ml-auto"></span>
</div>
<div className="flex items-center gap-2 text-[11px]">
<Radio className="w-3.5 h-3.5 text-purple-600 dark:text-purple-400" />
<Radio className="w-3.5 h-3.5 text-purple-400" />
<span className="text-muted-foreground"></span>
<span className="text-green-600 dark:text-green-400 font-bold ml-auto"></span>
<span className="text-green-400 font-bold ml-auto"></span>
</div>
</div>
<CircleGauge
value={
apiStats && apiStats.total > 0
? Number(((1 - apiStats.criticalCount / Math.max(apiStats.total, 1)) * 100).toFixed(1))
: 100
}
label=""
/>
<CircleGauge value={chinaVessels.length > 0 ? Number(((1 - riskDistribution.critical / Math.max(chinaVessels.length, 1)) * 100).toFixed(1)) : 100} label="" />
</div>
</CardContent>
</Card>
@ -535,27 +467,22 @@ export function ChinaFishing() {
<div className="col-span-5">
<Card className="bg-surface-raised border-slate-700/30">
<CardContent className="p-0">
{/* 탭 헤더 — 특이운항만 활성, 나머지 3개는 데이터 소스 미연동 */}
<TabBar variant="underline" className="border-slate-700/30">
{vesselTabs.map((tab) => {
const disabled = tab !== '특이운항';
return (
<TabButton
key={tab}
variant="underline"
active={vesselTab === tab}
onClick={() => !disabled && setVesselTab(tab)}
disabled={disabled}
className="flex-1 justify-center"
>
{tab}
{disabled && (
<Badge intent="warning" size="xs" className="font-normal"></Badge>
)}
</TabButton>
);
})}
</TabBar>
{/* 탭 헤더 */}
<div className="flex border-b border-slate-700/30">
{vesselTabs.map((tab) => (
<button type="button"
key={tab}
onClick={() => setVesselTab(tab)}
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors ${
vesselTab === tab
? 'text-heading border-b-2 border-blue-500 bg-surface-overlay'
: 'text-hint hover:text-label'
}`}
>
{tab}
</button>
))}
</div>
{/* 선박 목록 */}
<div className="max-h-[420px] overflow-y-auto">
@ -564,34 +491,28 @@ export function ChinaFishing() {
{apiLoading ? '데이터 로딩 중...' : '중국어선 특이운항 데이터가 없습니다'}
</div>
)}
{vesselList.map((v) => {
const selected = v.mmsi === selectedMmsi;
return (
<div
key={v.id}
onClick={() => setSelectedMmsi(selected ? null : v.mmsi)}
className={`flex items-center gap-3 px-4 py-3 border-b border-slate-700/20 transition-colors cursor-pointer group ${
selected ? 'bg-blue-500/10 hover:bg-blue-500/15' : 'hover:bg-surface-overlay'
}`}
>
<StatusRing status={v.status} riskPct={v.riskPct} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-[10px] text-hint mb-0.5">
<span>MMSI | <span className="text-label">{v.mmsi}</span></span>
<span> | <span className="text-label">{v.source}</span></span>
</div>
<div className="flex items-center gap-2">
<span className="text-[12px] font-bold text-heading">{v.name}</span>
<Badge intent="info" size="xs" className="px-1.5 py-0">{v.type}</Badge>
</div>
<div className="flex items-center gap-1 mt-0.5 text-[9px] text-hint">
<span>{v.country}</span>
</div>
{vesselList.map((v) => (
<div
key={v.id}
className="flex items-center gap-3 px-4 py-3 border-b border-slate-700/20 hover:bg-surface-overlay transition-colors cursor-pointer group"
>
<StatusRing status={v.status} riskPct={v.riskPct} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-[10px] text-hint mb-0.5">
<span>MMSI | <span className="text-label">{v.mmsi}</span></span>
<span> | <span className="text-label">{v.source}</span></span>
</div>
<div className="flex items-center gap-2">
<span className="text-[12px] font-bold text-heading">{v.name}</span>
<Badge intent="info" size="xs" className="px-1.5 py-0">{v.type}</Badge>
</div>
<div className="flex items-center gap-1 mt-0.5 text-[9px] text-hint">
<span>{v.country}</span>
</div>
<ChevronRight className="w-4 h-4 text-hint group-hover:text-muted-foreground shrink-0" />
</div>
);
})}
<ChevronRight className="w-4 h-4 text-hint group-hover:text-muted-foreground shrink-0" />
</div>
))}
</div>
</CardContent>
</Card>
@ -603,21 +524,22 @@ export function ChinaFishing() {
{/* 통계 차트 */}
<Card className="bg-surface-raised border-slate-700/30">
<CardContent className="p-0">
{/* 탭 — 월별 집계 API 미연동 */}
<TabBar variant="underline" className="border-slate-700/30">
{/* 탭 */}
<div className="flex border-b border-slate-700/30">
{statsTabs.map((tab) => (
<TabButton
<button type="button"
key={tab}
variant="underline"
active={statsTab === tab}
onClick={() => setStatsTab(tab)}
className="flex-1 justify-center"
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors ${
statsTab === tab
? 'text-heading border-b-2 border-green-500 bg-surface-overlay'
: 'text-hint hover:text-label'
}`}
>
{tab}
<Badge intent="warning" size="xs" className="font-normal"></Badge>
</TabButton>
</button>
))}
</TabBar>
</div>
<div className="p-4 flex gap-4">
{/* 월별 통계 - API 미지원, 준비중 안내 */}
@ -662,9 +584,9 @@ export function ChinaFishing() {
{/* 다운로드 버튼 */}
<div className="px-4 pb-3 flex justify-end">
<Button variant="secondary" size="sm">
<button type="button" className="px-3 py-1 bg-secondary border border-slate-700/50 rounded text-[10px] text-label hover:bg-switch-background transition-colors">
</Button>
</button>
</div>
</CardContent>
</Card>
@ -672,15 +594,12 @@ export function ChinaFishing() {
{/* 하단 카드 3개 */}
<div className="grid grid-cols-3 gap-3">
{/* 최근 위성영상 분석 (VIIRS 수집 파이프라인 미구축 → 데모) */}
{/* 최근 위성영상 분석 */}
<Card className="bg-surface-raised border-slate-700/30">
<CardContent className="p-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-bold text-heading"> </span>
<Badge intent="warning" size="xs" className="font-normal"> </Badge>
</div>
<button type="button" className="text-[9px] text-blue-600 dark:text-blue-400 hover:underline"> </button>
<span className="text-[11px] font-bold text-heading"> </span>
<button type="button" className="text-[9px] text-blue-400 hover:underline"> </button>
</div>
<div className="space-y-1.5 text-[10px]">
<div className="flex gap-2">
@ -699,19 +618,16 @@ export function ChinaFishing() {
</CardContent>
</Card>
{/* 기상 예보 (기상청 API 미연동 → 데모) */}
{/* 기상 예보 */}
<Card className="bg-surface-raised border-slate-700/30">
<CardContent className="p-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-bold text-heading"> </span>
<Badge intent="warning" size="xs" className="font-normal"> </Badge>
</div>
<button type="button" className="text-[9px] text-blue-600 dark:text-blue-400 hover:underline"> </button>
<span className="text-[11px] font-bold text-heading"> </span>
<button type="button" className="text-[9px] text-blue-400 hover:underline"> </button>
</div>
<div className="flex items-center gap-3">
<div className="text-center">
<Cloud className="w-8 h-8 text-yellow-600 dark:text-yellow-400 mx-auto" />
<Cloud className="w-8 h-8 text-yellow-400 mx-auto" />
</div>
<div>
<div className="text-[9px] text-muted-foreground"></div>
@ -725,15 +641,12 @@ export function ChinaFishing() {
</CardContent>
</Card>
{/* VTS연계 현황 (VTS 시스템 연계 미구축 → 데모) */}
{/* VTS연계 현황 */}
<Card className="bg-surface-raised border-slate-700/30">
<CardContent className="p-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-bold text-heading">VTS연계 </span>
<Badge intent="warning" size="xs" className="font-normal"> </Badge>
</div>
<button type="button" className="text-[9px] text-blue-600 dark:text-blue-400 hover:underline"> </button>
<span className="text-[11px] font-bold text-heading">VTS연계 </span>
<button type="button" className="text-[9px] text-blue-400 hover:underline"> </button>
</div>
<div className="grid grid-cols-2 gap-1.5">
{VTS_ITEMS.map((vts) => (
@ -741,28 +654,22 @@ export function ChinaFishing() {
key={vts.name}
className={`flex items-center gap-1.5 px-2 py-1 rounded text-[10px] ${
vts.active
? 'bg-orange-500/15 text-orange-600 dark:text-orange-400 border border-orange-500/20'
? 'bg-orange-500/15 text-orange-400 border border-orange-500/20'
: 'bg-surface-overlay text-muted-foreground border border-slate-700/30'
}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${vts.active ? 'bg-orange-500' : 'bg-muted'}`} />
<span className={`w-1.5 h-1.5 rounded-full ${vts.active ? 'bg-orange-400' : 'bg-muted'}`} />
{vts.name}
</div>
))}
</div>
<div className="flex justify-between mt-2">
<Button
variant="ghost"
size="sm"
aria-label={tcCommon('aria.previous')}
icon={<ChevronLeft className="w-4 h-4" />}
/>
<Button
variant="ghost"
size="sm"
aria-label={tcCommon('aria.next')}
icon={<ChevronRight className="w-4 h-4" />}
/>
<button type="button" aria-label="이전" className="text-hint hover:text-heading transition-colors">
<ChevronLeft className="w-4 h-4" />
</button>
<button type="button" aria-label="다음" className="text-hint hover:text-heading transition-colors">
<ChevronRight className="w-4 h-4" />
</button>
</div>
</CardContent>
</Card>
@ -770,28 +677,6 @@ export function ChinaFishing() {
</div>
</div>
{/* ── 선택 시: 궤적 미니맵 + 특이운항 판별 구간 상세 (최근 24h 분석 이력 기반) ── */}
{selectedMmsi && (
<div className="grid grid-cols-12 gap-3">
<div className="col-span-5">
<VesselMiniMap
mmsi={selectedMmsi}
vesselName={vesselList.find((v) => v.mmsi === selectedMmsi)?.name}
segments={anomalySegments}
onClose={() => setSelectedMmsi(null)}
/>
</div>
<div className="col-span-7">
<VesselAnomalyPanel
segments={anomalySegments}
loading={historyLoading}
error={historyError}
totalHistoryCount={history.length}
/>
</div>
</div>
)}
</>}
</PageContainer>
);

파일 보기

@ -12,7 +12,6 @@ import type { MarkerData } from '@lib/map';
import { getDarkVessels, type VesselAnalysis } from '@/services/analysisApi';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getRiskIntent } from '@shared/constants/statusIntent';
import { getAlertLevelTierScore } from '@shared/constants/alertLevels';
import { useSettingsStore } from '@stores/settingsStore';
import { DarkDetailPanel } from './components/DarkDetailPanel';
@ -88,23 +87,20 @@ export function DarkVesselDetection() {
{ key: 'darkTier', label: '등급', width: '80px', sortable: true,
render: (v) => {
const tier = v as string;
return <Badge intent={getRiskIntent(tier === 'WATCH' ? 40 : getAlertLevelTierScore(tier))} size="sm">{tier}</Badge>;
return <Badge intent={getRiskIntent(tier === 'CRITICAL' ? 90 : tier === 'HIGH' ? 60 : tier === 'WATCH' ? 40 : 10)} size="sm">{tier}</Badge>;
} },
{ key: 'darkScore', label: '의심점수', width: '80px', align: 'center', sortable: true,
render: (v) => {
const n = v as number;
const c = n >= 70 ? 'text-red-600 dark:text-red-400'
: n >= 50 ? 'text-orange-600 dark:text-orange-400'
: 'text-yellow-600 dark:text-yellow-400';
return <span className={`font-bold font-mono ${c}`}>{n}</span>;
return <span className={`font-bold font-mono ${n >= 70 ? 'text-red-400' : n >= 50 ? 'text-orange-400' : 'text-yellow-400'}`}>{n}</span>;
} },
{ key: 'name', label: '선박 유형', sortable: true,
render: (v) => <span className="text-cyan-600 dark:text-cyan-400 font-medium">{v as string}</span> },
render: (v) => <span className="text-cyan-400 font-medium">{v as string}</span> },
{ key: 'mmsi', label: 'MMSI', width: '100px',
render: (v) => {
const mmsi = v as string;
return (
<button type="button" className="text-cyan-600 dark:text-cyan-400 hover:text-cyan-700 dark:hover:text-cyan-300 hover:underline font-mono text-[10px]"
<button type="button" className="text-cyan-400 hover:text-cyan-300 hover:underline font-mono text-[10px]"
onClick={(e) => { e.stopPropagation(); navigate(`/vessel/${mmsi}`); }}>
{mmsi}
</button>
@ -119,10 +115,7 @@ export function DarkVesselDetection() {
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
render: (v) => {
const n = v as number;
const c = n >= 70 ? 'text-red-600 dark:text-red-400'
: n >= 50 ? 'text-yellow-600 dark:text-yellow-400'
: 'text-green-600 dark:text-green-400';
return <span className={`font-bold ${c}`}>{n}</span>;
return <span className={`font-bold ${n >= 70 ? 'text-red-400' : n >= 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}</span>;
} },
{ key: 'darkPatterns', label: '의심 패턴', minWidth: '120px',
render: (v) => <span className="text-hint text-[9px]">{v as string}</span> },
@ -258,7 +251,7 @@ export function DarkVesselDetection() {
}
/>
{error && <div className="text-xs text-red-600 dark:text-red-400">{tc('error.errorPrefix', { msg: error })}</div>}
{error && <div className="text-xs text-red-400">: {error}</div>}
{loading && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
@ -269,10 +262,10 @@ export function DarkVesselDetection() {
{/* KPI — tier 기반 */}
<div className="flex gap-2">
{[
{ l: '전체', v: tierCounts.total, c: 'text-red-600 dark:text-red-400', filter: '' },
{ l: 'CRITICAL', v: tierCounts.CRITICAL, c: 'text-red-600 dark:text-red-400', filter: 'CRITICAL' },
{ l: 'HIGH', v: tierCounts.HIGH, c: 'text-orange-600 dark:text-orange-400', filter: 'HIGH' },
{ l: 'WATCH', v: tierCounts.WATCH, c: 'text-yellow-600 dark:text-yellow-400', filter: 'WATCH' },
{ l: '전체', v: tierCounts.total, c: 'text-red-400', filter: '' },
{ l: 'CRITICAL', v: tierCounts.CRITICAL, c: 'text-red-400', filter: 'CRITICAL' },
{ l: 'HIGH', v: tierCounts.HIGH, c: 'text-orange-400', filter: 'HIGH' },
{ l: 'WATCH', v: tierCounts.WATCH, c: 'text-yellow-400', filter: 'WATCH' },
].map((k) => (
<div key={k.l}
onClick={() => setTierFilter(k.filter)}
@ -310,7 +303,7 @@ export function DarkVesselDetection() {
</div>
<div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
<div className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
<span className="text-[10px] text-red-600 dark:text-red-400 font-bold">{tierCounts.CRITICAL}</span>
<span className="text-[10px] text-red-400 font-bold">{tierCounts.CRITICAL}</span>
<span className="text-[9px] text-hint">CRITICAL Dark Vessel</span>
</div>
</CardContent>

파일 보기

@ -1,427 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AlertOctagon, RefreshCw } from 'lucide-react';
import { PageContainer, PageHeader, Section } from '@shared/components/layout';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { Input } from '@shared/components/ui/input';
import { Select } from '@shared/components/ui/select';
import { Textarea } from '@shared/components/ui/textarea';
import { Card, CardContent } from '@shared/components/ui/card';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
import {
GEAR_COLLISION_STATUS_ORDER,
getGearCollisionStatusIntent,
getGearCollisionStatusLabel,
} from '@shared/constants/gearCollisionStatuses';
import {
getGearCollisionStats,
listGearCollisions,
resolveGearCollision,
type GearCollision,
type GearCollisionResolveAction,
type GearCollisionStats,
} from '@/services/gearCollisionApi';
import { useSettingsStore } from '@stores/settingsStore';
/**
* (GEAR_IDENTITY_COLLISION) .
*
* MMSI cycle
* REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE .
*/
type SeverityCode = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
const SEVERITY_OPTIONS: SeverityCode[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'];
const DEFAULT_HOURS = 48;
export function GearCollisionDetection() {
const { t } = useTranslation('detection');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language) as 'ko' | 'en';
const [rows, setRows] = useState<GearCollision[]>([]);
const [stats, setStats] = useState<GearCollisionStats | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [severityFilter, setSeverityFilter] = useState<string>('');
const [nameFilter, setNameFilter] = useState<string>('');
const [selected, setSelected] = useState<GearCollision | null>(null);
const [resolveAction, setResolveAction] = useState<GearCollisionResolveAction>('REVIEWED');
const [resolveNote, setResolveNote] = useState('');
const [resolving, setResolving] = useState(false);
const loadData = useCallback(async () => {
setLoading(true);
setError('');
try {
const [page, summary] = await Promise.all([
listGearCollisions({
status: statusFilter || undefined,
severity: severityFilter || undefined,
name: nameFilter || undefined,
hours: DEFAULT_HOURS,
size: 200,
}),
getGearCollisionStats(DEFAULT_HOURS),
]);
setRows(page.content);
setStats(summary);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : t('gearCollision.error.loadFailed'));
} finally {
setLoading(false);
}
}, [statusFilter, severityFilter, nameFilter, t]);
useEffect(() => { loadData(); }, [loadData]);
// 선택된 row 와 현재 목록의 동기화
const syncedSelected = useMemo(
() => selected ? rows.find((r) => r.id === selected.id) ?? selected : null,
[rows, selected],
);
const cols: DataColumn<GearCollision & Record<string, unknown>>[] = useMemo(() => [
{
key: 'name', label: t('gearCollision.columns.name'), minWidth: '120px', sortable: true,
render: (v) => <span className="text-cyan-600 dark:text-cyan-400 font-medium">{v as string}</span>,
},
{
key: 'mmsiLo', label: t('gearCollision.columns.mmsiPair'), minWidth: '160px',
render: (_, row) => (
<span className="font-mono text-[10px] text-label">
{row.mmsiLo} {row.mmsiHi}
</span>
),
},
{
key: 'parentName', label: t('gearCollision.columns.parentName'), minWidth: '110px',
render: (v) => <span className="text-hint text-[10px]">{(v as string) || '-'}</span>,
},
{
key: 'coexistenceCount', label: t('gearCollision.columns.coexistenceCount'),
width: '90px', align: 'center', sortable: true,
render: (v) => <span className="font-mono text-label">{v as number}</span>,
},
{
key: 'maxDistanceKm', label: t('gearCollision.columns.maxDistance'),
width: '110px', align: 'right', sortable: true,
render: (v) => {
const n = typeof v === 'number' ? v : Number(v ?? 0);
return <span className="font-mono text-[10px] text-label">{n.toFixed(2)}</span>;
},
},
{
key: 'severity', label: t('gearCollision.columns.severity'),
width: '90px', align: 'center', sortable: true,
render: (v) => (
<Badge intent={getAlertLevelIntent(v as string)} size="sm">
{getAlertLevelLabel(v as string, tc, lang)}
</Badge>
),
},
{
key: 'status', label: t('gearCollision.columns.status'),
width: '110px', align: 'center', sortable: true,
render: (v) => (
<Badge intent={getGearCollisionStatusIntent(v as string)} size="sm">
{getGearCollisionStatusLabel(v as string, t, lang)}
</Badge>
),
},
{
key: 'lastSeenAt', label: t('gearCollision.columns.lastSeen'),
width: '130px', sortable: true,
render: (v) => (
<span className="text-muted-foreground text-[10px]">{formatDateTime(v as string)}</span>
),
},
], [t, tc, lang]);
const handleResolve = useCallback(async () => {
if (!syncedSelected) return;
const ok = window.confirm(t('gearCollision.resolve.confirmPrompt'));
if (!ok) return;
setResolving(true);
try {
const updated = await resolveGearCollision(syncedSelected.id, {
action: resolveAction,
note: resolveNote || undefined,
});
setSelected(updated);
setResolveNote('');
await loadData();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : t('gearCollision.error.resolveFailed'));
} finally {
setResolving(false);
}
}, [syncedSelected, resolveAction, resolveNote, loadData, t]);
const statusCount = (code: string) => stats?.byStatus?.[code] ?? 0;
return (
<PageContainer>
<PageHeader
icon={AlertOctagon}
iconColor="text-orange-600 dark:text-orange-400"
title={t('gearCollision.title')}
description={t('gearCollision.desc')}
actions={
<Button
variant="outline"
size="sm"
onClick={loadData}
disabled={loading}
icon={<RefreshCw className="w-3.5 h-3.5" />}
>
{t('gearCollision.list.refresh')}
</Button>
}
/>
{error && (
<Card variant="default">
<CardContent className="text-destructive text-xs py-2">{error}</CardContent>
</Card>
)}
<Section title={t('gearCollision.stats.title')}>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
<StatCard label={t('gearCollision.stats.total')} value={stats?.total ?? 0} />
<StatCard
label={t('gearCollision.stats.open')}
value={statusCount('OPEN')}
intent="warning"
/>
<StatCard
label={t('gearCollision.stats.reviewed')}
value={statusCount('REVIEWED')}
intent="info"
/>
<StatCard
label={t('gearCollision.stats.confirmed')}
value={statusCount('CONFIRMED_ILLEGAL')}
intent="critical"
/>
<StatCard
label={t('gearCollision.stats.falsePositive')}
value={statusCount('FALSE_POSITIVE')}
intent="muted"
/>
</div>
</Section>
<Section title={t('gearCollision.list.title')}>
<div className="grid grid-cols-1 md:grid-cols-4 gap-2 mb-3">
<Select
aria-label={t('gearCollision.filters.status')}
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="">{t('gearCollision.filters.allStatus')}</option>
{GEAR_COLLISION_STATUS_ORDER.map((s) => (
<option key={s} value={s}>{getGearCollisionStatusLabel(s, t, lang)}</option>
))}
</Select>
<Select
aria-label={t('gearCollision.filters.severity')}
value={severityFilter}
onChange={(e) => setSeverityFilter(e.target.value)}
>
<option value="">{t('gearCollision.filters.allSeverity')}</option>
{SEVERITY_OPTIONS.map((sv) => (
<option key={sv} value={sv}>{getAlertLevelLabel(sv, tc, lang)}</option>
))}
</Select>
<Input
aria-label={t('gearCollision.filters.name')}
placeholder={t('gearCollision.filters.name')}
value={nameFilter}
onChange={(e) => setNameFilter(e.target.value)}
/>
<div className="flex items-center justify-end">
<Badge intent="info" size="sm">
{t('gearCollision.filters.hours')} · {DEFAULT_HOURS}h
</Badge>
</div>
</div>
{rows.length === 0 && !loading ? (
<p className="text-hint text-xs py-4 text-center">
{t('gearCollision.list.empty', { hours: DEFAULT_HOURS })}
</p>
) : (
<DataTable
data={rows as (GearCollision & Record<string, unknown>)[]}
columns={cols}
pageSize={20}
showSearch={false}
showExport={false}
showPrint={false}
onRowClick={(row) => setSelected(row as GearCollision)}
/>
)}
</Section>
{syncedSelected && (
<Section title={t('gearCollision.detail.title')}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5 text-xs">
<DetailRow label={t('gearCollision.columns.name')} value={syncedSelected.name} mono />
<DetailRow
label={t('gearCollision.columns.mmsiPair')}
value={`${syncedSelected.mmsiLo}${syncedSelected.mmsiHi}`}
mono
/>
<DetailRow
label={t('gearCollision.columns.parentName')}
value={syncedSelected.parentName ?? '-'}
/>
<DetailRow
label={t('gearCollision.detail.firstSeenAt')}
value={formatDateTime(syncedSelected.firstSeenAt)}
/>
<DetailRow
label={t('gearCollision.detail.lastSeenAt')}
value={formatDateTime(syncedSelected.lastSeenAt)}
/>
<DetailRow
label={t('gearCollision.columns.coexistenceCount')}
value={String(syncedSelected.coexistenceCount)}
/>
<DetailRow
label={t('gearCollision.detail.swapCount')}
value={String(syncedSelected.swapCount)}
/>
<DetailRow
label={t('gearCollision.columns.maxDistance')}
value={
syncedSelected.maxDistanceKm != null
? Number(syncedSelected.maxDistanceKm).toFixed(2)
: '-'
}
/>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-label">
{t('gearCollision.columns.severity')}:
</span>
<Badge intent={getAlertLevelIntent(syncedSelected.severity)} size="sm">
{getAlertLevelLabel(syncedSelected.severity, tc, lang)}
</Badge>
<span className="text-xs text-label ml-3">
{t('gearCollision.columns.status')}:
</span>
<Badge intent={getGearCollisionStatusIntent(syncedSelected.status)} size="sm">
{getGearCollisionStatusLabel(syncedSelected.status, t, lang)}
</Badge>
</div>
{syncedSelected.resolutionNote && (
<p className="text-xs text-hint border-l-2 border-border pl-2">
{syncedSelected.resolutionNote}
</p>
)}
<div className="space-y-1.5">
<label
htmlFor="gc-resolve-action"
className="block text-xs text-label"
>
{t('gearCollision.resolve.title')}
</label>
<Select
id="gc-resolve-action"
aria-label={t('gearCollision.resolve.title')}
value={resolveAction}
onChange={(e) => setResolveAction(e.target.value as GearCollisionResolveAction)}
>
<option value="REVIEWED">{t('gearCollision.resolve.reviewed')}</option>
<option value="CONFIRMED_ILLEGAL">
{t('gearCollision.resolve.confirmedIllegal')}
</option>
<option value="FALSE_POSITIVE">
{t('gearCollision.resolve.falsePositive')}
</option>
<option value="REOPEN">{t('gearCollision.resolve.reopen')}</option>
</Select>
<Textarea
aria-label={t('gearCollision.resolve.note')}
placeholder={t('gearCollision.resolve.notePlaceholder')}
value={resolveNote}
onChange={(e) => setResolveNote(e.target.value)}
rows={3}
/>
<div className="flex gap-2 justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => { setSelected(null); setResolveNote(''); }}
>
{t('gearCollision.resolve.cancel')}
</Button>
<Button
variant="primary"
size="sm"
onClick={handleResolve}
disabled={resolving}
>
{t('gearCollision.resolve.submit')}
</Button>
</div>
</div>
</div>
</div>
</Section>
)}
</PageContainer>
);
}
// ─── 내부 컴포넌트 ─────────────
interface StatCardProps {
label: string;
value: number;
intent?: 'warning' | 'info' | 'critical' | 'muted';
}
function StatCard({ label, value, intent }: StatCardProps) {
return (
<Card variant="default">
<CardContent className="py-3 flex flex-col items-center gap-1">
<span className="text-[10px] text-hint">{label}</span>
{intent ? (
<Badge intent={intent} size="md">
{value}
</Badge>
) : (
<span className="text-lg font-bold text-heading">{value}</span>
)}
</CardContent>
</Card>
);
}
interface DetailRowProps {
label: string;
value: string;
mono?: boolean;
}
function DetailRow({ label, value, mono }: DetailRowProps) {
return (
<div className="flex items-center gap-2">
<span className="text-hint w-24 shrink-0">{label}</span>
<span className={mono ? 'font-mono text-label' : 'text-label'}>{value}</span>
</div>
);
}
export default GearCollisionDetection;

파일 보기

@ -2,65 +2,22 @@ import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { Checkbox } from '@shared/components/ui/checkbox';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { Anchor, AlertTriangle, Loader2, Filter, X } from 'lucide-react';
import type { MapboxOverlay } from '@deck.gl/mapbox';
import {
BaseMap, createStaticLayers,
createGeoJsonLayer, createGearPolygonLayer,
createShipIconLayer, createGearIconLayer,
type MapHandle,
type ShipIconData, type GearIconData,
} from '@lib/map';
import { Anchor, AlertTriangle, Loader2 } from 'lucide-react';
import { BaseMap, createStaticLayers, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { MarkerData } from '@lib/map';
import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi';
import { formatDate } from '@shared/utils/dateFormat';
import { getPermitStatusIntent, getPermitStatusLabel, getGearJudgmentIntent } from '@shared/constants/permissionStatuses';
import { getAlertLevelHex } from '@shared/constants/alertLevels';
import { useSettingsStore } from '@stores/settingsStore';
import { getZoneCodeIntent, getZoneCodeLabel, getZoneAllowedGears } from '@shared/constants/zoneCodes';
import { getGearViolationIntent } from '@shared/constants/gearViolationCodes';
import { getGearGroupTypeLabel } from '@shared/constants/gearGroupTypes';
import { GearDetailPanel } from './components/GearDetailPanel';
import { GearReplayController } from './components/GearReplayController';
import { useGearReplayStore } from '@stores/gearReplayStore';
import { useGearReplayLayers } from '@/hooks/useGearReplayLayers';
import fishingZonesGeoJson from '@lib/map/data/fishing-zones-wgs84.json';
/* SFR-10: 불법 어망·어구 탐지 및 관리 */
type Gear = {
id: string;
groupKey: string;
type: string; // 어구 그룹 유형 (구역 내/외)
owner: string; // 모선 MMSI 또는 그룹 라벨
zone: string; // 수역 코드
status: string;
permit: string;
installed: string;
lastSignal: string;
risk: string;
lat: number;
lng: number;
parentStatus: string;
parentMmsi: string;
confidence: string;
memberCount: number;
members: Array<{ mmsi: string; name?: string; lat?: number; lon?: number; role?: string; isParent?: boolean }>;
// 폴리곤 원본
polygon: unknown;
// G코드 위반 정보
gCodes: string[];
gearViolationScore: number;
gearViolationEvidence: Record<string, Record<string, unknown>>;
pairTrawlDetected: boolean;
pairTrawlPairMmsi: string;
allowedGears: string[];
topScore: number; // 최대 후보 일치율 (0~1)
};
type Gear = { id: string; type: string; owner: string; zone: string; status: string; permit: string; installed: string; lastSignal: string; risk: string; lat: number; lng: number; parentStatus: string; parentMmsi: string; confidence: string; [key: string]: unknown; };
// 한글 위험도 → AlertLevel hex 매핑
const RISK_HEX: Record<string, string> = {
'고위험': getAlertLevelHex('CRITICAL'),
'중위험': getAlertLevelHex('MEDIUM'),
@ -80,31 +37,14 @@ function deriveStatus(g: GearGroupItem): string {
return '확인 중';
}
/** 그룹 유형에서 수역 코드 추론 (backend가 zoneCode를 미제공하므로 groupType 기반) */
function deriveZone(g: GearGroupItem): string {
if (g.groupType === 'GEAR_OUT_ZONE') return 'EEZ_OR_BEYOND';
// GEAR_IN_ZONE: 위치 기반 추론 — 위도/경도로 대략적 수역 판별
const lat = g.centerLat;
const lon = g.centerLon;
if (lat > 37.0 && lon > 129.0) return 'ZONE_I'; // 동해
if (lat < 34.0 && lon > 127.0) return 'ZONE_II'; // 남해
if (lat < 35.5 && lon < 127.0) return 'ZONE_III'; // 서남해
if (lat >= 35.5 && lon < 126.5) return 'ZONE_IV'; // 서해
return 'ZONE_III'; // 기본값
}
function mapGroupToGear(g: GearGroupItem, idx: number, t: (k: string, opts?: { defaultValue?: string }) => string, lang: 'ko' | 'en'): Gear {
function mapGroupToGear(g: GearGroupItem, idx: number): Gear {
const risk = deriveRisk(g);
const status = deriveStatus(g);
const zone = deriveZone(g);
// 그룹명: 항상 groupLabel/groupKey 사용 (모선 후보 MMSI는 별도 칼럼)
const owner = g.groupLabel || g.groupKey;
return {
id: `G-${String(idx + 1).padStart(3, '0')}`,
groupKey: g.groupKey,
type: getGearGroupTypeLabel(g.groupType, t, lang),
owner,
zone,
type: g.groupLabel || (g.groupType === 'GEAR_IN_ZONE' ? '지정해역 어구' : '지정해역 외 어구'),
owner: g.members[0]?.name || g.members[0]?.mmsi || '-',
zone: g.groupType === 'GEAR_IN_ZONE' ? '지정해역' : '지정해역 외',
status,
permit: 'NONE',
installed: formatDate(g.snapshotTime),
@ -114,56 +54,10 @@ function mapGroupToGear(g: GearGroupItem, idx: number, t: (k: string, opts?: { d
lng: g.centerLon,
parentStatus: g.resolution?.status ?? '-',
parentMmsi: g.resolution?.selectedParentMmsi ?? '-',
confidence: (g.candidateCount ?? 0) > 0 ? `${g.candidateCount}` : '-',
memberCount: g.memberCount ?? 0,
members: g.members ?? [],
polygon: g.polygon,
gCodes: [],
gearViolationScore: 0,
gearViolationEvidence: {},
pairTrawlDetected: false,
pairTrawlPairMmsi: '',
allowedGears: getZoneAllowedGears(zone),
topScore: Math.max(g.liveTopScore ?? 0, g.resolution?.topScore ?? 0),
confidence: g.candidateCount != null ? `${g.candidateCount}` : '-',
};
}
/** 필터 그룹 내 체크박스 목록 */
function FilterCheckGroup({ label, selected, onChange, options }: {
label: string;
selected: Set<string>;
onChange: (v: Set<string>) => void;
options: { value: string; label: string }[];
}) {
const toggle = (v: string) => {
const next = new Set(selected);
if (next.has(v)) next.delete(v); else next.add(v);
onChange(next);
};
return (
<div className="space-y-1">
<div className="text-[10px] text-hint font-medium">{label} {selected.size > 0 && <span className="text-primary">({selected.size})</span>}</div>
<div className="flex flex-wrap gap-x-3 gap-y-1">
{options.map(o => (
<Checkbox
key={o.value}
checked={selected.has(o.value)}
onChange={() => toggle(o.value)}
label={o.label}
className="w-3 h-3"
/>
))}
</div>
</div>
);
}
function ReplayOverlay() {
const groupKey = useGearReplayStore(s => s.groupKey);
if (!groupKey) return null;
return <GearReplayController onClose={() => useGearReplayStore.getState().reset()} />;
}
export function GearDetection() {
const { t } = useTranslation('detection');
const { t: tc } = useTranslation('common');
@ -171,110 +65,43 @@ export function GearDetection() {
const cols: DataColumn<Gear>[] = useMemo(() => [
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'type', label: '그룹 유형', width: '100px', sortable: true, render: v => <span className="text-heading font-medium text-[11px]">{v as string}</span> },
{ key: 'owner', label: '어구 그룹', sortable: true,
render: v => <span className="text-cyan-600 dark:text-cyan-400 font-mono text-[11px]">{v as string}</span> },
{ key: 'memberCount', label: '멤버', width: '50px', align: 'center',
render: v => <span className="font-mono text-[10px] text-label">{v as number}</span> },
{ key: 'zone', label: '설치 해역', width: '130px', sortable: true,
render: (v: unknown) => (
<Badge intent={getZoneCodeIntent(v as string)} size="sm">
{getZoneCodeLabel(v as string, t, lang)}
</Badge>
) },
{ key: 'permit', label: '허가', width: '70px', align: 'center',
{ key: 'type', label: '어구 유형', width: '100px', sortable: true, render: v => <span className="text-heading font-medium">{v as string}</span> },
{ key: 'owner', label: '소유 선박', sortable: true, render: v => <span className="text-cyan-400">{v as string}</span> },
{ key: 'zone', label: '설치 해역', width: '90px', sortable: true },
{ key: 'permit', label: '허가 상태', width: '80px', align: 'center',
render: v => <Badge intent={getPermitStatusIntent(v as string)} size="sm">{getPermitStatusLabel(v as string, tc, lang)}</Badge> },
{ key: 'status', label: '판정', width: '80px', align: 'center', sortable: true,
render: v => <Badge intent={getGearJudgmentIntent(v as string)} size="sm">{v as string}</Badge> },
{ key: 'gCodes', label: 'G코드', width: '100px',
render: (_: unknown, row: Gear) => row.gCodes.length > 0 ? (
<div className="flex flex-wrap gap-0.5">
{row.gCodes.map(code => (
<Badge key={code} intent={getGearViolationIntent(code)} size="sm">{code}</Badge>
))}
</div>
) : <span className="text-hint text-[10px]">-</span> },
{ key: 'risk', label: '위험도', width: '65px', align: 'center', sortable: true,
render: v => {
const r = v as string;
const c = r === '고위험' ? 'text-red-600 dark:text-red-400'
: r === '중위험' ? 'text-yellow-600 dark:text-yellow-400'
: 'text-green-600 dark:text-green-400';
return <span className={`text-[10px] font-bold ${c}`}>{r}</span>;
} },
{ key: 'parentStatus', label: '모선 상태', width: '90px', sortable: true,
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
render: v => { const r = v as string; const c = r === '고위험' ? 'text-red-400' : r === '중위험' ? 'text-yellow-400' : 'text-green-400'; return <span className={`text-[10px] font-bold ${c}`}>{r}</span>; } },
{ key: 'parentStatus', label: '모선 상태', width: '100px', sortable: true,
render: v => {
const s = v as string;
const intent = s === 'DIRECT_PARENT_MATCH' ? 'success' : s === 'AUTO_PROMOTED' ? 'info' : s === 'REVIEW_REQUIRED' ? 'warning' : 'muted';
const intent = s === 'DIRECT_PARENT_MATCH' ? 'success' : s === 'AUTO_PROMOTED' ? 'info' : s === 'REVIEW_REQUIRED' ? 'warning' : s === 'UNRESOLVED' ? 'muted' : 'muted';
const label = s === 'DIRECT_PARENT_MATCH' ? '직접매칭' : s === 'AUTO_PROMOTED' ? '자동승격' : s === 'REVIEW_REQUIRED' ? '심사필요' : s === 'UNRESOLVED' ? '미결정' : s;
return <Badge intent={intent} size="sm">{label}</Badge>;
} },
{ key: 'parentMmsi', label: '추정 모선', width: '100px',
render: v => { const m = v as string; return m !== '-' ? <span className="text-cyan-600 dark:text-cyan-400 font-mono text-[10px]">{m}</span> : <span className="text-hint">-</span>; } },
{ key: 'topScore', label: '후보 일치', width: '75px', align: 'center', sortable: true,
render: (v: unknown) => {
const s = v as number;
if (s <= 0) return <span className="text-hint text-[10px]">-</span>;
const pct = Math.round(s * 100);
const c = pct >= 72 ? 'text-green-600 dark:text-green-400' : pct >= 50 ? 'text-yellow-600 dark:text-yellow-400' : 'text-hint';
return <span className={`font-mono text-[10px] font-bold ${c}`}>{pct}%</span>;
} },
render: v => { const m = v as string; return m !== '-' ? <span className="text-cyan-400 font-mono text-[10px]">{m}</span> : <span className="text-hint">-</span>; } },
{ key: 'confidence', label: '후보', width: '50px', align: 'center',
render: v => <span className="font-mono text-[10px] text-label">{v as string}</span> },
{ key: 'lastSignal', label: '최종 신호', width: '80px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
], [t, tc, lang]);
], [tc, lang]);
const [groups, setGroups] = useState<GearGroupItem[]>([]);
const [serviceAvailable, setServiceAvailable] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
// ── 필터 상태 (다중 선택, localStorage 영속화) ──
const [filterOpen, setFilterOpen] = useState(() => {
try { return JSON.parse(localStorage.getItem('kcg-gear-filter-open') ?? 'false'); } catch { return false; }
});
const [filterZone, setFilterZone] = useState<Set<string>>(() => {
try { return new Set(JSON.parse(localStorage.getItem('kcg-gear-fz') ?? '[]')); } catch { return new Set(); }
});
const [filterStatus, setFilterStatus] = useState<Set<string>>(() => {
try { return new Set(JSON.parse(localStorage.getItem('kcg-gear-fs') ?? '[]')); } catch { return new Set(); }
});
const [filterRisk, setFilterRisk] = useState<Set<string>>(() => {
try { return new Set(JSON.parse(localStorage.getItem('kcg-gear-fr') ?? '[]')); } catch { return new Set(); }
});
const [filterParentStatus, setFilterParentStatus] = useState<Set<string>>(() => {
try { return new Set(JSON.parse(localStorage.getItem('kcg-gear-fps') ?? '[]')); } catch { return new Set(); }
});
const [filterPermit, setFilterPermit] = useState<Set<string>>(() => {
try { return new Set(JSON.parse(localStorage.getItem('kcg-gear-fp') ?? '[]')); } catch { return new Set(); }
});
const [filterMemberMin, setFilterMemberMin] = useState(() => {
try { return JSON.parse(localStorage.getItem('kcg-gear-fmn') ?? '2'); } catch { return 2; }
});
const [filterMemberMax, setFilterMemberMax] = useState(() => {
try { const v = localStorage.getItem('kcg-gear-fmx'); return v ? JSON.parse(v) : Infinity; } catch { return Infinity; }
});
const checkFilterCount = filterZone.size + filterStatus.size + filterRisk.size + filterParentStatus.size + filterPermit.size;
// localStorage 동기화
useEffect(() => {
localStorage.setItem('kcg-gear-filter-open', JSON.stringify(filterOpen));
localStorage.setItem('kcg-gear-fz', JSON.stringify([...filterZone]));
localStorage.setItem('kcg-gear-fs', JSON.stringify([...filterStatus]));
localStorage.setItem('kcg-gear-fr', JSON.stringify([...filterRisk]));
localStorage.setItem('kcg-gear-fps', JSON.stringify([...filterParentStatus]));
localStorage.setItem('kcg-gear-fp', JSON.stringify([...filterPermit]));
localStorage.setItem('kcg-gear-fmn', JSON.stringify(filterMemberMin));
if (filterMemberMax !== Infinity) localStorage.setItem('kcg-gear-fmx', JSON.stringify(filterMemberMax));
else localStorage.removeItem('kcg-gear-fmx');
}, [filterOpen, filterZone, filterStatus, filterRisk, filterParentStatus, filterPermit, filterMemberMin, filterMemberMax]);
const loadData = useCallback(async () => {
setLoading(true);
setError('');
try {
const res = await fetchGroups('GEAR');
const res = await fetchGroups();
setServiceAvailable(res.serviceAvailable);
setGroups(res.items);
setGroups(res.items.filter(
(i) => i.groupType === 'GEAR_IN_ZONE' || i.groupType === 'GEAR_OUT_ZONE',
));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다');
setServiceAvailable(false);
@ -286,175 +113,39 @@ export function GearDetection() {
useEffect(() => { loadData(); }, [loadData]);
const DATA: Gear[] = useMemo(
() => groups.map((g, i) => mapGroupToGear(g, i, t, lang)),
[groups, t, lang],
);
// 필터 옵션 (고유값 추출)
const filterOptions = useMemo(() => ({
zones: [...new Set(DATA.map(d => d.zone))].sort(),
statuses: [...new Set(DATA.map(d => d.status))],
risks: [...new Set(DATA.map(d => d.risk))],
parentStatuses: [...new Set(DATA.map(d => d.parentStatus))],
permits: [...new Set(DATA.map(d => d.permit))],
maxMember: DATA.reduce((max, d) => Math.max(max, d.memberCount), 0),
}), [DATA]);
const hasActiveFilter = checkFilterCount > 0 || (filterMemberMin > 2 || (filterMemberMax !== Infinity && filterMemberMax < filterOptions.maxMember));
// 데이터 로드 후 멤버 슬라이더 최대값 초기화
useEffect(() => {
if (filterOptions.maxMember > 0 && filterMemberMax === Infinity) {
setFilterMemberMax(filterOptions.maxMember);
}
}, [filterOptions.maxMember, filterMemberMax]);
// ── 필터 적용 ──
const filteredData = useMemo(() => {
const effMax = filterMemberMax === Infinity ? filterOptions.maxMember : filterMemberMax;
return DATA.filter(d =>
(filterZone.size === 0 || filterZone.has(d.zone)) &&
(filterStatus.size === 0 || filterStatus.has(d.status)) &&
(filterRisk.size === 0 || filterRisk.has(d.risk)) &&
(filterParentStatus.size === 0 || filterParentStatus.has(d.parentStatus)) &&
(filterPermit.size === 0 || filterPermit.has(d.permit)) &&
d.memberCount >= filterMemberMin && d.memberCount <= effMax,
);
}, [DATA, filterZone, filterStatus, filterRisk, filterParentStatus, filterPermit, filterMemberMin, filterMemberMax, filterOptions.maxMember]);
const selectedGear = useMemo(
() => DATA.find(g => g.id === selectedId) ?? null,
[DATA, selectedId],
() => groups.map((g, i) => mapGroupToGear(g, i)),
[groups],
);
const mapRef = useRef<MapHandle>(null);
// overlay Proxy ref — mapRef.current.overlay를 항상 최신으로 참조
// 리플레이 훅이 overlay.setProps() 직접 호출 (단일 렌더링 경로)
const overlayRef = useMemo<React.RefObject<MapboxOverlay | null>>(() => ({
get current() { return mapRef.current?.overlay ?? null; },
}), []);
const buildLayers = useCallback(() => [
...createStaticLayers(),
createRadiusLayer(
'gear-radius',
DATA.filter(g => g.risk === '고위험').map(g => ({
lat: g.lat,
lng: g.lng,
radius: 6000,
color: RISK_HEX[g.risk] || "#64748b",
})),
0.1,
),
createMarkerLayer(
'gear-markers',
DATA.map(g => ({
lat: g.lat,
lng: g.lng,
color: RISK_HEX[g.risk] || "#64748b",
radius: g.risk === '고위험' ? 1200 : 800,
label: `${g.id} ${g.type}`,
} as MarkerData)),
),
], [DATA]);
const replayGroupKey = useGearReplayStore(s => s.groupKey);
const isReplayActive = !!replayGroupKey;
const buildLayers = useCallback(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const layers: any[] = [
// 1. 정적 레이어 (EEZ + NLL)
...createStaticLayers(),
// 2. 특정해역 I~IV 폴리곤 (항상 표시)
createGeoJsonLayer('fishing-zones', fishingZonesGeoJson, '#6366f1', {
fillOpacity: 15,
lineWidth: 2,
pickable: false,
}),
];
if (isReplayActive) {
// ── 리플레이 모드: 정적 레이어만 (리플레이 훅이 나머지 직접 관리) ──
// 비선택 어구 그룹 중심 (흐린 마름모) — 위치 참조용
layers.push(createGearIconLayer(
'gear-dim',
DATA.filter(g => g.groupKey !== replayGroupKey).map(g => ({
lat: g.lat, lon: g.lng, color: '#475569', size: 10,
} as GearIconData)),
));
} else if (selectedId) {
// ── 선택 모드 (리플레이 비활성) ──
const sel = DATA.find(g => g.id === selectedId);
// 선택된 어구 그룹 폴리곤 강조
if (sel?.polygon) {
layers.push(createGearPolygonLayer('gear-polygon-selected', [{
polygon: sel.polygon,
color: '#f59e0b',
label: `${sel.id} ${sel.type}`,
risk: sel.risk,
}]));
}
// 멤버 아이콘: 선박(PARENT)=삼각형+COG, 어구(GEAR)=마름모
if (sel && sel.members.length > 0) {
const ships = sel.members.filter(m => m.lat != null && m.lon != null && (m.isParent || m.role === 'PARENT'));
const gears = sel.members.filter(m => m.lat != null && m.lon != null && !m.isParent && m.role !== 'PARENT');
if (ships.length > 0) {
layers.push(createShipIconLayer('sel-ships', ships.map(m => ({
lat: m.lat!, lon: m.lon!,
cog: (m as Record<string, unknown>).cog as number | undefined,
color: '#06b6d4', size: 28,
label: `${m.mmsi} ${m.name ?? ''}`,
isParent: true,
} as ShipIconData))));
}
if (gears.length > 0) {
layers.push(createGearIconLayer('sel-gears', gears.map(m => ({
lat: m.lat!, lon: m.lon!,
color: '#f59e0b', size: 18,
label: `${m.mmsi} ${m.name ?? ''}`,
} as GearIconData))));
}
}
// 비선택 어구 그룹 중심 (흐린 마름모)
layers.push(createGearIconLayer(
'gear-dim',
DATA.filter(g => g.id !== selectedId).map(g => ({
lat: g.lat, lon: g.lng, color: '#475569', size: 10,
} as GearIconData)),
));
} else {
// ── 기본 모드: 모든 어구 폴리곤 + 아이콘 ──
layers.push(createGearPolygonLayer(
'gear-polygons',
DATA.filter(g => g.polygon != null).map(g => ({
polygon: g.polygon,
color: RISK_HEX[g.risk] || '#64748b',
label: `${g.id} ${g.type}`,
risk: g.risk,
})),
));
// 어구 그룹 중심 마름모 아이콘
layers.push(createGearIconLayer(
'gear-center-icons',
DATA.map(g => ({
lat: g.lat, lon: g.lng,
color: RISK_HEX[g.risk] || '#64748b',
size: g.risk === '고위험' ? 20 : 14,
label: `${g.id} ${g.owner}`,
} as GearIconData)),
));
}
return layers;
}, [DATA, selectedId, isReplayActive, replayGroupKey]);
// 리플레이 비활성 시만 useMapLayers가 overlay 제어
// 리플레이 활성 시 useGearReplayLayers가 overlay 독점 (단일 렌더링 경로)
useEffect(() => {
if (isReplayActive) return; // replay hook이 overlay 독점
const raf = requestAnimationFrame(() => {
mapRef.current?.overlay?.setProps({ layers: buildLayers() });
});
return () => cancelAnimationFrame(raf);
}, [buildLayers, isReplayActive]);
useGearReplayLayers(overlayRef, buildLayers);
// 수역별 통계
const zoneStats = useMemo(() => {
const stats: Record<string, number> = {};
filteredData.forEach(d => { stats[d.zone] = (stats[d.zone] || 0) + 1; });
return stats;
}, [filteredData]);
useMapLayers(mapRef, buildLayers, [DATA]);
return (
<>
<GearDetailPanel gear={selectedGear} onClose={() => setSelectedId(null)} />
<PageContainer>
<PageHeader
icon={Anchor}
@ -466,11 +157,13 @@ export function GearDetection() {
{!serviceAvailable && (
<div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 text-yellow-400 text-xs">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>AI - </span>
<span>iran - </span>
</div>
)}
{error && <div className="text-xs text-red-600 dark:text-red-400">{tc('error.errorPrefix', { msg: error })}</div>}
{error && (
<div className="text-xs text-red-400">: {error}</div>
)}
{loading && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
@ -478,175 +171,45 @@ export function GearDetection() {
</div>
)}
{/* 요약 배지 */}
<div className="flex gap-2 flex-wrap">
<div className="flex gap-2">
{[
{ l: '전체 어구 그룹', v: filteredData.length, c: 'text-heading' },
{ l: '불법 의심', v: filteredData.filter(d => d.status.includes('불법')).length, c: 'text-red-600 dark:text-red-400' },
{ l: '확인 중', v: filteredData.filter(d => d.status === '확인 중').length, c: 'text-yellow-600 dark:text-yellow-400' },
{ l: '정상', v: filteredData.filter(d => d.status === '정상').length, c: 'text-green-600 dark:text-green-400' },
{ l: '전체 어구 그룹', v: DATA.length, c: 'text-heading' },
{ l: '불법 의심', v: DATA.filter(d => d.status.includes('불법')).length, c: 'text-red-400' },
{ l: '확인 중', v: DATA.filter(d => d.status === '확인 중').length, c: 'text-yellow-400' },
{ l: '정상', v: DATA.filter(d => d.status === '정상').length, c: 'text-green-400' },
].map(k => (
<div key={k.l} className="flex-1 min-w-[100px] flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
<div key={k.l} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
<span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span>
</div>
))}
</div>
{/* 수역별 분포 */}
<div className="flex gap-1.5 flex-wrap">
{Object.entries(zoneStats).sort(([,a],[,b]) => b - a).map(([zone, cnt]) => (
<Badge key={zone} intent={getZoneCodeIntent(zone)} size="sm">
{getZoneCodeLabel(zone, t, lang)} {cnt}
</Badge>
))}
</div>
{/* 필터 토글 버튼 */}
<div className="flex items-center gap-2">
<button type="button" onClick={() => setFilterOpen(!filterOpen)} aria-label={tc('aria.filterToggle')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-[11px] border rounded-lg transition-colors ${
hasActiveFilter
? 'bg-primary/10 border-primary/40 text-heading'
: 'bg-surface-raised border-border text-label hover:border-primary/50'
}`}>
<Filter className="w-3.5 h-3.5" />
{hasActiveFilter && (
<span className="bg-primary text-on-vivid rounded-full px-1.5 py-0.5 text-[9px] font-bold leading-none">
{filterZone.size + filterStatus.size + filterRisk.size + filterParentStatus.size + filterPermit.size}
</span>
)}
</button>
{hasActiveFilter && (
<>
<span className="text-[10px] text-hint">{filteredData.length}/{DATA.length}</span>
<Button
variant="ghost"
size="sm"
aria-label={tc('aria.filterReset')}
onClick={() => { setFilterZone(new Set()); setFilterStatus(new Set()); setFilterRisk(new Set()); setFilterParentStatus(new Set()); setFilterPermit(new Set()); setFilterMemberMin(2); setFilterMemberMax(filterOptions.maxMember || Infinity); }}
icon={<X className="w-3 h-3" />}
>
</Button>
</>
)}
</div>
{/* 필터 패널 (접기/펼치기) */}
{filterOpen && (
<div className="bg-surface-raised border border-border rounded-lg p-3 space-y-3">
<FilterCheckGroup label="설치 해역" selected={filterZone} onChange={setFilterZone}
options={filterOptions.zones.map(z => ({ value: z, label: getZoneCodeLabel(z, t, lang) }))} />
<FilterCheckGroup label="판정" selected={filterStatus} onChange={setFilterStatus}
options={filterOptions.statuses.map(s => ({ value: s, label: s }))} />
<FilterCheckGroup label="위험도" selected={filterRisk} onChange={setFilterRisk}
options={filterOptions.risks.map(r => ({ value: r, label: r }))} />
<FilterCheckGroup label="모선 상태" selected={filterParentStatus} onChange={setFilterParentStatus}
options={filterOptions.parentStatuses.map(s => ({
value: s,
label: s === 'DIRECT_PARENT_MATCH' ? '직접매칭' : s === 'AUTO_PROMOTED' ? '자동승격'
: s === 'REVIEW_REQUIRED' ? '심사필요' : s === 'UNRESOLVED' ? '미결정' : s,
}))} />
<FilterCheckGroup label="허가" selected={filterPermit} onChange={setFilterPermit}
options={filterOptions.permits.map(p => ({ value: p, label: getPermitStatusLabel(p, tc, lang) }))} />
{/* 멤버 수 범위 슬라이더 */}
{filterOptions.maxMember > 2 && (
<div className="space-y-1">
<div className="text-[10px] text-hint font-medium">
<span className="text-label font-bold">{filterMemberMin}~{filterMemberMax === Infinity ? filterOptions.maxMember : filterMemberMax}</span>
</div>
<div className="flex items-center gap-3">
<span className="text-[9px] text-hint w-6 text-right">{filterMemberMin}</span>
<input type="range" min={2} max={filterOptions.maxMember}
value={filterMemberMin} onChange={e => setFilterMemberMin(Number(e.target.value))}
aria-label={tc('aria.memberCountMin')}
className="flex-1 h-1 accent-primary cursor-pointer" />
<input type="range" min={2} max={filterOptions.maxMember}
value={filterMemberMax === Infinity ? filterOptions.maxMember : filterMemberMax}
onChange={e => setFilterMemberMax(Number(e.target.value))}
aria-label={tc('aria.memberCountMax')}
className="flex-1 h-1 accent-primary cursor-pointer" />
<span className="text-[9px] text-hint w-6">{filterMemberMax === Infinity ? filterOptions.maxMember : filterMemberMax}</span>
</div>
</div>
)}
{/* 패널 내 초기화 */}
<div className="pt-2 border-t border-border flex items-center justify-between">
<span className="text-[10px] text-hint">{filteredData.length}/{DATA.length} </span>
<Button
variant="ghost"
size="sm"
aria-label={tc('aria.filterReset')}
onClick={() => { setFilterZone(new Set()); setFilterStatus(new Set()); setFilterRisk(new Set()); setFilterParentStatus(new Set()); setFilterPermit(new Set()); setFilterMemberMin(2); setFilterMemberMax(filterOptions.maxMember || Infinity); }}
icon={<X className="w-3 h-3" />}
>
</Button>
</div>
</div>
)}
<DataTable
data={filteredData}
columns={cols}
pageSize={10}
searchPlaceholder="그룹유형, 모선, 해역 검색..."
searchKeys={['type', 'owner', 'zone', 'groupKey']}
exportFilename="어구탐지"
onRowClick={(row: Gear) => {
const newId = row.id === selectedId ? null : row.id;
setSelectedId(newId);
// 선택 시 지도 중심 이동
if (newId) {
const gear = DATA.find(g => g.id === newId);
if (gear && mapRef.current?.map) {
mapRef.current.map.flyTo({
center: [gear.lng, gear.lat],
zoom: 10,
duration: 1000,
});
}
}
}}
/>
<DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="어구유형, 소유선박, 해역 검색..." searchKeys={['type', 'owner', 'zone']} exportFilename="어구탐지" />
{/* 어구 탐지 위치 지도 */}
<Card>
<CardContent className="p-0 relative">
<BaseMap ref={mapRef} center={[34.5, 126.0]} zoom={7} height={500} className="rounded-lg overflow-hidden" />
<BaseMap ref={mapRef} center={[36.5, 127.0]} zoom={7} height={450} className="rounded-lg overflow-hidden" />
{/* 범례 */}
<div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
<div className="text-[9px] text-muted-foreground font-bold mb-1.5"></div>
<div className="text-[9px] text-muted-foreground font-bold mb-1.5"> </div>
<div className="space-y-1">
<div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-red-500" /><span className="text-[8px] text-muted-foreground"> </span></div>
<div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-red-500" /><span className="text-[8px] text-muted-foreground"> ( /)</span></div>
<div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-yellow-500" /><span className="text-[8px] text-muted-foreground"> ( )</span></div>
<div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-green-500" /><span className="text-[8px] text-muted-foreground"> ()</span></div>
</div>
<div className="mt-1.5 pt-1.5 border-t border-border space-y-1">
<div className="text-[8px] text-muted-foreground font-bold"></div>
<div className="flex items-center gap-1.5"><div className="w-3 h-2 rounded-sm bg-purple-500/30 border border-purple-500/60" /><span className="text-[8px] text-muted-foreground"> I ()</span></div>
<div className="flex items-center gap-1.5"><div className="w-3 h-2 rounded-sm bg-blue-500/30 border border-blue-500/60" /><span className="text-[8px] text-muted-foreground"> II ()</span></div>
<div className="flex items-center gap-1.5"><div className="w-3 h-2 rounded-sm bg-cyan-500/30 border border-cyan-500/60" /><span className="text-[8px] text-muted-foreground"> III ()</span></div>
<div className="flex items-center gap-1.5"><div className="w-3 h-2 rounded-sm bg-amber-500/30 border border-amber-500/60" /><span className="text-[8px] text-muted-foreground"> IV ()</span></div>
</div>
<div className="flex items-center gap-3 mt-1.5 pt-1.5 border-t border-border">
<div className="flex items-center gap-1"><div className="w-3 h-0 border-t border-dashed border-red-500/50" /><span className="text-[7px] text-hint">EEZ</span></div>
<div className="flex items-center gap-1"><div className="w-3 h-0 border-t border-dashed border-orange-500/60" /><span className="text-[7px] text-hint">NLL</span></div>
</div>
</div>
<div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
<Anchor className="w-3.5 h-3.5 text-orange-600 dark:text-orange-400" />
<span className="text-[10px] text-orange-600 dark:text-orange-400 font-bold">{DATA.length}</span>
<span className="text-[9px] text-hint"> </span>
<Anchor className="w-3.5 h-3.5 text-orange-400" />
<span className="text-[10px] text-orange-400 font-bold">{DATA.length}</span>
<span className="text-[9px] text-hint"> </span>
</div>
{/* 리플레이 컨트롤러 (활성 시 표시) */}
<ReplayOverlay />
</CardContent>
</Card>
</PageContainer>
</>
);
}

파일 보기

@ -1,17 +1,12 @@
import { useCallback, useEffect, useState } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Search, Anchor, Ship, AlertTriangle, CheckCircle, XCircle,
ChevronRight, Info, Shield, Radar, Target, Waves,
ArrowRight, Zap, HelpCircle, Loader2, RefreshCw
Search, Anchor, Ship, Eye, AlertTriangle, CheckCircle, XCircle,
ChevronRight, ChevronDown, Info, Shield, Radar, Target, Waves,
ArrowRight, Flag, Zap, HelpCircle
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { getAlertLevelIntent, isValidAlertLevel } from '@shared/constants/alertLevels';
import { getZoneCodeLabel } from '@shared/constants/zoneCodes';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getGearDetections, type GearDetection } from '@/services/analysisApi';
// ─── 판별 기준 데이터 ─────────────────
@ -574,7 +569,7 @@ function GearComparisonTable() {
<Card>
<CardHeader className="px-4 pt-3 pb-0">
<CardTitle className="text-xs text-label flex items-center gap-1.5">
<Info className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
<Info className="w-3.5 h-3.5 text-blue-400" />
· (GB/T 5147-2003 )
</CardTitle>
</CardHeader>
@ -594,18 +589,18 @@ function GearComparisonTable() {
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="text-[9px] text-red-600 dark:text-red-400 font-medium mb-1"> </div>
<div className="text-[9px] text-red-400 font-medium mb-1"> </div>
{row.chinaFeatures.map((f, i) => (
<div key={i} className="text-[10px] text-muted-foreground flex items-start gap-1">
<span className="text-red-600 dark:text-red-500 mt-0.5 shrink-0">-</span>{f}
<span className="text-red-500 mt-0.5 shrink-0">-</span>{f}
</div>
))}
</div>
<div>
<div className="text-[9px] text-blue-600 dark:text-blue-400 font-medium mb-1"> </div>
<div className="text-[9px] text-blue-400 font-medium mb-1"> </div>
{row.koreaFeatures.map((f, i) => (
<div key={i} className="text-[10px] text-muted-foreground flex items-start gap-1">
<span className="text-blue-600 dark:text-blue-500 mt-0.5 shrink-0">-</span>{f}
<span className="text-blue-500 mt-0.5 shrink-0">-</span>{f}
</div>
))}
</div>
@ -624,25 +619,11 @@ function GearComparisonTable() {
// ─── 메인 페이지 ──────────────────────
// gearCode → gearCategory 매핑 (자동탐지 → 입력 폼 프리필용)
const GEAR_CODE_CATEGORY: Record<string, GearType> = {
C21: 'trawl', C22: 'trawl', PT: 'trawl', OT: 'trawl', TRAWL: 'trawl',
C23: 'purseSeine', PS: 'purseSeine', PURSE: 'purseSeine',
C25: 'gillnet', GN: 'gillnet', GNS: 'gillnet', GND: 'gillnet', GILLNET: 'gillnet',
C40: 'unknown', FC: 'unknown',
};
const ZONE_CODE_SEA_AREA: Record<string, string> = {
ZONE_I: 'I', ZONE_II: 'II', ZONE_III: 'III', ZONE_IV: 'IV',
TERRITORIAL_SEA: '영해', CONTIGUOUS_ZONE: '접속수역', EEZ_OR_BEYOND: 'EEZ 외',
};
export function GearIdentification() {
const { t } = useTranslation('detection');
const [input, setInput] = useState<GearInput>(DEFAULT_INPUT);
const [result, setResult] = useState<IdentificationResult | null>(null);
const [showReference, setShowReference] = useState(false);
const [autoSelected, setAutoSelected] = useState<GearDetection | null>(null);
const update = <K extends keyof GearInput>(key: K, value: GearInput[K]) => {
setInput((prev) => ({ ...prev, [key]: value }));
@ -655,58 +636,6 @@ export function GearIdentification() {
const resetForm = () => {
setInput(DEFAULT_INPUT);
setResult(null);
setAutoSelected(null);
};
// 자동탐지 row 선택 → 입력 폼 프리필 + 결과 패널에 근거 프리셋
const applyAutoDetection = (v: GearDetection) => {
const code = (v.gearCode || '').toUpperCase();
const category = GEAR_CODE_CATEGORY[code] ?? 'unknown';
const seaArea = v.zoneCode ? ZONE_CODE_SEA_AREA[v.zoneCode] ?? '' : '';
setInput({
...DEFAULT_INPUT,
gearCategory: category,
permitCode: code,
mmsiPrefix: v.mmsi.slice(0, 3),
seaArea,
discoveryDate: v.analyzedAt.slice(0, 10),
});
setAutoSelected(v);
// 자동탐지 근거를 결과 패널에 프리셋
const reasons: string[] = [];
const warnings: string[] = [];
reasons.push(`MMSI ${v.mmsi} · ${v.vesselType ?? 'UNKNOWN'} · prediction 자동탐지`);
reasons.push(`어구 코드: ${code} · 판정: ${GEAR_JUDGMENT_LABEL[v.gearJudgment] ?? v.gearJudgment}`);
if (v.permitStatus) {
reasons.push(`허가 상태: ${PERMIT_STATUS_LABEL[v.permitStatus] ?? v.permitStatus}`);
}
(v.violationCategories ?? []).forEach((cat) => warnings.push(`위반 카테고리: ${cat}`));
if (v.gearJudgment === 'CLOSED_SEASON_FISHING') warnings.push('금어기 조업 의심 — 허가기간 외 조업');
if (v.gearJudgment === 'UNREGISTERED_GEAR') warnings.push('미등록 어구 — fleet_vessels 미매칭');
if (v.gearJudgment === 'GEAR_MISMATCH') warnings.push('허가 어구와 실제 탐지 어구 불일치');
if (v.gearJudgment === 'MULTIPLE_VIOLATION') warnings.push('복합 위반 — 두 개 이상 항목 동시 탐지');
const alertLevel = isValidAlertLevel(v.riskLevel) ? v.riskLevel : 'LOW';
setResult({
origin: 'china',
confidence: v.riskScore && v.riskScore >= 70 ? 'high' : v.riskScore && v.riskScore >= 40 ? 'medium' : 'low',
gearType: category,
gearSubType: code,
gbCode: '',
koreaName: '',
reasons,
warnings,
actionRequired: alertLevel === 'CRITICAL' || alertLevel === 'HIGH'
? '현장 확인 및 보강 정보 입력 후 최종 판별 실행'
: '추가 정보 입력 후 판별 실행',
alertLevel,
});
// 입력 폼 영역으로 스크롤
window.scrollTo({ top: 0, behavior: 'smooth' });
};
return (
@ -715,7 +644,7 @@ export function GearIdentification() {
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Search className="w-5 h-5 text-cyan-600 dark:text-cyan-500" />
<Search className="w-5 h-5 text-cyan-500" />
{t('gearId.title')}
</h2>
<p className="text-[10px] text-hint mt-0.5">
@ -723,47 +652,19 @@ export function GearIdentification() {
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
<button type="button"
onClick={() => setShowReference(!showReference)}
icon={<Info className="w-3 h-3" />}
className="px-3 py-1.5 bg-secondary border border-slate-700/50 rounded-md text-[11px] text-label hover:bg-switch-background transition-colors flex items-center gap-1.5"
>
<Info className="w-3 h-3" />
{showReference ? '레퍼런스 닫기' : '비교 레퍼런스'}
</Button>
</button>
</div>
</div>
{/* 레퍼런스 테이블 (토글) */}
{showReference && <GearComparisonTable />}
{/* 자동탐지 선택 힌트 */}
{autoSelected && (
<div className="flex items-center justify-between gap-3 px-4 py-2.5 rounded-lg border border-cyan-500/30 bg-cyan-500/5 text-[11px]">
<div className="flex items-center gap-2 flex-wrap">
<Badge intent="info" size="sm"> </Badge>
<span className="text-hint">MMSI</span>
<span className="font-mono text-cyan-600 dark:text-cyan-400">{autoSelected.mmsi}</span>
<span className="text-hint">·</span>
<span className="text-hint"></span>
<span className="font-mono text-label">{autoSelected.gearCode}</span>
<span className="text-hint">·</span>
<Badge intent={GEAR_JUDGMENT_INTENT[autoSelected.gearJudgment] ?? 'muted'} size="sm">
{GEAR_JUDGMENT_LABEL[autoSelected.gearJudgment] ?? autoSelected.gearJudgment}
</Badge>
<span className="text-hint ml-2"> . .</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => { setAutoSelected(null); setInput(DEFAULT_INPUT); setResult(null); }}
className="shrink-0 text-[10px]"
>
</Button>
</div>
)}
<div className="grid grid-cols-12 gap-4">
{/* ── 좌측: 입력 폼 ── */}
<div className="col-span-5 space-y-3">
@ -882,22 +783,19 @@ export function GearIdentification() {
{/* 판별 버튼 */}
<div className="flex gap-2">
<Button
variant="primary"
size="md"
<button type="button"
onClick={runIdentification}
icon={<Zap className="w-4 h-4" />}
className="flex-1 font-bold"
className="flex-1 py-2.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2"
>
<Zap className="w-4 h-4" />
</Button>
<Button
variant="secondary"
size="md"
</button>
<button type="button"
onClick={resetForm}
className="px-4 py-2.5 bg-secondary hover:bg-switch-background text-label text-sm rounded-lg transition-colors border border-slate-700/50"
>
</Button>
</button>
</div>
</div>
@ -957,7 +855,7 @@ export function GearIdentification() {
<Card>
<CardHeader className="px-4 pt-3 pb-0">
<CardTitle className="text-xs text-label flex items-center gap-1.5">
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500" />
<CheckCircle className="w-3.5 h-3.5 text-green-500" />
({result.reasons.length})
</CardTitle>
</CardHeader>
@ -965,7 +863,7 @@ export function GearIdentification() {
<div className="space-y-1.5">
{result.reasons.map((reason, i) => (
<div key={i} className="flex items-start gap-2 p-2 bg-surface-overlay rounded-md">
<ChevronRight className="w-3 h-3 text-green-600 dark:text-green-500 mt-0.5 shrink-0" />
<ChevronRight className="w-3 h-3 text-green-500 mt-0.5 shrink-0" />
<span className="text-[11px] text-label">{reason}</span>
</div>
))}
@ -977,7 +875,7 @@ export function GearIdentification() {
{result.warnings.length > 0 && (
<Card className="bg-surface-raised border-orange-500/20">
<CardHeader className="px-4 pt-3 pb-0">
<CardTitle className="text-xs text-orange-600 dark:text-orange-400 flex items-center gap-1.5">
<CardTitle className="text-xs text-orange-400 flex items-center gap-1.5">
<AlertTriangle className="w-3.5 h-3.5" />
/ ({result.warnings.length})
</CardTitle>
@ -986,8 +884,8 @@ export function GearIdentification() {
<div className="space-y-1.5">
{result.warnings.map((warning, i) => (
<div key={i} className="flex items-start gap-2 p-2 bg-orange-500/5 border border-orange-500/10 rounded-md">
<XCircle className="w-3 h-3 text-orange-600 dark:text-orange-500 mt-0.5 shrink-0" />
<span className="text-[11px] text-orange-700 dark:text-orange-300">{warning}</span>
<XCircle className="w-3 h-3 text-orange-500 mt-0.5 shrink-0" />
<span className="text-[11px] text-orange-300">{warning}</span>
</div>
))}
</div>
@ -999,13 +897,13 @@ export function GearIdentification() {
<Card>
<CardHeader className="px-4 pt-3 pb-0">
<CardTitle className="text-xs text-label flex items-center gap-1.5">
<Shield className="w-3.5 h-3.5 text-purple-600 dark:text-purple-500" />
<Shield className="w-3.5 h-3.5 text-purple-500" />
AI Rule ( )
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 pt-2">
{result.gearType === 'trawl' && (
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-600 dark:text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
{`# 트롤 탐지 조건 (Trawl Detection Rule)
if speed in range(2.0, 5.0) # knots
and trajectory == 'parallel_sweep' #
@ -1020,7 +918,7 @@ and speed_sync > 0.92 # 2선 속도 동기화`}
</pre>
)}
{result.gearType === 'gillnet' && (
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-600 dark:text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
{`# 자망 탐지 조건 (Gillnet Detection Rule)
if speed < 2.0 # knots
and stop_duration > 30 # min
@ -1035,7 +933,7 @@ and sar_vessel_detect == True # SAR 위치 확인
</pre>
)}
{result.gearType === 'purseSeine' && (
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-600 dark:text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
{`# 선망 탐지 조건 (Purse Seine Detection Rule)
if trajectory == 'circular' #
and speed_change > 5.0 # kt ( )
@ -1051,7 +949,7 @@ and vessel_spacing < 1000 # m
</pre>
)}
{result.gearType === 'setNet' && (
<pre className="bg-background rounded-lg p-3 text-[10px] text-red-600 dark:text-red-400 font-mono overflow-x-auto whitespace-pre-wrap">
<pre className="bg-background rounded-lg p-3 text-[10px] text-red-400 font-mono overflow-x-auto whitespace-pre-wrap">
{`# 정치망 — EEZ 내 중국어선 미허가 어구
# GB/T 코드: Z__ (ZD: 단묘장망, ZS: 복묘장망)
#
@ -1077,7 +975,7 @@ and vessel_spacing < 1000 # m
<Card>
<CardHeader className="px-4 pt-3 pb-0">
<CardTitle className="text-xs text-label flex items-center gap-1.5">
<Waves className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-500" />
<Waves className="w-3.5 h-3.5 text-cyan-500" />
</CardTitle>
</CardHeader>
@ -1104,158 +1002,6 @@ and vessel_spacing < 1000 # m
)}
</div>
</div>
{/* 최근 자동탐지 결과 (prediction 기반) */}
<AutoGearDetectionSection
onSelect={applyAutoDetection}
selectedId={autoSelected?.id ?? null}
/>
</div>
);
}
// ─── 자동탐지 결과 섹션 ─────────────────
const GEAR_JUDGMENT_LABEL: Record<string, string> = {
CLOSED_SEASON_FISHING: '금어기 조업',
UNREGISTERED_GEAR: '미등록 어구',
GEAR_MISMATCH: '어구 불일치',
MULTIPLE_VIOLATION: '복합 위반',
NORMAL: '정상',
};
const GEAR_JUDGMENT_INTENT: Record<string, 'critical' | 'warning' | 'muted' | 'success'> = {
CLOSED_SEASON_FISHING: 'critical',
UNREGISTERED_GEAR: 'warning',
GEAR_MISMATCH: 'warning',
MULTIPLE_VIOLATION: 'critical',
NORMAL: 'success',
};
const PERMIT_STATUS_LABEL: Record<string, string> = {
PERMITTED: '허가',
UNPERMITTED: '미허가',
UNKNOWN: '확인불가',
};
function AutoGearDetectionSection({
onSelect,
selectedId,
}: {
onSelect: (v: GearDetection) => void;
selectedId: number | null;
}) {
const { t, i18n } = useTranslation('common');
const lang = (i18n.language as 'ko' | 'en') || 'ko';
const [items, setItems] = useState<GearDetection[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const load = useCallback(async () => {
setLoading(true); setError('');
try {
const page = await getGearDetections({ hours: 1, mmsiPrefix: '412', size: 50 });
setItems(page.content);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : '조회 실패');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
return (
<Card>
<CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-bold text-heading flex items-center gap-2">
<Radar className="w-4 h-4 text-cyan-600 dark:text-cyan-500" />
(prediction, 1 )
</div>
<div className="text-[10px] text-hint mt-0.5">
GET /api/analysis/gear-detections · MMSI 412 · gear_code / gear_judgment NOT NULL ·
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={load}
aria-label={t('aria.refresh')}
icon={<RefreshCw className="w-3.5 h-3.5" />}
/>
</div>
{error && <div className="text-xs text-red-600 dark:text-red-400">{t('error.errorPrefix', { msg: error })}</div>}
{loading && <div className="flex items-center justify-center py-6 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
{!loading && (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead className="bg-surface-overlay text-hint">
<tr>
<th className="px-2 py-1.5 text-left">MMSI</th>
<th className="px-2 py-1.5 text-left"></th>
<th className="px-2 py-1.5 text-center"></th>
<th className="px-2 py-1.5 text-center"></th>
<th className="px-2 py-1.5 text-center"></th>
<th className="px-2 py-1.5 text-left"></th>
<th className="px-2 py-1.5 text-center"></th>
<th className="px-2 py-1.5 text-right"></th>
<th className="px-2 py-1.5 text-left"></th>
<th className="px-2 py-1.5 text-left"></th>
</tr>
</thead>
<tbody>
{items.length === 0 && (
<tr><td colSpan={10} className="px-3 py-6 text-center text-hint"> .</td></tr>
)}
{items.map((v) => {
const selected = v.id === selectedId;
return (
<tr
key={v.id}
onClick={() => onSelect(v)}
className={`border-t border-border cursor-pointer transition-colors ${
selected ? 'bg-cyan-500/10 hover:bg-cyan-500/15' : 'hover:bg-surface-overlay/50'
}`}
title="클릭하면 상단 입력 폼에 자동으로 채워집니다"
>
<td className="px-2 py-1.5 text-cyan-600 dark:text-cyan-400 font-mono">{v.mmsi}</td>
<td className="px-2 py-1.5 text-heading font-medium">{v.vesselType ?? '-'}</td>
<td className="px-2 py-1.5 text-center font-mono text-label">{v.gearCode}</td>
<td className="px-2 py-1.5 text-center">
<Badge intent={GEAR_JUDGMENT_INTENT[v.gearJudgment] ?? 'muted'} size="sm">
{GEAR_JUDGMENT_LABEL[v.gearJudgment] ?? v.gearJudgment}
</Badge>
</td>
<td className="px-2 py-1.5 text-center text-[10px] text-muted-foreground">
{PERMIT_STATUS_LABEL[v.permitStatus ?? ''] ?? v.permitStatus ?? '-'}
</td>
<td className="px-2 py-1.5 text-muted-foreground text-[10px]">
{v.zoneCode ? getZoneCodeLabel(v.zoneCode, t, lang) : '-'}
</td>
<td className="px-2 py-1.5 text-center">
{v.riskLevel ? (
<Badge intent={getAlertLevelIntent(v.riskLevel)} size="sm">{v.riskLevel}</Badge>
) : <span className="text-hint">-</span>}
</td>
<td className="px-2 py-1.5 text-right text-heading font-bold">{v.riskScore ?? '-'}</td>
<td className="px-2 py-1.5 text-[10px] text-muted-foreground">
{(v.violationCategories ?? []).join(', ') || '-'}
</td>
<td className="px-2 py-1.5 text-muted-foreground text-[10px]">
{v.analyzedAt ? formatDateTime(v.analyzedAt) : '-'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
);
}

파일 보기

@ -2,9 +2,6 @@ import { useEffect, useState, useCallback } from 'react';
import { Loader2, RefreshCw, MapPin } from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { Select } from '@shared/components/ui/select';
import type { BadgeIntent } from '@lib/theme/variants';
import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi';
import { getGearGroupTypeIntent, getGearGroupTypeLabel } from '@shared/constants/gearGroupTypes';
import { getParentResolutionIntent, getParentResolutionLabel } from '@shared/constants/parentResolutionStatuses';
@ -12,9 +9,9 @@ import { useSettingsStore } from '@stores/settingsStore';
import { useTranslation } from 'react-i18next';
/**
* prediction / .
* iran / .
* - GET /api/vessel-analysis/groups
* - DB의 ParentResolution
* - DB의 ParentResolution
*/
export function RealGearGroups() {
@ -57,7 +54,7 @@ export function RealGearGroups() {
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-bold text-heading flex items-center gap-2">
<MapPin className="w-4 h-4 text-orange-400" /> /
<MapPin className="w-4 h-4 text-orange-400" /> / (iran )
{!available && <Badge intent="critical" size="sm"></Badge>}
</div>
<div className="text-[10px] text-hint mt-0.5">
@ -65,37 +62,29 @@ export function RealGearGroups() {
</div>
</div>
<div className="flex items-center gap-2">
<Select
size="sm"
aria-label={tc('aria.groupTypeFilter')}
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
>
<select aria-label="그룹 유형 필터" value={filterType} onChange={(e) => setFilterType(e.target.value)}
className="bg-surface-overlay border border-border rounded px-2 py-1 text-[10px] text-heading">
<option value=""></option>
<option value="FLEET">FLEET</option>
<option value="GEAR_IN_ZONE">GEAR_IN_ZONE</option>
<option value="GEAR_OUT_ZONE">GEAR_OUT_ZONE</option>
</Select>
<Button
variant="ghost"
size="sm"
onClick={load}
aria-label={tc('aria.refresh')}
icon={<RefreshCw className="w-3.5 h-3.5" />}
/>
</select>
<button type="button" onClick={load} className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* 통계 */}
<div className="grid grid-cols-5 gap-2">
<StatBox label="총 그룹" value={stats.total} intent="muted" />
<StatBox label="FLEET" value={stats.fleet} intent="info" />
<StatBox label="어구 (지정해역)" value={stats.gearInZone} intent="high" />
<StatBox label="어구 (지정해역 외)" value={stats.gearOutZone} intent="purple" />
<StatBox label="모선 확정됨" value={stats.confirmed} intent="success" />
<StatBox label="총 그룹" value={stats.total} color="text-heading" />
<StatBox label="FLEET" value={stats.fleet} color="text-blue-400" />
<StatBox label="어구 (지정해역)" value={stats.gearInZone} color="text-orange-400" />
<StatBox label="어구 (지정해역 외)" value={stats.gearOutZone} color="text-purple-400" />
<StatBox label="모선 확정됨" value={stats.confirmed} color="text-green-400" />
</div>
{error && <div className="text-xs text-red-600 dark:text-red-400">{tc('error.errorPrefix', { msg: error })}</div>}
{error && <div className="text-xs text-red-400">: {error}</div>}
{loading && <div className="flex items-center justify-center py-4 text-muted-foreground"><Loader2 className="w-4 h-4 animate-spin" /></div>}
{!loading && (
@ -153,22 +142,11 @@ export function RealGearGroups() {
);
}
const INTENT_TEXT_CLASS: Record<BadgeIntent, string> = {
critical: 'text-red-600 dark:text-red-400',
high: 'text-orange-600 dark:text-orange-400',
warning: 'text-yellow-600 dark:text-yellow-400',
info: 'text-blue-600 dark:text-blue-400',
success: 'text-green-600 dark:text-green-400',
muted: 'text-heading',
purple: 'text-purple-600 dark:text-purple-400',
cyan: 'text-cyan-600 dark:text-cyan-400',
};
function StatBox({ label, value, intent = 'muted' }: { label: string; value: number; intent?: BadgeIntent }) {
function StatBox({ label, value, color }: { label: string; value: number; color: string }) {
return (
<div className="px-3 py-2 rounded border border-border bg-surface-overlay">
<div className="text-[9px] text-hint">{label}</div>
<div className={`text-lg font-bold ${INTENT_TEXT_CLASS[intent]}`}>{value}</div>
<div className={`text-lg font-bold ${color}`}>{value}</div>
</div>
);
}

파일 보기

@ -1,39 +1,28 @@
import { useEffect, useState, useCallback, useMemo } from 'react';
import { Loader2, RefreshCw, EyeOff, AlertTriangle, Radar } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { Select } from '@shared/components/ui/select';
import type { BadgeIntent } from '@lib/theme/variants';
import { getAlertLevelIntent } from '@shared/constants/alertLevels';
import { getVesselTypeLabel } from '@shared/constants/vesselTypes';
import type { VesselAnalysisItem } from '@/services/vesselAnalysisApi';
import {
getAnalysisVessels,
getDarkVessels,
getTransshipSuspects,
} from '@/services/analysisApi';
import { toVesselItem } from '@/services/analysisAdapter';
fetchVesselAnalysis,
type VesselAnalysisItem,
type VesselAnalysisStats,
} from '@/services/vesselAnalysisApi';
/**
* vessel_analysis_results .
* iran vessel analysis .
* - mode: 'dark' (Dark Vessel만) / 'spoofing' ( ) / 'transship' () / 'all'
* - +
* - +
*/
interface Props {
mode: 'dark' | 'spoofing' | 'transship' | 'all';
title: string;
icon?: React.ReactNode;
/** 'all' / 'spoofing' mode 에서 MMSI prefix 필터 (예: '412' — 중국 선박 한정) */
mmsiPrefix?: string;
/** 'all' / 'spoofing' mode 에서 서버 측 최소 riskScore 필터 */
minRiskScore?: number;
/** 서버 조회 건수 (dark/transship 기본 200) */
size?: number;
}
// 위험도 색상은 alertLevels 카탈로그 (intent prop) 사용
const ZONE_LABELS: Record<string, string> = {
TERRITORIAL_SEA: '영해',
CONTIGUOUS_ZONE: '접속수역',
@ -44,17 +33,9 @@ const ZONE_LABELS: Record<string, string> = {
ZONE_IV: '특정해역 IV',
};
const ENDPOINT_LABEL: Record<Props['mode'], string> = {
all: 'GET /api/analysis/vessels',
dark: 'GET /api/analysis/dark',
transship: 'GET /api/analysis/transship',
spoofing: 'GET /api/analysis/vessels (spoofing_score ≥ 0.3)',
};
export function RealVesselAnalysis({ mode, title, icon, mmsiPrefix, minRiskScore, size = 200 }: Props) {
const { t, i18n } = useTranslation('common');
const lang = (i18n.language as 'ko' | 'en') || 'ko';
export function RealVesselAnalysis({ mode, title, icon }: Props) {
const [items, setItems] = useState<VesselAnalysisItem[]>([]);
const [stats, setStats] = useState<VesselAnalysisStats | null>(null);
const [available, setAvailable] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
@ -63,27 +44,24 @@ export function RealVesselAnalysis({ mode, title, icon, mmsiPrefix, minRiskScore
const load = useCallback(async () => {
setLoading(true); setError('');
try {
const page = mode === 'dark'
? await getDarkVessels({ hours: 1, size })
: mode === 'transship'
? await getTransshipSuspects({ hours: 1, size })
: await getAnalysisVessels({ hours: 1, size, mmsiPrefix, minRiskScore });
setItems(page.content.map(toVesselItem));
setAvailable(true);
const res = await fetchVesselAnalysis();
setItems(res.items);
setStats(res.stats);
setAvailable(res.serviceAvailable);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'unknown');
setAvailable(false);
} finally {
setLoading(false);
}
}, [mode, mmsiPrefix, minRiskScore, size]);
}, []);
useEffect(() => { load(); }, [load]);
const filtered = useMemo(() => {
let result = items;
// spoofing 은 /analysis/vessels 결과를 클라에서 임계값 필터
if (mode === 'spoofing') result = result.filter((i) => i.algorithms.gpsSpoofing.spoofingScore >= 0.3);
if (mode === 'dark') result = result.filter((i) => i.algorithms.darkVessel.isDark);
else if (mode === 'spoofing') result = result.filter((i) => i.algorithms.gpsSpoofing.spoofingScore >= 0.3);
else if (mode === 'transship') result = result.filter((i) => i.algorithms.transship.isSuspect);
if (zoneFilter) result = result.filter((i) => i.algorithms.location.zone === zoneFilter);
return result;
}, [items, mode, zoneFilter]);
@ -93,20 +71,6 @@ export function RealVesselAnalysis({ mode, title, icon, mmsiPrefix, minRiskScore
[filtered],
);
// 통계 카드: mode 로 필터된 items 기반으로 집계해야 상단 숫자와 하단 리스트가 정합
const stats = useMemo(() => {
const modeFilteredItems = mode === 'spoofing'
? items.filter((i) => i.algorithms.gpsSpoofing.spoofingScore >= 0.3)
: items;
return {
total: modeFilteredItems.length,
criticalCount: modeFilteredItems.filter((i) => i.algorithms.riskScore.level === 'CRITICAL').length,
highCount: modeFilteredItems.filter((i) => i.algorithms.riskScore.level === 'HIGH').length,
mediumCount: modeFilteredItems.filter((i) => i.algorithms.riskScore.level === 'MEDIUM').length,
darkCount: modeFilteredItems.filter((i) => i.algorithms.darkVessel.isDark).length,
};
}, [items, mode]);
return (
<Card>
<CardContent className="p-4 space-y-3">
@ -117,42 +81,37 @@ export function RealVesselAnalysis({ mode, title, icon, mmsiPrefix, minRiskScore
{!available && <Badge intent="critical" size="sm"></Badge>}
</div>
<div className="text-[10px] text-hint mt-0.5">
{ENDPOINT_LABEL[mode]} · prediction 5
GET /api/vessel-analysis · iran
</div>
</div>
<div className="flex items-center gap-2">
<Select
size="sm"
aria-label={t('aria.regionFilter')}
value={zoneFilter}
onChange={(e) => setZoneFilter(e.target.value)}
>
<select aria-label="해역 필터" value={zoneFilter} onChange={(e) => setZoneFilter(e.target.value)}
className="bg-surface-overlay border border-border rounded px-2 py-1 text-[10px] text-heading">
<option value=""> </option>
<option value="TERRITORIAL_SEA"></option>
<option value="CONTIGUOUS_ZONE"></option>
<option value="EEZ_OR_BEYOND">EEZ </option>
</Select>
<Button
variant="ghost"
size="sm"
onClick={load}
aria-label={t('aria.refresh')}
icon={<RefreshCw className="w-3.5 h-3.5" />}
/>
</select>
<button type="button" onClick={load}
className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* 통계 카드 — items(= mode 필터 적용된 선박) 기준 집계 */}
<div className="grid grid-cols-6 gap-2">
<StatBox label="전체" value={stats.total} intent="muted" />
<StatBox label="CRITICAL" value={stats.criticalCount} intent="critical" />
<StatBox label="HIGH" value={stats.highCount} intent="high" />
<StatBox label="MEDIUM" value={stats.mediumCount} intent="warning" />
<StatBox label="Dark" value={stats.darkCount} intent="purple" />
<StatBox label="필터링" value={filtered.length} intent="cyan" />
</div>
{/* 통계 카드 */}
{stats && (
<div className="grid grid-cols-6 gap-2">
<StatBox label="전체" value={stats.total} color="text-heading" />
<StatBox label="CRITICAL" value={stats.critical} color="text-red-400" />
<StatBox label="HIGH" value={stats.high} color="text-orange-400" />
<StatBox label="MEDIUM" value={stats.medium} color="text-yellow-400" />
<StatBox label="Dark" value={stats.dark} color="text-purple-400" />
<StatBox label="필터링" value={filtered.length} color="text-cyan-400" />
</div>
)}
{error && <div className="text-xs text-red-600 dark:text-red-400">{t('error.errorPrefix', { msg: error })}</div>}
{error && <div className="text-xs text-red-400">: {error}</div>}
{loading && <div className="flex items-center justify-center py-6 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
{!loading && (
@ -178,12 +137,10 @@ export function RealVesselAnalysis({ mode, title, icon, mmsiPrefix, minRiskScore
)}
{sortedByRisk.slice(0, 100).map((v) => (
<tr key={v.mmsi} className="border-t border-border hover:bg-surface-overlay/50">
<td className="px-2 py-1.5 text-cyan-600 dark:text-cyan-400 font-mono">{v.mmsi}</td>
<td className="px-2 py-1.5 text-cyan-400 font-mono">{v.mmsi}</td>
<td className="px-2 py-1.5 text-heading font-medium">
{getVesselTypeLabel(v.classification.vesselType, t, lang)}
{v.classification.confidence > 0 && (
<span className="text-hint ml-1 text-[9px]">({(v.classification.confidence * 100).toFixed(0)}%)</span>
)}
{v.classification.vesselType}
<span className="text-hint ml-1 text-[9px]">({(v.classification.confidence * 100).toFixed(0)}%)</span>
</td>
<td className="px-2 py-1.5 text-center">
<Badge intent={getAlertLevelIntent(v.algorithms.riskScore.level)} size="sm">
@ -203,7 +160,7 @@ export function RealVesselAnalysis({ mode, title, icon, mmsiPrefix, minRiskScore
</td>
<td className="px-2 py-1.5 text-right">
{v.algorithms.gpsSpoofing.spoofingScore > 0 ? (
<span className="text-orange-600 dark:text-orange-400 font-mono text-[10px]">{v.algorithms.gpsSpoofing.spoofingScore.toFixed(2)}</span>
<span className="text-orange-400 font-mono text-[10px]">{v.algorithms.gpsSpoofing.spoofingScore.toFixed(2)}</span>
) : <span className="text-hint">-</span>}
</td>
<td className="px-2 py-1.5 text-center">
@ -230,22 +187,11 @@ export function RealVesselAnalysis({ mode, title, icon, mmsiPrefix, minRiskScore
);
}
const INTENT_TEXT_CLASS: Record<BadgeIntent, string> = {
critical: 'text-red-600 dark:text-red-400',
high: 'text-orange-600 dark:text-orange-400',
warning: 'text-yellow-600 dark:text-yellow-400',
info: 'text-blue-600 dark:text-blue-400',
success: 'text-green-600 dark:text-green-400',
muted: 'text-heading',
purple: 'text-purple-600 dark:text-purple-400',
cyan: 'text-cyan-600 dark:text-cyan-400',
};
function StatBox({ label, value, intent = 'muted' }: { label: string; value: number | undefined; intent?: BadgeIntent }) {
function StatBox({ label, value, color }: { label: string; value: number; color: string }) {
return (
<div className="px-3 py-2 rounded border border-border bg-surface-overlay">
<div className="text-[9px] text-hint">{label}</div>
<div className={`text-lg font-bold ${INTENT_TEXT_CLASS[intent]}`}>{(value ?? 0).toLocaleString()}</div>
<div className={`text-lg font-bold ${color}`}>{value.toLocaleString()}</div>
</div>
);
}
@ -254,12 +200,4 @@ function StatBox({ label, value, intent = 'muted' }: { label: string; value: num
export const RealDarkVessels = () => <RealVesselAnalysis mode="dark" title="Dark Vessel (실시간)" icon={<EyeOff className="w-4 h-4 text-purple-400" />} />;
export const RealSpoofingVessels = () => <RealVesselAnalysis mode="spoofing" title="GPS 스푸핑 의심 (실시간)" icon={<AlertTriangle className="w-4 h-4 text-orange-400" />} />;
export const RealTransshipSuspects = () => <RealVesselAnalysis mode="transship" title="전재 의심 (실시간)" icon={<Radar className="w-4 h-4 text-red-400" />} />;
// 중국 선박 감시 페이지 전용 — MMSI prefix 412 고정
export const RealAllVessels = () => (
<RealVesselAnalysis
mode="all"
title="중국 선박 전체 분석 결과 (실시간)"
icon={<Radar className="w-4 h-4 text-blue-400" />}
mmsiPrefix="412"
/>
);
export const RealAllVessels = () => <RealVesselAnalysis mode="all" title="전체 분석 결과 (실시간)" icon={<Radar className="w-4 h-4 text-blue-400" />} />;

파일 보기

@ -6,7 +6,6 @@
*/
import { useEffect, useState, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { ScoreBreakdown } from '@shared/components/common/ScoreBreakdown';
@ -25,7 +24,6 @@ interface DarkDetailPanelProps {
export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) {
const navigate = useNavigate();
const { t: tc } = useTranslation('common');
const [history, setHistory] = useState<VesselAnalysis[]>([]);
const features = vessel?.features ?? {};
@ -73,12 +71,12 @@ export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) {
{/* 헤더 */}
<div className="sticky top-0 bg-background/95 backdrop-blur-sm border-b border-border px-4 py-3 flex items-center justify-between z-10">
<div className="flex items-center gap-2">
<ShieldAlert className="w-4 h-4 text-red-600 dark:text-red-400" />
<ShieldAlert className="w-4 h-4 text-red-400" />
<span className="font-bold text-heading text-sm"> </span>
<Badge intent={getAlertLevelIntent(darkTier)} size="sm">{darkTier}</Badge>
<span className="text-xs font-mono font-bold text-heading">{darkScore}</span>
</div>
<button type="button" onClick={onClose} className="p-1 hover:bg-surface-raised rounded" aria-label={tc('aria.close')}>
<button type="button" onClick={onClose} className="p-1 hover:bg-surface-raised rounded" aria-label="닫기">
<X className="w-4 h-4 text-hint" />
</button>
</div>
@ -87,12 +85,12 @@ export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) {
{/* 선박 기본 정보 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<Ship className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" />
<Ship className="w-3.5 h-3.5 text-cyan-400" />
<span className="text-label font-medium"> </span>
</div>
<div className="grid grid-cols-2 gap-y-1 text-xs">
<span className="text-hint">MMSI</span>
<button type="button" className="text-cyan-600 dark:text-cyan-400 hover:underline text-right font-mono"
<button type="button" className="text-cyan-400 hover:underline text-right font-mono"
onClick={() => navigate(`/vessel/${vessel.mmsi}`)}>
{vessel.mmsi} <ExternalLink className="w-2.5 h-2.5 inline" />
</button>
@ -116,7 +114,7 @@ export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) {
{/* 점수 산출 내역 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<AlertTriangle className="w-3.5 h-3.5 text-orange-600 dark:text-orange-400" />
<AlertTriangle className="w-3.5 h-3.5 text-orange-400" />
<span className="text-label font-medium"> </span>
<span className="text-hint text-[10px]">({breakdown.items.length} )</span>
</div>
@ -126,7 +124,7 @@ export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) {
{/* GAP 상세 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<MapPin className="w-3.5 h-3.5 text-yellow-600 dark:text-yellow-400" />
<MapPin className="w-3.5 h-3.5 text-yellow-400" />
<span className="text-label font-medium">GAP </span>
</div>
<div className="grid grid-cols-2 gap-y-1 text-xs">
@ -152,7 +150,7 @@ export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) {
{/* 과거 이력 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<TrendingUp className="w-3.5 h-3.5 text-purple-600 dark:text-purple-400" />
<TrendingUp className="w-3.5 h-3.5 text-purple-400" />
<span className="text-label font-medium"> (7)</span>
</div>
<div className="grid grid-cols-2 gap-y-1 text-xs">

파일 보기

@ -1,642 +0,0 @@
/**
* GearDetailPanel
*
* .
* G코드 , , + correlation ,
* .
*/
import { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import {
getGearViolationIntent,
getGearViolationLabel,
getGearViolationDesc,
GEAR_VIOLATION_CODES,
} from '@shared/constants/gearViolationCodes';
import { getZoneCodeIntent, getZoneCodeLabel, getZoneAllowedGears } from '@shared/constants/zoneCodes';
import { getGearJudgmentIntent } from '@shared/constants/permissionStatuses';
import { X, Anchor, MapPin, ShieldAlert, Users, Ship, Play, TrendingUp, Loader2, CheckCircle, XCircle, BarChart3 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useSettingsStore } from '@stores/settingsStore';
import { fetchGroupCorrelations, fetchVesselTracks, fetchCandidateMetrics, resolveParent, type CandidateMetricItem } from '@/services/vesselAnalysisApi';
import { useGearReplayStore } from '@stores/gearReplayStore';
interface CorrelationItem {
targetMmsi: string;
targetName: string;
targetType: string;
score: number;
streak: number;
freezeState: string;
proximityRatio: number;
visitScore: number;
headingCoherence: number;
modelName: string;
}
interface GearData {
id: string;
groupKey: string;
type: string;
owner: string;
zone: string;
status: string;
permit: string;
risk: string;
lat: number;
lng: number;
parentStatus: string;
parentMmsi: string;
confidence: string;
gCodes: string[];
gearViolationScore: number;
gearViolationEvidence: Record<string, Record<string, unknown>>;
pairTrawlDetected: boolean;
pairTrawlPairMmsi: string;
memberCount: number;
members: Array<{ mmsi: string; name?: string; lat?: number; lon?: number; role?: string; isParent?: boolean }>;
allowedGears: string[];
}
interface GearDetailPanelProps {
gear: GearData | null;
onClose: () => void;
}
export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
const navigate = useNavigate();
const { t } = useTranslation('detection');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const [correlations, setCorrelations] = useState<CorrelationItem[]>([]);
const [corrLoading, setCorrLoading] = useState(false);
const [selectedCandidates, setSelectedCandidates] = useState<Set<string>>(new Set());
const [selectedDetail, setSelectedDetail] = useState<string | null>(null); // 상세 보기 중인 후보 MMSI
const [detailMetrics, setDetailMetrics] = useState<CandidateMetricItem[]>([]);
const [detailLoading, setDetailLoading] = useState(false);
const [resolveLoading, setResolveLoading] = useState(false);
const [resolveMsg, setResolveMsg] = useState('');
const replayGroupKey = useGearReplayStore(s => s.groupKey);
const toggleCandidate = useCallback((mmsi: string) => {
setSelectedCandidates(prev => {
const next = new Set(prev);
if (next.has(mmsi)) next.delete(mmsi); else next.add(mmsi);
return next;
});
}, []);
// 후보 상세 메트릭 로드
const loadCandidateDetail = useCallback(async (mmsi: string) => {
if (!gear) return;
setSelectedDetail(mmsi);
setDetailLoading(true);
setResolveMsg('');
try {
const data = await fetchCandidateMetrics(gear.groupKey, mmsi);
setDetailMetrics(data.items ?? []);
} catch {
setDetailMetrics([]);
} finally {
setDetailLoading(false);
}
}, [gear]);
// 모선 확정/제외 처리
const handleResolve = useCallback(async (action: 'confirm' | 'reject') => {
if (!gear || !selectedDetail) return;
setResolveLoading(true);
setResolveMsg('');
try {
const result = await resolveParent(gear.groupKey, action, selectedDetail);
if (result.ok) {
setResolveMsg(action === 'confirm' ? '모선 확정 완료' : '후보 제외 완료');
} else {
setResolveMsg(result.message ?? '처리 실패');
}
} catch {
setResolveMsg('요청 실패');
} finally {
setResolveLoading(false);
}
}, [gear, selectedDetail]);
const loadCorrelations = useCallback(async (groupKey: string) => {
setCorrLoading(true);
try {
const data = await fetchGroupCorrelations(groupKey, 0.3);
// API 응답을 CorrelationItem 형태로 매핑
const items = Array.isArray(data) ? data : ((data as Record<string, unknown>)?.items as unknown[]) ?? [];
// 같은 MMSI가 여러 모델에서 중복 → default 모델 우선, 없으면 첫 번째
const byMmsi = new Map<string, CorrelationItem>();
for (const item of items) {
const d = item as Record<string, unknown>;
const mmsi = String(d.targetMmsi ?? '');
const mapped: CorrelationItem = {
targetMmsi: mmsi,
targetName: String(d.target_name ?? d.targetName ?? ''),
targetType: String(d.target_type ?? d.targetType ?? ''),
score: Number(d.score ?? d.current_score ?? d.currentScore ?? 0),
streak: Number(d.streak ?? d.streak_count ?? d.streakCount ?? 0),
freezeState: String(d.freeze_state ?? d.freezeState ?? 'ACTIVE'),
proximityRatio: Number(d.proximity_ratio ?? d.proximityRatio ?? 0),
visitScore: Number(d.visit_score ?? d.visitScore ?? 0),
headingCoherence: Number(d.heading_coherence ?? d.headingCoherence ?? 0),
modelName: String(d.model_name ?? d.modelName ?? 'default'),
};
const existing = byMmsi.get(mmsi);
if (!existing || mapped.modelName === 'default' || mapped.score > existing.score) {
byMmsi.set(mmsi, mapped);
}
}
const sorted = Array.from(byMmsi.values()).sort((a, b) => b.score - a.score);
setCorrelations(sorted);
// 최고 일치율 후보 자동 선택
if (sorted.length > 0) {
loadCandidateDetail(sorted[0].targetMmsi);
}
} catch {
setCorrelations([]);
} finally {
setCorrLoading(false);
}
}, [loadCandidateDetail]);
useEffect(() => {
if (gear?.groupKey) {
loadCorrelations(gear.groupKey);
} else {
setCorrelations([]);
}
}, [gear?.groupKey, loadCorrelations]);
const [replayLoading, setReplayLoading] = useState(false);
const handleStartReplay = useCallback(async () => {
if (!gear) return;
setReplayLoading(true);
try {
// 자체 백엔드 API로 group history + correlation 조회
const groupKeyEnc = encodeURIComponent(gear.groupKey);
const [detailRes, tracksRes] = await Promise.all([
fetch(`/api/vessel-analysis/groups/${groupKeyEnc}/detail`)
.then(r => r.json()).catch(() => ({ history: [] })),
fetch(`/api/vessel-analysis/groups/${groupKeyEnc}/correlations?minScore=0.1`)
.then(r => r.json()).catch(() => ({ items: [] })),
]);
// detailRes.history → frames 변환
const historyRes = { frames: (detailRes.history ?? []) };
const frames = (historyRes.frames ?? []).map((f: Record<string, unknown>) => ({
snapshotTime: String(f.snapshotTime ?? ''),
centerLat: Number(f.centerLat ?? 0),
centerLon: Number(f.centerLon ?? 0),
memberCount: Number(f.memberCount ?? 0),
polygon: f.polygon ?? null,
members: Array.isArray(f.members) ? f.members : [],
}));
// correlation 응답 → loadHistory용 CorrelationItem 형태로 매핑
const corrItems = tracksRes.items ?? [];
const replayCorrelations = Array.isArray(corrItems) ? corrItems.map((v: Record<string, unknown>) => ({
targetMmsi: String(v.targetMmsi ?? ''),
targetName: String(v.targetName ?? ''),
score: Number(v.score ?? v.currentScore ?? 0),
freezeState: String(v.freezeState ?? 'ACTIVE'),
})) : [];
if (frames.length === 0) {
frames.push({
snapshotTime: new Date().toISOString(),
centerLat: gear.lat,
centerLon: gear.lng,
memberCount: gear.memberCount,
polygon: null,
members: gear.members ?? [],
});
}
// 선택된 후보 선박 항적 조회 (24시간)
let candidateTracks: { vesselId: string; shipName: string; geometry: [number, number][]; timestamps: string[] }[] = [];
if (selectedCandidates.size > 0) {
const now = new Date();
const h24ago = new Date(now.getTime() - 24 * 3600_000);
try {
candidateTracks = await fetchVesselTracks(
[...selectedCandidates],
h24ago.toISOString(),
now.toISOString(),
);
} catch {
// 항적 조회 실패해도 리플레이는 진행
}
}
useGearReplayStore.getState().loadHistory(gear.groupKey, frames, replayCorrelations, candidateTracks);
useGearReplayStore.getState().play();
} catch {
// silent fallback
} finally {
setReplayLoading(false);
}
}, [gear, selectedCandidates]);
if (!gear) return null;
const allowedGears = gear.allowedGears.length > 0
? gear.allowedGears
: getZoneAllowedGears(gear.zone);
const parentStatusLabel =
gear.parentStatus === 'DIRECT_PARENT_MATCH' ? '직접매칭' :
gear.parentStatus === 'AUTO_PROMOTED' ? '자동승격' :
gear.parentStatus === 'REVIEW_REQUIRED' ? '심사필요' :
gear.parentStatus === 'UNRESOLVED' ? '미결정' :
gear.parentStatus;
const parentStatusIntent: 'success' | 'info' | 'warning' | 'muted' =
gear.parentStatus === 'DIRECT_PARENT_MATCH' ? 'success' :
gear.parentStatus === 'AUTO_PROMOTED' ? 'info' :
gear.parentStatus === 'REVIEW_REQUIRED' ? 'warning' :
'muted';
const hasPairTrawl = gear.pairTrawlDetected || gear.gCodes.includes('G-06');
const isReplayActive = replayGroupKey === gear.groupKey;
return (
<div className="fixed inset-y-0 right-0 w-[420px] bg-background border-l border-border z-50 overflow-y-auto shadow-2xl">
{/* 헤더 */}
<div className="sticky top-0 bg-background/95 backdrop-blur-sm border-b border-border px-4 py-3 flex items-center justify-between z-10">
<div className="flex items-center gap-2">
<ShieldAlert className="w-4 h-4 text-orange-600 dark:text-orange-400" />
<span className="font-bold text-heading text-sm"> </span>
<span className="text-xs font-mono font-bold text-hint">{gear.id}</span>
<Badge intent={getZoneCodeIntent(gear.zone)} size="sm">
{getZoneCodeLabel(gear.zone, t, lang)}
</Badge>
</div>
<button type="button" onClick={onClose} className="p-1 hover:bg-surface-raised rounded" aria-label={tc('aria.close')}>
<X className="w-4 h-4 text-hint" />
</button>
</div>
<div className="p-4 space-y-4">
{/* G코드 위반 내역 */}
{gear.gCodes.length > 0 && (
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<ShieldAlert className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
<span className="text-label font-medium">G코드 </span>
<span className="text-hint text-[10px]"> {gear.gearViolationScore}</span>
</div>
<div className="space-y-1.5">
{gear.gCodes.map((code) => {
const meta = GEAR_VIOLATION_CODES[code as keyof typeof GEAR_VIOLATION_CODES];
return (
<div key={code} className="flex items-start gap-2 py-1.5 border-b border-border last:border-0">
<Badge intent={getGearViolationIntent(code)} size="sm" className="shrink-0 mt-0.5">{code}</Badge>
<div className="flex-1 min-w-0">
<div className="text-xs text-label font-medium">{getGearViolationLabel(code, t, lang)}</div>
<div className="text-[10px] text-hint mt-0.5">{getGearViolationDesc(code, lang)}</div>
</div>
{meta && <span className="text-[10px] font-mono text-label shrink-0">+{meta.score}pt</span>}
</div>
);
})}
</div>
</div>
)}
{/* 어구 그룹 정보 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<Anchor className="w-3.5 h-3.5 text-orange-600 dark:text-orange-400" />
<span className="text-label font-medium"> </span>
</div>
<div className="grid grid-cols-2 gap-y-1 text-xs">
<span className="text-hint"> </span>
<span className="text-label text-right font-mono text-[10px]">{gear.groupKey}</span>
<span className="text-hint"> </span>
<span className="text-label text-right">{gear.type}</span>
<span className="text-hint">/</span>
<span className="text-label text-right font-mono">{gear.owner}</span>
<span className="text-hint"> </span>
<span className="text-label text-right">{gear.memberCount > 0 ? `${gear.memberCount}` : '-'}</span>
<span className="text-hint"> </span>
<span className="text-right">
<Badge intent={getZoneCodeIntent(gear.zone)} size="sm">{getZoneCodeLabel(gear.zone, t, lang)}</Badge>
</span>
<span className="text-hint"> </span>
<span className="text-right">
<Badge intent={getGearJudgmentIntent(gear.status)} size="sm">{gear.status}</Badge>
</span>
<span className="text-hint"> </span>
<span className="text-label text-right text-[10px]">{allowedGears.length > 0 ? allowedGears.join(', ') : '없음'}</span>
<span className="text-hint"></span>
<span className="text-label text-right font-mono text-[10px]">
{gear.lat.toFixed(4)}°N {gear.lng.toFixed(4)}°E
</span>
</div>
</div>
{/* 모선 추론 정보 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<Ship className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" />
<span className="text-label font-medium"> </span>
<Badge intent={parentStatusIntent} size="sm">{parentStatusLabel}</Badge>
</div>
<div className="grid grid-cols-2 gap-y-1 text-xs">
<span className="text-hint"> </span>
<span className="text-label text-right font-mono">
{gear.parentMmsi !== '-' && gear.parentMmsi ? (
<button type="button" className="text-cyan-600 dark:text-cyan-400 hover:underline"
onClick={() => navigate(`/vessel/${gear.parentMmsi}`)}>
{gear.parentMmsi}
</button>
) : '-'}
</span>
<span className="text-hint"> </span>
<span className="text-label text-right">{gear.confidence}</span>
</div>
</div>
{/* 모선 추론 후보 상세 (Correlation) */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<TrendingUp className="w-3.5 h-3.5 text-purple-600 dark:text-purple-400" />
<span className="text-label font-medium"> </span>
{corrLoading && <Loader2 className="w-3 h-3 animate-spin text-hint" />}
<span className="text-hint text-[10px]">{correlations.length}</span>
</div>
{correlations.length > 0 ? (
<div className="space-y-1.5 max-h-[240px] overflow-y-auto">
{correlations.sort((a, b) => b.score - a.score).map((c, i) => (
<div key={`${i}-${c.targetMmsi}`}
onClick={(e) => {
// 체크박스, MMSI 링크 클릭은 전파 방지됨 → 나머지 영역 클릭 시 상세 로드
if ((e.target as HTMLElement).closest('input, a, button')) return;
loadCandidateDetail(c.targetMmsi);
}}
className={`py-1.5 px-2 rounded text-xs cursor-pointer transition-colors ${
selectedDetail === c.targetMmsi
? 'bg-purple-500/10 border border-purple-500/30'
: c.targetMmsi === gear.parentMmsi
? 'bg-cyan-500/10 border border-cyan-500/30'
: 'hover:bg-surface-overlay'
}`}>
<div className="flex items-center gap-2">
<input type="checkbox" checked={selectedCandidates.has(c.targetMmsi)}
onChange={() => toggleCandidate(c.targetMmsi)}
className="w-3 h-3 rounded border-border accent-emerald-500 shrink-0 cursor-pointer"
aria-label={`${c.targetMmsi} 리플레이 선택`} />
<button type="button"
className="text-cyan-600 dark:text-cyan-400 hover:underline font-mono text-[11px]"
onClick={() => navigate(`/vessel/${c.targetMmsi}`)}>
{c.targetMmsi}
</button>
<span className="text-hint text-[9px] truncate max-w-[80px]">{c.targetName}</span>
<div className="flex-1" />
<div className="w-14 h-1.5 bg-surface-overlay rounded-full overflow-hidden">
<div className="h-full rounded-full transition-all"
style={{
width: `${Math.min(100, c.score * 100)}%`,
backgroundColor: c.score >= 0.72 ? '#10b981' : c.score >= 0.5 ? '#f59e0b' : '#64748b',
}} />
</div>
<span className="font-mono text-[10px] text-label w-10 text-right">
{(c.score * 100).toFixed(0)}%
</span>
<Badge intent={c.freezeState === 'ACTIVE' ? 'success' : 'muted'} size="sm">
{c.freezeState === 'ACTIVE' ? '활성' : c.freezeState.slice(0, 4)}
</Badge>
</div>
<div className="flex items-center gap-2 mt-1 ml-6 text-[9px] text-hint">
<span>: {(c.proximityRatio * 100).toFixed(0)}%</span>
{c.visitScore > 0 && <span>: {(c.visitScore * 100).toFixed(0)}%</span>}
{c.headingCoherence > 0 && <span>: {(c.headingCoherence * 100).toFixed(0)}%</span>}
<span>: {c.streak}</span>
<Badge intent={c.targetType === 'VESSEL' ? 'info' : 'muted'} size="sm">
{c.targetType === 'VESSEL' ? '선박' : c.targetType === 'GEAR_BUOY' ? '어구' : c.targetType}
</Badge>
</div>
</div>
))}
</div>
) : !corrLoading ? (
<div className="text-hint text-[10px] text-center py-2"> </div>
) : null}
{correlations.length > 0 && (
<div className="flex items-center gap-3 pt-1.5 border-t border-border text-[9px] text-hint">
<span>근접: 어구- </span>
<span>방문: 어구 </span>
<span>방향: 침로 </span>
</div>
)}
</div>
{/* ── 후보 상세 검토 패널 ── */}
{selectedDetail && (() => {
const cand = correlations.find(c => c.targetMmsi === selectedDetail);
if (!cand) return null;
// 최근 메트릭 평균 계산
const avg = (arr: (number | null)[]): number | null => {
const valid = arr.filter((v): v is number => v != null && v > 0);
return valid.length > 0 ? valid.reduce((a, b) => a + b, 0) / valid.length : null;
};
const avgProximity = avg(detailMetrics.map(m => m.proximityRatio));
const avgVisit = avg(detailMetrics.map(m => m.visitScore));
const avgActivity = avg(detailMetrics.map(m => m.activitySync));
const avgDtw = avg(detailMetrics.map(m => m.dtwSimilarity));
const avgSpeed = avg(detailMetrics.map(m => m.speedCorrelation));
const avgHeading = avg(detailMetrics.map(m => m.headingCoherence));
const avgDrift = avg(detailMetrics.map(m => m.driftSimilarity));
const shadowStayCount = detailMetrics.filter(m => m.shadowStay).length;
const shadowReturnCount = detailMetrics.filter(m => m.shadowReturn).length;
const pct = (v: number | null) => v != null ? `${(v * 100).toFixed(1)}%` : '-';
const bar = (v: number | null, color: string) => (
<div className="w-full h-1.5 bg-surface-overlay rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${Math.min(100, (v ?? 0) * 100)}%`, backgroundColor: color }} />
</div>
);
return (
<div className="bg-surface-raised rounded-lg p-3 space-y-3 border border-purple-500/30">
{/* 헤더 */}
<div className="flex items-center gap-2 text-xs">
<BarChart3 className="w-3.5 h-3.5 text-purple-600 dark:text-purple-400" />
<span className="text-label font-medium"> </span>
<span className="text-cyan-600 dark:text-cyan-400 font-mono text-[11px]">{cand.targetMmsi}</span>
<span className="text-hint text-[9px] truncate">{cand.targetName}</span>
</div>
{/* 종합 점수 */}
<div className="flex items-center gap-3 py-2 border-y border-border">
<div className="text-center flex-1">
<div className={`text-lg font-bold ${cand.score >= 0.72 ? 'text-green-400' : cand.score >= 0.5 ? 'text-yellow-400' : 'text-hint'}`}>
{(cand.score * 100).toFixed(1)}%
</div>
<div className="text-[9px] text-hint"> </div>
</div>
<div className="text-center flex-1">
<div className="text-sm font-bold text-label">{cand.streak}</div>
<div className="text-[9px] text-hint"> </div>
</div>
<div className="text-center flex-1">
<div className="text-sm font-bold text-label">{detailMetrics.length}</div>
<div className="text-[9px] text-hint">raw </div>
</div>
</div>
{/* 점수 근거 상세 */}
{detailLoading ? (
<div className="flex items-center justify-center py-4"><Loader2 className="w-4 h-4 animate-spin text-hint" /></div>
) : (
<div className="space-y-2 text-[10px]">
<div className="text-hint font-medium text-[9px]"> ( {detailMetrics.length} )</div>
{[
{ label: '근접도', value: avgProximity, color: '#06b6d4', desc: '어구-선박 거리 근접 비율' },
{ label: '방문 점수', value: avgVisit, color: '#8b5cf6', desc: '어구 구역 방문 빈도' },
{ label: '활동 동기화', value: avgActivity, color: '#f59e0b', desc: '어구-선박 활동 시간 일치' },
{ label: 'DTW 유사도', value: avgDtw, color: '#ec4899', desc: '궤적 형태 유사도' },
{ label: '속도 상관', value: avgSpeed, color: '#14b8a6', desc: '속도 변화 패턴 일치' },
{ label: '침로 일관성', value: avgHeading, color: '#3b82f6', desc: '방향 변화 일치' },
{ label: '드리프트 유사도', value: avgDrift, color: '#64748b', desc: '표류 패턴 유사' },
].map(({ label, value, color, desc }) => (
<div key={label} className="flex items-center gap-2">
<span className="w-[70px] text-label shrink-0">{label}</span>
<div className="flex-1">{bar(value, color)}</div>
<span className="w-10 text-right font-mono text-label">{pct(value)}</span>
<span className="w-[90px] text-hint text-[8px] truncate" title={desc}>{desc}</span>
</div>
))}
{/* 보정 지표 */}
<div className="text-hint font-medium text-[9px] pt-1 border-t border-border"> </div>
<div className="grid grid-cols-2 gap-y-1 gap-x-4">
<span className="text-hint"> </span>
<span className="text-label text-right">{shadowStayCount}/{detailMetrics.length}</span>
<span className="text-hint"> </span>
<span className="text-label text-right">{shadowReturnCount}/{detailMetrics.length}</span>
<span className="text-hint"> </span>
<span className="text-right"><Badge intent={cand.freezeState === 'ACTIVE' ? 'success' : 'muted'} size="sm">{cand.freezeState === 'ACTIVE' ? '활성' : cand.freezeState}</Badge></span>
<span className="text-hint"> </span>
<span className="text-right"><Badge intent={cand.targetType === 'VESSEL' ? 'info' : 'muted'} size="sm">{cand.targetType === 'VESSEL' ? '선박' : cand.targetType === 'GEAR_BUOY' ? '어구' : cand.targetType}</Badge></span>
</div>
</div>
)}
{/* 확정/제외 버튼 */}
{resolveMsg && (
<div className={`text-[10px] text-center py-1 rounded ${resolveMsg.includes('완료') ? 'text-green-400 bg-green-500/10' : 'text-red-400 bg-red-500/10'}`}>
{resolveMsg}
</div>
)}
<div className="flex gap-2">
<Button variant="primary" size="sm" className="flex-1"
onClick={() => handleResolve('confirm')} disabled={resolveLoading}>
<CheckCircle className="w-3.5 h-3.5 mr-1" />
{resolveLoading ? '처리 중...' : '모선 확정'}
</Button>
<Button variant="outline" size="sm" className="flex-1"
onClick={() => handleResolve('reject')} disabled={resolveLoading}>
<XCircle className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</div>
);
})()}
{/* 궤적 리플레이 버튼 */}
<Button
variant={isReplayActive ? 'secondary' : 'primary'}
size="sm"
className="w-full"
onClick={handleStartReplay}
disabled={replayLoading}
>
{replayLoading ? (
<Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" />
) : (
<Play className="w-3.5 h-3.5 mr-1" />
)}
{replayLoading ? '데이터 로딩...'
: isReplayActive ? '리플레이 재시작'
: selectedCandidates.size > 0
? `리플레이 (후보 ${selectedCandidates.size}척 포함)`
: '24시간 궤적 리플레이'}
</Button>
{/* 쌍끌이 감지 정보 */}
{hasPairTrawl && (
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<Users className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
<span className="text-label font-medium"> </span>
<Badge intent="critical" size="sm">G-06</Badge>
</div>
<div className="grid grid-cols-2 gap-y-1 text-xs">
<span className="text-hint"> </span>
<span className="text-label text-right font-mono">
{gear.pairTrawlPairMmsi ? (
<button type="button" className="text-cyan-600 dark:text-cyan-400 hover:underline"
onClick={() => navigate(`/vessel/${gear.pairTrawlPairMmsi}`)}>
{gear.pairTrawlPairMmsi}
</button>
) : '-'}
</span>
{gear.gearViolationEvidence['G-06'] && (() => {
const ev = gear.gearViolationEvidence['G-06'];
return (
<>
{ev.sync_duration_min != null && (
<>
<span className="text-hint"> </span>
<span className="text-label text-right font-mono">{String(ev.sync_duration_min)}</span>
</>
)}
{ev.mean_separation_nm != null && (
<>
<span className="text-hint"> </span>
<span className="text-label text-right font-mono">{(Number(ev.mean_separation_nm) * 1852).toFixed(0)}m</span>
</>
)}
</>
);
})()}
</div>
</div>
)}
{/* 위치 + 액션 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<MapPin className="w-3.5 h-3.5 text-yellow-600 dark:text-yellow-400" />
<span className="text-label font-medium"></span>
</div>
<div className="grid grid-cols-2 gap-y-1 text-xs">
<span className="text-hint"></span>
<span className="text-label text-right font-mono">{gear.lat.toFixed(6)}°N</span>
<span className="text-hint"></span>
<span className="text-label text-right font-mono">{gear.lng.toFixed(6)}°E</span>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" className="flex-1"
onClick={() => navigate(`/vessel/${gear.owner}`)}>
<Ship className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="primary" size="sm" className="flex-1"
onClick={() => { /* TODO: 단속 대상 등록 API */ }}>
<ShieldAlert className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</div>
</div>
);
}

파일 보기

@ -1,181 +0,0 @@
/**
* GearReplayController 24
*
* absolute .
* Zustand subscribe DOM React re-render .
*/
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@shared/components/ui/button';
import { useGearReplayStore } from '@stores/gearReplayStore';
import { Play, Pause, X } from 'lucide-react';
interface GearReplayControllerProps {
onClose: () => void;
}
const SPEED_OPTIONS = [1, 2, 5, 10] as const;
type SpeedOption = (typeof SPEED_OPTIONS)[number];
function formatEpochTime(epochMs: number): string {
if (epochMs === 0) return '--:--';
const d = new Date(epochMs);
const MM = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
return `${MM}/${dd} ${hh}:${mm}`;
}
export function GearReplayController({ onClose }: GearReplayControllerProps) {
const { t: tc } = useTranslation('common');
const play = useGearReplayStore((s) => s.play);
const pause = useGearReplayStore((s) => s.pause);
const seek = useGearReplayStore((s) => s.seek);
const setSpeed = useGearReplayStore((s) => s.setSpeed);
const isPlaying = useGearReplayStore((s) => s.isPlaying);
const playbackSpeed = useGearReplayStore((s) => s.playbackSpeed);
const startTime = useGearReplayStore((s) => s.startTime);
const endTime = useGearReplayStore((s) => s.endTime);
const snapshotRanges = useGearReplayStore((s) => s.snapshotRanges);
const dataStartTime = useGearReplayStore((s) => s.dataStartTime);
const dataEndTime = useGearReplayStore((s) => s.dataEndTime);
// DOM refs for direct updates — no React state during playback
const progressBarRef = useRef<HTMLDivElement>(null);
const timeLabelRef = useRef<HTMLSpanElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
// Subscribe to currentTime changes and update DOM directly
useEffect(() => {
const unsubscribe = useGearReplayStore.subscribe(
(s) => s.currentTime,
(currentTime) => {
const duration = endTime - startTime;
const pct = duration > 0 ? ((currentTime - startTime) / duration) * 100 : 0;
if (progressBarRef.current) {
progressBarRef.current.style.width = `${Math.min(100, pct)}%`;
}
if (timeLabelRef.current) {
timeLabelRef.current.textContent = formatEpochTime(currentTime);
}
},
);
return unsubscribe;
}, [startTime, endTime]);
// Handle click on track to seek
const handleTrackClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!trackRef.current) return;
const rect = trackRef.current.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
seek(startTime + ratio * (endTime - startTime));
};
const handlePlayPause = () => {
if (isPlaying) {
pause();
} else {
play();
}
};
const duration = endTime - startTime;
const initialPct =
duration > 0
? ((useGearReplayStore.getState().currentTime - startTime) / duration) * 100
: 0;
return (
<div className="absolute bottom-16 left-1/2 -translate-x-1/2 z-[1001] w-full max-w-[520px] px-3">
<div className="bg-background/95 backdrop-blur-sm border border-border rounded-lg px-3 py-2 shadow-lg flex items-center gap-2">
{/* Play / Pause */}
<Button
type="button"
variant="ghost"
size="sm"
aria-label={isPlaying ? '일시정지' : '재생'}
onClick={handlePlayPause}
className="shrink-0 p-1"
icon={
isPlaying ? (
<Pause className="w-4 h-4 text-heading" />
) : (
<Play className="w-4 h-4 text-heading" />
)
}
/>
{/* Speed selector */}
<div className="flex items-center gap-1 shrink-0">
{SPEED_OPTIONS.map((s) => (
<button
key={s}
type="button"
onClick={() => setSpeed(s)}
className={[
'text-xs px-1.5 py-0.5 rounded font-mono transition-colors',
playbackSpeed === s
? 'bg-primary text-on-vivid font-bold'
: 'text-hint hover:text-label hover:bg-surface-raised',
].join(' ')}
>
{s}x
</button>
))}
</div>
{/* Start time */}
<span className="text-[9px] font-mono text-hint shrink-0">{formatEpochTime(startTime)}</span>
{/* Progress track */}
<div
ref={trackRef}
className="flex-1 h-2 bg-surface-raised rounded-full cursor-pointer relative overflow-hidden min-w-[80px]"
onClick={handleTrackClick}
role="slider"
aria-label={tc('aria.replayPosition')}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(initialPct)}
>
{/* 스냅샷 틱마크 */}
{snapshotRanges.map((r, i) => (
<div
key={i}
className="absolute top-0 bottom-0 w-px bg-amber-400/50"
style={{ left: `${r * 100}%` }}
/>
))}
<div
ref={progressBarRef}
className="absolute inset-y-0 left-0 bg-primary rounded-full transition-none"
style={{ width: `${Math.min(100, Math.max(0, initialPct))}%` }}
/>
</div>
{/* End time */}
<span className="text-[9px] font-mono text-hint shrink-0">{formatEpochTime(endTime)}</span>
{/* Current time label */}
<span
ref={timeLabelRef}
className="text-[10px] font-mono text-heading shrink-0 w-[75px] text-center font-bold"
>
{formatEpochTime(useGearReplayStore.getState().currentTime)}
</span>
{/* Close */}
<button
type="button"
aria-label={tc('aria.replayClose')}
onClick={onClose}
className="shrink-0 p-1 hover:bg-surface-raised rounded"
>
<X className="w-3.5 h-3.5 text-hint" />
</button>
</div>
</div>
);
}

파일 보기

@ -1,138 +0,0 @@
/**
* 24h .
* 1 ~ .
*/
import { Loader2, AlertTriangle, ShieldAlert } from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { formatDateTime } from '@shared/utils/dateFormat';
import {
type AnomalySegment,
getAnomalyCategoryIntent,
getAnomalyCategoryLabel,
} from './vesselAnomaly';
interface Props {
segments: AnomalySegment[];
loading?: boolean;
error?: string;
totalHistoryCount: number;
}
function formatDuration(min: number): string {
if (min <= 0) return '단일 샘플';
if (min < 60) return `${min}`;
const h = Math.floor(min / 60);
const m = min % 60;
return m === 0 ? `${h}시간` : `${h}시간 ${m}`;
}
export function VesselAnomalyPanel({ segments, loading, error, totalHistoryCount }: Props) {
const criticalCount = segments.filter((s) => s.severity === 'critical').length;
const warningCount = segments.filter((s) => s.severity === 'warning').length;
const infoCount = segments.filter((s) => s.severity === 'info').length;
const totalSamples = segments.reduce((sum, s) => sum + s.pointCount, 0);
return (
<Card className="bg-surface-raised border-slate-700/30">
<CardContent className="p-3 space-y-2">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2 flex-wrap">
<ShieldAlert className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
<span className="text-[11px] font-bold text-heading"> </span>
{segments.length > 0 && (
<span className="text-[10px] text-hint flex items-center gap-1 flex-wrap">
· {segments.length}
{criticalCount > 0 && <span className="text-red-600 dark:text-red-400 ml-0.5">CRITICAL {criticalCount}</span>}
{warningCount > 0 && <span className="text-orange-600 dark:text-orange-400 ml-0.5">WARNING {warningCount}</span>}
{infoCount > 0 && <span className="text-blue-600 dark:text-blue-400 ml-0.5">INFO {infoCount}</span>}
</span>
)}
</div>
<span className="text-[9px] text-hint">
24h {totalHistoryCount} {totalSamples}
</span>
</div>
{error && (
<div className="flex items-center gap-2 px-2 py-1.5 rounded border border-red-500/30 bg-red-500/5 text-[10px] text-red-600 dark:text-red-400">
<AlertTriangle className="w-3 h-3 shrink-0" />
<span>{error}</span>
</div>
)}
{loading && (
<div className="flex items-center justify-center py-6 text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
</div>
)}
{!loading && !error && segments.length === 0 && totalHistoryCount > 0 && (
<div className="px-3 py-6 text-center text-[11px] text-hint">
24h Dark / Spoofing / / / .
</div>
)}
{!loading && !error && totalHistoryCount === 0 && (
<div className="px-3 py-6 text-center text-[11px] text-hint">
.
</div>
)}
{!loading && segments.length > 0 && (
<div className="max-h-[360px] overflow-y-auto space-y-1.5 pr-1">
{segments.map((s) => (
<div
key={s.id}
className={`rounded border px-2.5 py-2 text-[10px] ${
s.severity === 'critical'
? 'border-red-500/30 bg-red-500/5'
: s.severity === 'warning'
? 'border-orange-500/30 bg-orange-500/5'
: 'border-blue-500/30 bg-blue-500/5'
}`}
>
<div className="flex items-center justify-between gap-2 mb-1 flex-wrap">
<span className="font-mono text-label">
{formatDateTime(s.startTime)}
{s.startTime !== s.endTime && (
<>
<span className="text-hint mx-1"></span>
{formatDateTime(s.endTime)}
</>
)}
</span>
<span className="text-hint text-[9px]">
<span className="text-muted-foreground">{formatDuration(s.durationMin)}</span>
<span className="mx-1">·</span>
<span>{s.pointCount} </span>
{s.representativeLat != null && s.representativeLon != null && (
<>
<span className="mx-1">·</span>
<span
className="font-mono"
title="판별 근거가 된 이벤트 발생 지점 (예: AIS 신호 단절 시작 좌표). 실제 분석 시각 위치는 지도의 색칠 궤적 구간을 확인하세요."
>
{s.representativeLat.toFixed(3)}°N, {s.representativeLon.toFixed(3)}°E
</span>
<span className="text-muted-foreground ml-0.5">( )</span>
</>
)}
</span>
</div>
<div className="flex items-center gap-1 flex-wrap mb-1">
{s.categories.map((c) => (
<Badge key={c} intent={getAnomalyCategoryIntent(c)} size="xs" className="font-normal">
{getAnomalyCategoryLabel(c)}
</Badge>
))}
</div>
<div className="text-muted-foreground text-[10px] leading-snug">{s.description}</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}

파일 보기

@ -1,261 +0,0 @@
/**
* MMSI 24h .
* fetchVesselTracks (signal-batch ) PathLayer .
*/
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { Loader2, Ship, Clock, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PathLayer, ScatterplotLayer } from 'deck.gl';
import type { Layer } from 'deck.gl';
import { BaseMap, type MapHandle } from '@lib/map';
import { useMapLayers } from '@lib/map/hooks/useMapLayers';
import { fetchVesselTracks, type VesselTrack } from '@/services/vesselAnalysisApi';
import type { AnomalySegment } from './vesselAnomaly';
interface Props {
mmsi: string;
vesselName?: string;
hoursBack?: number;
segments?: AnomalySegment[];
onClose?: () => void;
}
function fmt(ts: string | number): string {
const n = typeof ts === 'string' ? parseInt(ts, 10) : ts;
if (!Number.isFinite(n)) return '-';
const d = new Date(n * 1000);
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
return `${mm}/${dd} ${hh}:${mi}`;
}
export function VesselMiniMap({ mmsi, vesselName, hoursBack = 24, segments = [], onClose }: Props) {
const { t: tc } = useTranslation('common');
const mapRef = useRef<MapHandle | null>(null);
const [track, setTrack] = useState<VesselTrack | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const load = useCallback(async () => {
setLoading(true); setError(''); setTrack(null);
try {
const end = new Date();
const start = new Date(end.getTime() - hoursBack * 3600 * 1000);
const res = await fetchVesselTracks(
[mmsi],
start.toISOString(),
end.toISOString(),
);
setTrack(res[0] ?? null);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : '궤적 조회 실패');
} finally {
setLoading(false);
}
}, [mmsi, hoursBack]);
useEffect(() => { load(); }, [load]);
// 궤적 로드 후 bounds 로 지도 이동
useEffect(() => {
if (!track || track.geometry.length === 0) return;
const map = mapRef.current?.map;
if (!map) return;
const lons = track.geometry.map((p) => p[0]);
const lats = track.geometry.map((p) => p[1]);
const w = Math.min(...lons), e = Math.max(...lons);
const s = Math.min(...lats), n = Math.max(...lats);
const span = Math.max(e - w, n - s);
if (span < 0.001) {
map.setCenter([(w + e) / 2, (s + n) / 2]);
map.setZoom(11);
} else {
map.fitBounds([[w, s], [e, n]], { padding: 24, maxZoom: 11, duration: 0 });
}
}, [track]);
// segment 의 [startTime, endTime] 범위에 들어오는 AIS 궤적 포인트를 뽑아 severity 색 path로 덧그린다.
// 이게 사용자가 '어떤 시간대 궤적이 특이운항으로 판별됐는지' 를 바로 알게 해주는 핵심 표시.
const segmentPaths = useMemo((): Array<{ id: string; path: [number, number][]; severity: AnomalySegment['severity'] }> => {
if (!track || track.timestamps.length === 0 || segments.length === 0) return [];
if (track.timestamps.length !== track.geometry.length) return [];
const epochs = track.timestamps.map((t) => Number(t) * 1000);
return segments
.map((seg) => {
const start = new Date(seg.startTime).getTime();
const end = new Date(seg.endTime).getTime();
const path: [number, number][] = [];
for (let i = 0; i < epochs.length; i++) {
if (epochs[i] >= start && epochs[i] <= end) path.push(track.geometry[i]);
}
return { id: seg.id, path, severity: seg.severity };
})
.filter((s) => s.path.length >= 2);
}, [track, segments]);
useMapLayers(mapRef, (): Layer[] => {
const layers: Layer[] = [];
if (track && track.geometry.length >= 2) {
layers.push(
new PathLayer({
id: `mini-track-${mmsi}`,
data: [{ path: track.geometry }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [59, 130, 246, 140],
getWidth: 2,
widthUnits: 'pixels',
jointRounded: true,
capRounded: true,
}),
);
}
if (segmentPaths.length > 0) {
layers.push(
new PathLayer<{ id: string; path: [number, number][]; severity: AnomalySegment['severity'] }>({
id: `mini-segment-paths-${mmsi}`,
data: segmentPaths,
getPath: (d) => d.path,
getColor: (d) =>
d.severity === 'critical' ? [239, 68, 68, 240]
: d.severity === 'warning' ? [249, 115, 22, 230]
: [59, 130, 246, 210],
getWidth: 4,
widthUnits: 'pixels',
widthMinPixels: 3,
jointRounded: true,
capRounded: true,
}),
);
}
if (track && track.geometry.length >= 2) {
layers.push(
new PathLayer({
id: `mini-track-head-${mmsi}`,
data: [{ path: track.geometry.slice(-2) }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [239, 68, 68, 255],
getWidth: 4,
widthUnits: 'pixels',
}),
);
}
// 이벤트 기준 좌표 (gap 시작점 등) — 분석 시각이 아니라 판별 근거가 된 과거 시점.
// 반복 분석이 같은 좌표를 참조하는 경우가 많아 작게/반투명하게 표시한다.
const geoSegments = segments.filter(
(s): s is AnomalySegment & { representativeLat: number; representativeLon: number } =>
s.representativeLat != null && s.representativeLon != null,
);
if (geoSegments.length > 0) {
layers.push(
new ScatterplotLayer<AnomalySegment & { representativeLat: number; representativeLon: number }>({
id: `mini-segments-${mmsi}`,
data: geoSegments,
getPosition: (d) => [d.representativeLon, d.representativeLat],
getRadius: 4,
radiusUnits: 'pixels',
radiusMinPixels: 4,
radiusMaxPixels: 6,
getFillColor: (d) =>
d.severity === 'critical' ? [239, 68, 68, 180]
: d.severity === 'warning' ? [249, 115, 22, 170]
: [59, 130, 246, 160],
getLineColor: [255, 255, 255, 220],
lineWidthMinPixels: 1,
stroked: true,
pickable: true,
}),
);
}
return layers;
}, [track, mmsi, segments, segmentPaths]);
const tsList = track?.timestamps ?? [];
const startTs = tsList[0];
const endTs = tsList[tsList.length - 1];
return (
<Card className="bg-surface-raised border-slate-700/30">
<CardContent className="p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<Ship className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400 shrink-0" />
<div className="min-w-0">
<div className="text-[11px] font-bold text-heading truncate flex items-center gap-1.5">
{vesselName ?? mmsi}
<span className="text-[9px] text-hint font-mono">{mmsi}</span>
{segments.length > 0 && (
<Badge intent="critical" size="xs" className="font-normal">
{segments.length}
</Badge>
)}
</div>
<div className="flex items-center gap-1 text-[9px] text-hint">
<Clock className="w-2.5 h-2.5" />
<span>{startTs ? fmt(startTs) : '-'}</span>
<span></span>
<span>{endTs ? fmt(endTs) : '-'}</span>
<span className="ml-1 text-muted-foreground">· {track?.pointCount ?? 0} pts</span>
</div>
</div>
</div>
{onClose && (
<button type="button" onClick={onClose} aria-label={tc('aria.miniMapClose')}
className="p-1 rounded text-hint hover:text-heading hover:bg-surface-overlay shrink-0">
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
<div className="relative h-80 rounded overflow-hidden border border-slate-700/30">
<BaseMap ref={mapRef} height={320} interactive={true} zoom={7} />
{loading && (
<div className="absolute inset-0 bg-background/60 flex items-center justify-center">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
)}
{!loading && error && (
<div className="absolute inset-0 bg-background/80 flex items-center justify-center text-xs text-red-600 dark:text-red-400 px-3 text-center">
{error}
</div>
)}
{!loading && !error && track && track.geometry.length < 2 && (
<div className="absolute inset-0 bg-background/60 flex items-center justify-center text-[11px] text-hint">
24 (AIS )
</div>
)}
</div>
{segments.length > 0 && (
<div className="flex items-start gap-x-3 gap-y-0.5 flex-wrap text-[9px] text-hint">
<span className="flex items-center gap-1">
<span className="inline-block w-4 h-[2px] bg-red-500" />
CRITICAL
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-4 h-[2px] bg-orange-500" />
WARNING
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-4 h-[2px] bg-blue-500" />
INFO
</span>
<span className="text-muted-foreground"> = AIS </span>
<span className="flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-white/80 inline-block border border-slate-600" />
</span>
{segmentPaths.length < segments.length && (
<span className="text-muted-foreground">
· {segments.length - segmentPaths.length}
</span>
)}
</div>
)}
</CardContent>
</Card>
);
}

파일 보기

@ -1,241 +0,0 @@
/**
* vessel_analysis_results '특이운항' .
*
* 24h history '이상 신호' AnomalyPoint .
* - DARK: is_dark=true
* - SPOOFING: spoofing_score >= 0.3
* - TRANSSHIP: transship_suspect=true
* - GEAR_VIOLATION: gear_judgment NORMAL
* - HIGH_RISK: risk_level CRITICAL/HIGH/MEDIUM (score>=40)
*
* top-level lat/lon null features.gap_start_lat/lon fallback.
* , .
*/
import type { VesselAnalysis } from '@/services/analysisApi';
import type { AlertLevel } from '@shared/constants/alertLevels';
/** riskLevel → 특이운항 패널의 severity/한글 레이블 매핑. */
const HIGH_RISK_LEVEL_META: Partial<Record<AlertLevel, { severity: AnomalyPoint['severity']; label: string }>> = {
CRITICAL: { severity: 'critical', label: '고위험 CRITICAL' },
HIGH: { severity: 'warning', label: '위험 HIGH' },
MEDIUM: { severity: 'info', label: '주의 MEDIUM' },
};
export type AnomalyCategory =
| 'DARK'
| 'SPOOFING'
| 'TRANSSHIP'
| 'GEAR_VIOLATION'
| 'HIGH_RISK';
export interface AnomalyPoint {
id: number;
timestamp: string; // ISO
lat: number | null; // 좌표 없을 수 있음 (미니맵 포인트 생략)
lon: number | null;
severity: 'critical' | 'warning' | 'info';
categories: AnomalyCategory[];
description: string; // 사람이 읽는 요약
raw: VesselAnalysis;
}
const CATEGORY_LABEL: Record<AnomalyCategory, string> = {
DARK: '다크베셀',
SPOOFING: 'GPS 스푸핑',
TRANSSHIP: '환적 의심',
GEAR_VIOLATION: '어구 위반',
HIGH_RISK: '고위험',
};
export function getAnomalyCategoryLabel(c: AnomalyCategory): string {
return CATEGORY_LABEL[c];
}
export function getAnomalyCategoryIntent(c: AnomalyCategory): 'critical' | 'warning' | 'info' | 'muted' {
switch (c) {
case 'TRANSSHIP':
return 'critical';
case 'DARK':
case 'SPOOFING':
case 'GEAR_VIOLATION':
case 'HIGH_RISK':
return 'warning';
default:
return 'muted';
}
}
/** severity 우선순위 비교 (critical > warning > info). */
function bumpSeverity(cur: AnomalyPoint['severity'], next: AnomalyPoint['severity']): AnomalyPoint['severity'] {
if (cur === 'critical' || next === 'critical') return 'critical';
if (cur === 'warning' || next === 'warning') return 'warning';
return 'info';
}
/** top-level lat/lon → features.gap_start_lat/lon fallback. */
function extractCoord(v: VesselAnalysis): { lat: number | null; lon: number | null } {
if (typeof v.lat === 'number' && typeof v.lon === 'number') return { lat: v.lat, lon: v.lon };
const f = v.features;
if (f) {
const fLat = f['gap_start_lat'];
const fLon = f['gap_start_lon'];
if (typeof fLat === 'number' && typeof fLon === 'number') return { lat: fLat, lon: fLon };
}
return { lat: null, lon: null };
}
/** features.dark_patterns → 한글/영문 그대로 나열. */
function extractDarkPatterns(v: VesselAnalysis): string[] {
const dp = v.features?.['dark_patterns'];
if (!Array.isArray(dp)) return [];
return dp.filter((x): x is string => typeof x === 'string');
}
/**
* analysis AnomalyPoint ( null).
*/
export function classifyAnomaly(v: VesselAnalysis): AnomalyPoint | null {
const cats: AnomalyCategory[] = [];
const descs: string[] = [];
let severity: AnomalyPoint['severity'] = 'info';
if (v.isDark) {
cats.push('DARK');
const patterns = extractDarkPatterns(v);
const patternTxt = patterns.length ? `, 패턴: ${patterns.join(', ')}` : '';
descs.push(`다크베셀 (gap ${v.gapDurationMin ?? 0}${v.darkPattern ? `, ${v.darkPattern}` : ''}${patternTxt})`);
severity = bumpSeverity(severity, 'warning');
}
if ((v.spoofingScore ?? 0) >= 0.3) {
cats.push('SPOOFING');
descs.push(`GPS 스푸핑 (score ${Number(v.spoofingScore).toFixed(2)}${v.speedJumpCount ? `, speed jump ${v.speedJumpCount}` : ''})`);
severity = bumpSeverity(severity, 'warning');
}
if (v.transshipSuspect) {
cats.push('TRANSSHIP');
descs.push(`환적 의심 (페어 ${v.transshipPairMmsi || '-'}, ${v.transshipDurationMin ?? 0}분)`);
severity = bumpSeverity(severity, 'critical');
}
if (v.gearJudgment && v.gearJudgment !== 'NORMAL') {
cats.push('GEAR_VIOLATION');
descs.push(`어구 판정 ${v.gearJudgment}${v.gearCode ? ` (${v.gearCode})` : ''}`);
severity = bumpSeverity(severity, 'warning');
}
const levelMeta = HIGH_RISK_LEVEL_META[v.riskLevel as AlertLevel];
if (levelMeta) {
const score = v.riskScore ?? 0;
const include = v.riskLevel === 'MEDIUM' ? score >= 40 : true;
if (include) {
if (!cats.includes('HIGH_RISK')) cats.push('HIGH_RISK');
descs.push(`${levelMeta.label} (score ${score})`);
severity = bumpSeverity(severity, levelMeta.severity);
}
}
if (cats.length === 0) return null;
const { lat, lon } = extractCoord(v);
return {
id: v.id,
timestamp: v.analyzedAt,
lat,
lon,
severity,
categories: Array.from(new Set(cats)),
description: descs.join(' · '),
raw: v,
};
}
/** history 배열 → 최신순 anomaly 포인트. */
export function extractAnomalies(history: VesselAnalysis[]): AnomalyPoint[] {
return history
.map(classifyAnomaly)
.filter((x): x is AnomalyPoint => x !== null)
.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
}
// ─── 구간(Segment) 병합 ────────────────────────────
// prediction 은 5분 주기로 분석 결과를 write 하므로, 동일 신호(카테고리 집합 일치)가
// 연속해서 쌓인 구간은 사용자 관점에서 '같은 사건'이다. 이를 시작~종료 구간으로 병합한다.
export interface AnomalySegment {
id: string;
categories: AnomalyCategory[];
severity: AnomalyPoint['severity'];
startTime: string;
endTime: string;
durationMin: number;
pointCount: number; // 병합된 원본 분석 행 수
description: string; // 대표 description (첫 포인트)
representativeLat: number | null;
representativeLon: number | null;
geoPoints: Array<{ lat: number; lon: number; timestamp: string }>;
points: AnomalyPoint[];
}
function categoryKey(cats: AnomalyCategory[]): string {
return [...cats].sort().join('|');
}
function buildSegment(points: AnomalyPoint[]): AnomalySegment {
const first = points[0];
const last = points[points.length - 1];
const startTime = first.timestamp;
const endTime = last.timestamp;
const durationMin = Math.max(
0,
Math.round((new Date(endTime).getTime() - new Date(startTime).getTime()) / 60000),
);
const geoPoints = points
.filter((p): p is AnomalyPoint & { lat: number; lon: number } => p.lat != null && p.lon != null)
.map((p) => ({ lat: p.lat, lon: p.lon, timestamp: p.timestamp }));
// 대표 좌표: 구간 중간 좌표 (없으면 첫 좌표)
const mid = geoPoints[Math.floor(geoPoints.length / 2)] ?? geoPoints[0] ?? null;
return {
id: `${first.timestamp}_${categoryKey(first.categories)}`,
categories: first.categories,
severity: first.severity,
startTime,
endTime,
durationMin,
pointCount: points.length,
description: first.description,
representativeLat: mid?.lat ?? null,
representativeLon: mid?.lon ?? null,
geoPoints,
points,
};
}
/**
* anomaly + .
* 5 .
*/
export function groupAnomaliesToSegments(points: AnomalyPoint[]): AnomalySegment[] {
if (points.length === 0) return [];
// 시간 오름차순 정렬 후 인접 병합
const asc = [...points].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
const segments: AnomalySegment[] = [];
let bucket: AnomalyPoint[] = [];
let key = '';
for (const p of asc) {
const k = categoryKey(p.categories);
if (bucket.length === 0) {
bucket.push(p);
key = k;
} else if (k === key) {
bucket.push(p);
} else {
segments.push(buildSegment(bucket));
bucket = [p];
key = k;
}
}
if (bucket.length > 0) segments.push(buildSegment(bucket));
// 최신 구간이 위로 오도록 역순 반환
return segments.sort((a, b) => b.startTime.localeCompare(a.startTime));
}

파일 보기

@ -2,4 +2,3 @@ export { DarkVesselDetection } from './DarkVesselDetection';
export { GearDetection } from './GearDetection';
export { ChinaFishing } from './ChinaFishing';
export { GearIdentification } from './GearIdentification';
export { GearCollisionDetection } from './GearCollisionDetection';

파일 보기

@ -131,7 +131,7 @@ export function EventList() {
},
},
{ key: 'vesselName', label: '선박명', minWidth: '100px', maxWidth: '220px', sortable: true,
render: (val) => <span className="text-cyan-600 dark:text-cyan-400 font-medium">{val as string}</span>,
render: (val) => <span className="text-cyan-400 font-medium">{val as string}</span>,
},
{ key: 'mmsi', label: 'MMSI', minWidth: '90px', maxWidth: '120px',
render: (_val, row) => {
@ -140,7 +140,7 @@ export function EventList() {
return (
<button
type="button"
className="text-cyan-600 dark:text-cyan-400 hover:text-cyan-700 dark:hover:text-cyan-300 hover:underline font-mono text-[10px]"
className="text-cyan-400 hover:text-cyan-300 hover:underline font-mono text-[10px]"
onClick={(e) => { e.stopPropagation(); navigate(`/vessel/${mmsi}`); }}
>
{mmsi}
@ -171,26 +171,26 @@ export function EventList() {
return (
<div className="flex items-center gap-1">
{isNew && (
<button type="button" aria-label={tc('aria.confirmAction')} title={canAck ? '확인(ACK)' : '확인 권한이 필요합니다'}
className="p-0.5 rounded hover:bg-blue-500/20 text-blue-600 dark:text-blue-400 disabled:opacity-30 disabled:cursor-not-allowed"
<button type="button" aria-label="확인" title={canAck ? '확인(ACK)' : '확인 권한이 필요합니다'}
className="p-0.5 rounded hover:bg-blue-500/20 text-blue-400 disabled:opacity-30 disabled:cursor-not-allowed"
disabled={busy || !canAck} onClick={(e) => { e.stopPropagation(); handleAck(eid); }}>
<CheckCircle className="w-3.5 h-3.5" />
</button>
)}
<button type="button" aria-label={tc('aria.vesselDetail')} title="선박 상세"
className="p-0.5 rounded hover:bg-cyan-500/20 text-cyan-600 dark:text-cyan-400"
<button type="button" aria-label="선박 상세" title="선박 상세"
className="p-0.5 rounded hover:bg-cyan-500/20 text-cyan-400"
onClick={(e) => { e.stopPropagation(); if (row.mmsi !== '-') navigate(`/vessel/${row.mmsi}`); }}>
<Ship className="w-3.5 h-3.5" />
</button>
{isActionable && (
<>
<button type="button" aria-label={tc('aria.enforcementRegister')} title={canCreateEnforcement ? '단속 등록' : '단속 등록 권한이 필요합니다'}
className="p-0.5 rounded hover:bg-green-500/20 text-green-600 dark:text-green-400 disabled:opacity-30 disabled:cursor-not-allowed"
<button type="button" aria-label="단속 등록" title={canCreateEnforcement ? '단속 등록' : '단속 등록 권한이 필요합니다'}
className="p-0.5 rounded hover:bg-green-500/20 text-green-400 disabled:opacity-30 disabled:cursor-not-allowed"
disabled={busy || !canCreateEnforcement} onClick={(e) => { e.stopPropagation(); handleCreateEnforcement(row); }}>
<Shield className="w-3.5 h-3.5" />
</button>
<button type="button" aria-label={tc('aria.falsePositiveProcess')} title={canAck ? '오탐 처리' : '상태 변경 권한이 필요합니다'}
className="p-0.5 rounded hover:bg-red-500/20 text-red-600 dark:text-red-400 disabled:opacity-30 disabled:cursor-not-allowed"
<button type="button" aria-label="오탐 처리" title={canAck ? '오탐 처리' : '상태 변경 권한이 필요합니다'}
className="p-0.5 rounded hover:bg-red-500/20 text-red-400 disabled:opacity-30 disabled:cursor-not-allowed"
disabled={busy || !canAck} onClick={(e) => { e.stopPropagation(); handleFalsePositive(eid); }}>
<Ban className="w-3.5 h-3.5" />
</button>
@ -317,7 +317,7 @@ export function EventList() {
{/* 에러 표시 */}
{error && (
<div className="rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-[11px] text-red-600 dark:text-red-400">
<div className="rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-[11px] text-red-400">
: {error}
</div>
)}
@ -327,7 +327,7 @@ export function EventList() {
<div className="rounded-xl border border-border bg-card p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] text-label font-bold"> </span>
<button type="button" aria-label={tc('aria.close')} title="닫기" onClick={() => setShowUpload(false)} className="text-hint hover:text-muted-foreground">
<button type="button" title="닫기" onClick={() => setShowUpload(false)} className="text-hint hover:text-muted-foreground">
<X className="w-4 h-4" />
</button>
</div>
@ -343,7 +343,7 @@ export function EventList() {
{/* 로딩 인디케이터 */}
{loading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 text-blue-600 dark:text-blue-400 animate-spin" />
<Loader2 className="w-5 h-5 text-blue-400 animate-spin" />
<span className="ml-2 text-[11px] text-muted-foreground"> ...</span>
</div>
)}

파일 보기

@ -37,7 +37,7 @@ const cols: DataColumn<AlertRow>[] = [
key: 'eventId',
label: '이벤트',
width: '80px',
render: (v) => <span className="text-cyan-600 dark:text-cyan-400 font-mono text-[10px]">EVT-{v as number}</span>,
render: (v) => <span className="text-cyan-400 font-mono text-[10px]">EVT-{v as number}</span>,
},
{
key: 'time',
@ -58,7 +58,7 @@ const cols: DataColumn<AlertRow>[] = [
{
key: 'recipient',
label: '수신 대상',
render: (v) => <span className="text-cyan-600 dark:text-cyan-400">{v as string}</span>,
render: (v) => <span className="text-cyan-400">{v as string}</span>,
},
{
key: 'confidence',
@ -70,7 +70,7 @@ const cols: DataColumn<AlertRow>[] = [
const s = v as string;
if (!s) return <span className="text-hint">-</span>;
const n = parseFloat(s);
const color = n > 0.9 ? 'text-red-600 dark:text-red-400' : n > 0.8 ? 'text-orange-600 dark:text-orange-400' : 'text-yellow-600 dark:text-yellow-400';
const color = n > 0.9 ? 'text-red-400' : n > 0.8 ? 'text-orange-400' : 'text-yellow-400';
return <span className={`font-bold ${color}`}>{(n * 100).toFixed(0)}%</span>;
},
},
@ -149,10 +149,10 @@ export function AIAlert() {
if (error) {
return (
<PageContainer>
<div className="flex items-center justify-center gap-2 text-red-600 dark:text-red-400 py-8">
<div className="flex items-center justify-center gap-2 text-red-400 py-8">
<AlertTriangle className="w-5 h-5" />
<span> : {error}</span>
<button type="button" onClick={fetchAlerts} className="ml-2 text-xs underline text-cyan-600 dark:text-cyan-400">
<button type="button" onClick={fetchAlerts} className="ml-2 text-xs underline text-cyan-400">
</button>
</div>
@ -164,15 +164,15 @@ export function AIAlert() {
<PageContainer>
<PageHeader
icon={Send}
iconColor="text-yellow-600 dark:text-yellow-400"
iconColor="text-yellow-400"
title={t('aiAlert.title')}
description={t('aiAlert.desc')}
/>
<div className="flex gap-2">
{[
{ l: '총 발송', v: totalElements, c: 'text-heading' },
{ l: '수신확인', v: deliveredCount, c: 'text-green-600 dark:text-green-400' },
{ l: '실패', v: failedCount, c: 'text-red-600 dark:text-red-400' },
{ l: '수신확인', v: deliveredCount, c: 'text-green-400' },
{ l: '실패', v: failedCount, c: 'text-red-400' },
].map((k) => (
<div
key={k.l}

파일 보기

@ -56,7 +56,7 @@ export function MobileService() {
<PageContainer>
<PageHeader
icon={Smartphone}
iconColor="text-blue-600 dark:text-blue-400"
iconColor="text-blue-400"
title={t('mobileService.title')}
description={t('mobileService.desc')}
/>
@ -70,7 +70,7 @@ export function MobileService() {
<div className="p-3 space-y-2">
{/* 긴급 경보 */}
<div className="bg-red-500/15 border border-red-500/20 rounded-lg p-2">
<div className="text-[9px] text-red-600 dark:text-red-400 font-bold">[] EEZ </div>
<div className="text-[9px] text-red-400 font-bold">[] EEZ </div>
<div className="text-[8px] text-hint">N37°12' E124°38' · 08:47</div>
</div>
{/* 지도 영역 — MapLibre GL */}
@ -125,14 +125,14 @@ export function MobileService() {
{ icon: WifiOff, name: '오프라인 지원', desc: '통신 불안정 시 지도·객체 임시 저장' },
].map(f => (
<div key={f.name} className="flex items-start gap-2 p-2 bg-surface-overlay rounded-lg">
<f.icon className="w-4 h-4 text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" />
<f.icon className="w-4 h-4 text-blue-400 shrink-0 mt-0.5" />
<div><div className="text-[10px] text-heading font-medium">{f.name}</div><div className="text-[9px] text-hint">{f.desc}</div></div>
</div>
))}
</div>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5"><Bell className="w-4 h-4 text-yellow-600 dark:text-yellow-400" /> </div>
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5"><Bell className="w-4 h-4 text-yellow-400" /> </div>
<div className="space-y-2">
{PUSH_SETTINGS.map(p => (
<div key={p.name} className="flex items-center justify-between px-3 py-2 bg-surface-overlay rounded-lg">

파일 보기

@ -88,7 +88,7 @@ export function MonitoringDashboard() {
title={t('monitoring.title')}
description={t('monitoring.desc')}
/>
{/* 백엔드 + prediction 분석 엔진 시스템 상태 (실시간) */}
{/* iran 백엔드 + Prediction 시스템 상태 (실시간) */}
<SystemStatusPanel />
<div className="flex gap-2">

파일 보기

@ -29,7 +29,7 @@ interface AnalysisStatus {
*
* :
* 1. (kcg-ai-backend)
* 2. (prediction) +
* 2. iran + Prediction ( )
* 3. ( )
*/
export function SystemStatusPanel() {
@ -94,10 +94,10 @@ export function SystemStatusPanel() {
]}
/>
{/* 분석 엔진 */}
{/* iran 백엔드 */}
<ServiceCard
icon={<Wifi className="w-4 h-4" />}
title="AI 분석 엔진"
title="iran 백엔드 (분석)"
status={stats ? 'CONNECTED' : 'DISCONNECTED'}
statusIntent={stats ? 'success' : 'critical'}
details={[

파일 보기

@ -3,7 +3,6 @@ import { Tag, X, Loader2 } from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { Input } from '@shared/components/ui/input';
import { Select } from '@shared/components/ui/select';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { useAuth } from '@/app/auth/AuthContext';
@ -68,7 +67,7 @@ export function LabelSession() {
setGroupKey(''); setLabelMmsi('');
await load();
} catch (e: unknown) {
alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
} finally {
setBusy(null);
}
@ -76,13 +75,13 @@ export function LabelSession() {
const handleCancel = async (id: number) => {
if (!canUpdate) return;
if (!confirm(tc('dialog.cancelSession'))) return;
if (!confirm('세션을 취소하시겠습니까?')) return;
setBusy(id);
try {
await cancelLabelSession(id, '운영자 취소');
await load();
} catch (e: unknown) {
alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
} finally {
setBusy(null);
}
@ -99,7 +98,7 @@ export function LabelSession() {
<>
<Select
size="sm"
aria-label={tc('aria.statusFilter')}
aria-label="상태 필터"
title="상태 필터"
value={filter}
onChange={(e) => setFilter(e.target.value)}
@ -120,29 +119,26 @@ export function LabelSession() {
<CardContent className="p-4">
<div className="text-xs font-medium text-heading mb-2 flex items-center gap-2">
<Tag className="w-3.5 h-3.5" />
{!canCreate && <span className="text-yellow-600 dark:text-yellow-400 text-[10px]"> </span>}
{!canCreate && <span className="text-yellow-400 text-[10px]"> </span>}
</div>
<div className="flex items-center gap-2">
<Input aria-label={tc('aria.groupKey')} size="sm" value={groupKey} onChange={(e) => setGroupKey(e.target.value)} placeholder="group_key"
className="flex-1" disabled={!canCreate} />
<Input aria-label={tc('aria.subClusterId')} size="sm" type="number" value={subCluster} onChange={(e) => setSubCluster(e.target.value)} placeholder="sub"
className="w-24" disabled={!canCreate} />
<Input aria-label={tc('aria.correctParentMmsi')} size="sm" value={labelMmsi} onChange={(e) => setLabelMmsi(e.target.value)} placeholder="정답 parent MMSI"
className="w-48" disabled={!canCreate} />
<Button
variant="primary"
size="sm"
onClick={handleCreate}
disabled={!canCreate || !groupKey || !labelMmsi || busy === -1}
icon={busy === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Tag className="w-3.5 h-3.5" />}
>
<input aria-label="group_key" value={groupKey} onChange={(e) => setGroupKey(e.target.value)} placeholder="group_key"
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} />
<input aria-label="sub_cluster_id" type="number" value={subCluster} onChange={(e) => setSubCluster(e.target.value)} placeholder="sub"
className="w-24 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} />
<input aria-label="정답 parent MMSI" value={labelMmsi} onChange={(e) => setLabelMmsi(e.target.value)} placeholder="정답 parent MMSI"
className="w-48 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} />
<button type="button" onClick={handleCreate}
disabled={!canCreate || !groupKey || !labelMmsi || busy === -1}
className="px-3 py-1.5 bg-purple-600 hover:bg-purple-500 disabled:bg-purple-600/40 text-white text-xs rounded flex items-center gap-1">
{busy === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Tag className="w-3.5 h-3.5" />}
</Button>
</button>
</div>
</CardContent>
</Card>
{error && <div className="text-xs text-red-600 dark:text-red-400">{tc('error.errorPrefix', { msg: error })}</div>}
{error && <div className="text-xs text-red-400">: {error}</div>}
{loading && (
<div className="flex items-center justify-center py-12 text-muted-foreground">
@ -175,7 +171,7 @@ export function LabelSession() {
<td className="px-3 py-2 text-hint font-mono">{it.id}</td>
<td className="px-3 py-2 text-heading font-medium">{it.groupKey}</td>
<td className="px-3 py-2 text-center text-muted-foreground">{it.subClusterId}</td>
<td className="px-3 py-2 text-cyan-600 dark:text-cyan-400 font-mono">{it.labelParentMmsi}</td>
<td className="px-3 py-2 text-cyan-400 font-mono">{it.labelParentMmsi}</td>
<td className="px-3 py-2">
<Badge intent={getLabelSessionIntent(it.status)} size="sm">{getLabelSessionLabel(it.status, tc, lang)}</Badge>
</td>
@ -184,7 +180,7 @@ export function LabelSession() {
<td className="px-3 py-2 text-center">
{it.status === 'ACTIVE' && (
<button type="button" disabled={!canUpdate || busy === it.id} onClick={() => handleCancel(it.id)}
className="p-1 rounded hover:bg-red-500/20 disabled:opacity-30 text-red-600 dark:text-red-400" title="취소" aria-label="취소">
className="p-1 rounded hover:bg-red-500/20 disabled:opacity-30 text-red-400" title="취소">
<X className="w-3.5 h-3.5" />
</button>
)}

파일 보기

@ -3,7 +3,6 @@ import { Ban, RotateCcw, Loader2, Globe, Layers } from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { Input } from '@shared/components/ui/input';
import { Select } from '@shared/components/ui/select';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { useAuth } from '@/app/auth/AuthContext';
@ -15,7 +14,6 @@ import {
type CandidateExclusion,
} from '@/services/parentInferenceApi';
import { formatDateTime } from '@shared/utils/dateFormat';
import { useTranslation } from 'react-i18next';
/**
* .
@ -28,7 +26,6 @@ import { useTranslation } from 'react-i18next';
*/
export function ParentExclusion() {
const { t: tc } = useTranslation('common');
const { hasPermission } = useAuth();
const canCreateGroup = hasPermission('parent-inference-workflow:parent-exclusion', 'CREATE');
const canRelease = hasPermission('parent-inference-workflow:parent-exclusion', 'UPDATE');
@ -74,7 +71,7 @@ export function ParentExclusion() {
setGrpKey(''); setGrpMmsi(''); setGrpReason('');
await load();
} catch (e: unknown) {
alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
} finally {
setBusy(null);
}
@ -88,7 +85,7 @@ export function ParentExclusion() {
setGlbMmsi(''); setGlbReason('');
await load();
} catch (e: unknown) {
alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
} finally {
setBusy(null);
}
@ -101,7 +98,7 @@ export function ParentExclusion() {
await releaseExclusion(id, '운영자 해제');
await load();
} catch (e: unknown) {
alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
} finally {
setBusy(null);
}
@ -118,7 +115,7 @@ export function ParentExclusion() {
<>
<Select
size="sm"
aria-label={tc('aria.scopeFilter')}
aria-label="스코프 필터"
title="스코프 필터"
value={filter}
onChange={(e) => setFilter(e.target.value as '' | 'GROUP' | 'GLOBAL')}
@ -139,26 +136,23 @@ export function ParentExclusion() {
<CardContent className="p-4 space-y-2">
<div className="text-xs font-medium text-heading flex items-center gap-2">
<Layers className="w-3.5 h-3.5" /> GROUP ( )
{!canCreateGroup && <span className="text-yellow-600 dark:text-yellow-400 text-[10px]"> </span>}
{!canCreateGroup && <span className="text-yellow-400 text-[10px]"> </span>}
</div>
<div className="flex items-center gap-2">
<Input aria-label={tc('aria.groupKey')} size="sm" value={grpKey} onChange={(e) => setGrpKey(e.target.value)} placeholder="group_key"
className="flex-1" disabled={!canCreateGroup} />
<Input aria-label={tc('aria.subClusterId')} size="sm" type="number" value={grpSub} onChange={(e) => setGrpSub(e.target.value)} placeholder="sub"
className="w-24" disabled={!canCreateGroup} />
<Input aria-label={tc('aria.excludedMmsi')} size="sm" value={grpMmsi} onChange={(e) => setGrpMmsi(e.target.value)} placeholder="excluded MMSI"
className="w-40" disabled={!canCreateGroup} />
<Input aria-label={tc('aria.exclusionReason')} size="sm" value={grpReason} onChange={(e) => setGrpReason(e.target.value)} placeholder="사유"
className="flex-1" disabled={!canCreateGroup} />
<Button
variant="primary"
size="sm"
onClick={handleAddGroup}
disabled={!canCreateGroup || !grpKey || !grpMmsi || busy === -1}
icon={busy === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Ban className="w-3.5 h-3.5" />}
>
<input aria-label="group_key" value={grpKey} onChange={(e) => setGrpKey(e.target.value)} placeholder="group_key"
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} />
<input aria-label="sub_cluster_id" type="number" value={grpSub} onChange={(e) => setGrpSub(e.target.value)} placeholder="sub"
className="w-24 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} />
<input aria-label="excluded MMSI" value={grpMmsi} onChange={(e) => setGrpMmsi(e.target.value)} placeholder="excluded MMSI"
className="w-40 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} />
<input aria-label="제외 사유" value={grpReason} onChange={(e) => setGrpReason(e.target.value)} placeholder="사유"
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} />
<button type="button" onClick={handleAddGroup}
disabled={!canCreateGroup || !grpKey || !grpMmsi || busy === -1}
className="px-3 py-1.5 bg-orange-600 hover:bg-orange-500 disabled:bg-orange-600/40 text-white text-xs rounded flex items-center gap-1">
{busy === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Ban className="w-3.5 h-3.5" />}
</Button>
</button>
</div>
</CardContent>
</Card>
@ -168,27 +162,24 @@ export function ParentExclusion() {
<CardContent className="p-4 space-y-2">
<div className="text-xs font-medium text-heading flex items-center gap-2">
<Globe className="w-3.5 h-3.5" /> GLOBAL ( , )
{!canCreateGlobal && <span className="text-yellow-600 dark:text-yellow-400 text-[10px]"> </span>}
{!canCreateGlobal && <span className="text-yellow-400 text-[10px]"> </span>}
</div>
<div className="flex items-center gap-2">
<Input aria-label={tc('aria.excludedMmsi')} size="sm" value={glbMmsi} onChange={(e) => setGlbMmsi(e.target.value)} placeholder="excluded MMSI"
className="w-40" disabled={!canCreateGlobal} />
<Input aria-label={tc('aria.globalExclusionReason')} size="sm" value={glbReason} onChange={(e) => setGlbReason(e.target.value)} placeholder="사유"
className="flex-1" disabled={!canCreateGlobal} />
<Button
variant="destructive"
size="sm"
onClick={handleAddGlobal}
disabled={!canCreateGlobal || !glbMmsi || busy === -2}
icon={busy === -2 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Globe className="w-3.5 h-3.5" />}
>
<input aria-label="excluded MMSI (전역)" value={glbMmsi} onChange={(e) => setGlbMmsi(e.target.value)} placeholder="excluded MMSI"
className="w-40 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGlobal} />
<input aria-label="전역 제외 사유" value={glbReason} onChange={(e) => setGlbReason(e.target.value)} placeholder="사유"
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGlobal} />
<button type="button" onClick={handleAddGlobal}
disabled={!canCreateGlobal || !glbMmsi || busy === -2}
className="px-3 py-1.5 bg-red-600 hover:bg-red-500 disabled:bg-red-600/40 text-white text-xs rounded flex items-center gap-1">
{busy === -2 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Globe className="w-3.5 h-3.5" />}
</Button>
</button>
</div>
</CardContent>
</Card>
{error && <div className="text-xs text-red-600 dark:text-red-400">{tc('error.errorPrefix', { msg: error })}</div>}
{error && <div className="text-xs text-red-400">: {error}</div>}
{loading && (
<div className="flex items-center justify-center py-12 text-muted-foreground">
@ -227,13 +218,13 @@ export function ParentExclusion() {
</td>
<td className="px-3 py-2 text-heading font-medium">{it.groupKey || '-'}</td>
<td className="px-3 py-2 text-center text-muted-foreground">{it.subClusterId ?? '-'}</td>
<td className="px-3 py-2 text-cyan-600 dark:text-cyan-400 font-mono">{it.excludedMmsi}</td>
<td className="px-3 py-2 text-cyan-400 font-mono">{it.excludedMmsi}</td>
<td className="px-3 py-2 text-muted-foreground">{it.reason || '-'}</td>
<td className="px-3 py-2 text-muted-foreground">{it.actorAcnt || '-'}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.createdAt)}</td>
<td className="px-3 py-2 text-center">
<button type="button" disabled={!canRelease || busy === it.id} onClick={() => handleRelease(it.id)}
className="p-1 rounded hover:bg-blue-500/20 disabled:opacity-30 text-blue-600 dark:text-blue-400" title="해제" aria-label="해제">
className="p-1 rounded hover:bg-blue-500/20 disabled:opacity-30 text-blue-400" title="해제">
<RotateCcw className="w-3.5 h-3.5" />
</button>
</td>

파일 보기

@ -3,7 +3,6 @@ import { CheckCircle, XCircle, RotateCcw, Loader2, GitMerge } from 'lucide-react
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { Input } from '@shared/components/ui/input';
import { Select } from '@shared/components/ui/select';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { useAuth } from '@/app/auth/AuthContext';
@ -98,7 +97,7 @@ export function ParentReview() {
await load();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'unknown';
alert(tc('error.processFailed', { msg }));
alert('처리 실패: ' + msg);
} finally {
setActionLoading(null);
}
@ -118,7 +117,7 @@ export function ParentReview() {
await load();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'unknown';
alert(tc('error.registerFailed', { msg }));
alert('등록 실패: ' + msg);
} finally {
setActionLoading(null);
}
@ -152,40 +151,39 @@ export function ParentReview() {
<CardContent className="p-4">
<div className="text-xs text-muted-foreground mb-2"> ()</div>
<div className="flex items-center gap-2">
<Input
aria-label={tc('aria.groupKey')}
size="sm"
<input
aria-label="group_key"
type="text"
value={newGroupKey}
onChange={(e) => setNewGroupKey(e.target.value)}
placeholder="group_key (예: 渔船A)"
className="flex-1"
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs"
/>
<Input
aria-label={tc('aria.subClusterId')}
size="sm"
<input
aria-label="sub_cluster_id"
type="number"
value={newSubCluster}
onChange={(e) => setNewSubCluster(e.target.value)}
placeholder="sub_cluster_id"
className="w-32"
className="w-32 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs"
/>
<Input
<input
aria-label="parent MMSI"
size="sm"
type="text"
value={newMmsi}
onChange={(e) => setNewMmsi(e.target.value)}
placeholder="parent MMSI"
className="w-40"
className="w-40 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs"
/>
<Button
variant="primary"
size="sm"
<button
type="button"
onClick={handleCreate}
disabled={!newGroupKey || !newMmsi || actionLoading === -1}
icon={actionLoading === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <CheckCircle className="w-3.5 h-3.5" />}
className="px-3 py-1.5 bg-green-600 hover:bg-green-500 disabled:bg-green-600/40 text-white text-xs rounded flex items-center gap-1"
>
{actionLoading === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <CheckCircle className="w-3.5 h-3.5" />}
</Button>
</button>
</div>
</CardContent>
</Card>
@ -194,7 +192,7 @@ export function ParentReview() {
{!canUpdate && (
<Card>
<CardContent className="p-4">
<div className="text-xs text-yellow-600 dark:text-yellow-400">
<div className="text-xs text-yellow-400">
(UPDATE ). // .
</div>
</CardContent>
@ -204,7 +202,7 @@ export function ParentReview() {
{error && (
<Card>
<CardContent className="p-4">
<div className="text-xs text-red-600 dark:text-red-400">{tc('error.errorPrefix', { msg: error })}</div>
<div className="text-xs text-red-400">: {error}</div>
</CardContent>
</Card>
)}
@ -249,42 +247,39 @@ export function ParentReview() {
{getParentResolutionLabel(it.status, tc, lang)}
</Badge>
</td>
<td className="px-3 py-2 text-cyan-600 dark:text-cyan-400 font-mono">{it.selectedParentMmsi || '-'}</td>
<td className="px-3 py-2 text-cyan-400 font-mono">{it.selectedParentMmsi || '-'}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">
{formatDateTime(it.updatedAt)}
</td>
<td className="px-3 py-2">
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="sm"
<button
type="button"
disabled={!canUpdate || actionLoading === it.id}
onClick={() => handleAction(it, 'CONFIRM')}
className="p-1 rounded hover:bg-green-500/20 disabled:opacity-30 text-green-400"
title="확정"
aria-label="확정"
className="text-green-600 dark:text-green-400 hover:bg-green-500/20"
icon={<CheckCircle className="w-3.5 h-3.5" />}
/>
<Button
variant="ghost"
size="sm"
>
<CheckCircle className="w-3.5 h-3.5" />
</button>
<button
type="button"
disabled={!canUpdate || actionLoading === it.id}
onClick={() => handleAction(it, 'REJECT')}
className="p-1 rounded hover:bg-red-500/20 disabled:opacity-30 text-red-400"
title="거부"
aria-label="거부"
className="text-red-600 dark:text-red-400 hover:bg-red-500/20"
icon={<XCircle className="w-3.5 h-3.5" />}
/>
<Button
variant="ghost"
size="sm"
>
<XCircle className="w-3.5 h-3.5" />
</button>
<button
type="button"
disabled={!canUpdate || actionLoading === it.id}
onClick={() => handleAction(it, 'RESET')}
className="p-1 rounded hover:bg-blue-500/20 disabled:opacity-30 text-blue-400"
title="리셋"
aria-label="리셋"
className="text-blue-600 dark:text-blue-400 hover:bg-blue-500/20"
icon={<RotateCcw className="w-3.5 h-3.5" />}
/>
>
<RotateCcw className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>

파일 보기

@ -32,7 +32,6 @@ const reports: Report[] = [
export function ReportManagement() {
const { t } = useTranslation('statistics');
const { t: tc } = useTranslation('common');
const [selected, setSelected] = useState<Report>(reports[0]);
const [search, setSearch] = useState('');
const [showUpload, setShowUpload] = useState(false);
@ -82,7 +81,7 @@ export function ReportManagement() {
<div className="rounded-xl border border-border bg-card p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] text-label font-bold"> (··)</span>
<button type="button" aria-label={tc('aria.uploadPanelClose')} onClick={() => setShowUpload(false)} className="text-hint hover:text-muted-foreground"><X className="w-4 h-4" /></button>
<button type="button" aria-label="업로드 패널 닫기" onClick={() => setShowUpload(false)} className="text-hint hover:text-muted-foreground"><X className="w-4 h-4" /></button>
</div>
<FileUpload accept=".jpg,.jpeg,.png,.mp4,.pdf,.hwp,.docx" multiple maxSizeMB={100} />
<div className="flex justify-end mt-3">
@ -121,8 +120,8 @@ export function ReportManagement() {
</div>
<div className="text-[11px] text-hint mt-1"> {r.evidence}</div>
<div className="flex gap-2 mt-2">
<Button variant="primary" size="sm">PDF</Button>
<Button variant="secondary" size="sm"></Button>
<button type="button" className="bg-blue-600 text-on-vivid text-[11px] px-3 py-1 rounded hover:bg-blue-500 transition-colors">PDF</button>
<button type="button" className="bg-muted text-heading text-[11px] px-3 py-1 rounded hover:bg-muted transition-colors"></button>
</div>
</div>
))}
@ -136,9 +135,9 @@ export function ReportManagement() {
<CardContent className="p-5">
<div className="flex items-center justify-between mb-4">
<div className="text-sm text-label"> </div>
<Button variant="primary" size="sm" icon={<Download className="w-3.5 h-3.5" />}>
</Button>
<button type="button" className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid px-3 py-1.5 rounded-lg text-xs transition-colors">
<Download className="w-3.5 h-3.5" />
</button>
</div>
<div className="bg-surface-raised border border-slate-700/40 rounded-xl p-6 space-y-5">

파일 보기

@ -15,11 +15,7 @@ import {
type PredictionEvent,
} from '@/services/event';
import {
getAlertLevelHex,
getAlertLevelMarkerOpacity,
getAlertLevelMarkerRadius,
} from '@shared/constants/alertLevels';
import { getAlertLevelHex } from '@shared/constants/alertLevels';
interface MapEvent {
id: string;
@ -56,7 +52,7 @@ function RiskBar({ value, size = 'md' }: { value: number; size?: 'sm' | 'md' })
<div className={`flex-1 ${h} bg-secondary rounded-full overflow-hidden`}>
<div className={`${h} bg-red-500 rounded-full`} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs font-medium text-red-600 dark:text-red-400">{value.toFixed(2)}</span>
<span className="text-xs font-medium text-red-400">{value.toFixed(2)}</span>
</div>
);
}
@ -117,7 +113,7 @@ export function LiveMapView() {
nationality: e.vesselMmsi?.startsWith('412') ? 'CN' : e.vesselMmsi?.startsWith('440') ? 'KR' : '미상',
time: e.occurredAt.includes(' ') ? e.occurredAt.split(' ')[1]?.slice(0, 5) ?? e.occurredAt : e.occurredAt,
vesselName: e.vesselName ?? '미상',
risk: e.aiConfidence ?? getAlertLevelMarkerOpacity(e.level),
risk: e.aiConfidence ?? (e.level === 'CRITICAL' ? 0.94 : e.level === 'HIGH' ? 0.91 : 0.88),
lat: e.lat!,
lng: e.lon!,
level: e.level,
@ -175,7 +171,7 @@ export function LiveMapView() {
lat: v.lat,
lng: v.lng,
color,
radius: getAlertLevelMarkerRadius(level),
radius: level === 'CRITICAL' ? 900 : level === 'HIGH' ? 750 : 600,
label: v.item.mmsi,
};
}),
@ -244,7 +240,7 @@ export function LiveMapView() {
{loading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 text-blue-600 dark:text-blue-400 animate-spin" />
<Loader2 className="w-5 h-5 text-blue-400 animate-spin" />
<span className="ml-2 text-[11px] text-hint"> ...</span>
</div>
)}
@ -252,8 +248,8 @@ export function LiveMapView() {
{!serviceAvailable && !loading && (
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-center gap-2">
<WifiOff className="w-4 h-4 text-yellow-600 dark:text-yellow-400" />
<span className="text-[11px] text-yellow-600 dark:text-yellow-400 font-medium"> </span>
<WifiOff className="w-4 h-4 text-yellow-400" />
<span className="text-[11px] text-yellow-400 font-medium"> </span>
</div>
<p className="text-[10px] text-hint mt-1"> .</p>
</div>
@ -278,7 +274,7 @@ export function LiveMapView() {
<IconComp className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-semibold text-heading">{evt.type}</span>
</div>
<Pin className="w-3.5 h-3.5 text-hint hover:text-orange-600 dark:hover:text-orange-400 transition-colors" />
<Pin className="w-3.5 h-3.5 text-hint hover:text-orange-400 transition-colors" />
</div>
<div className="text-[11px] text-hint mb-2">{evt.mmsi} · {evt.nationality} · {evt.time}</div>
<RiskBar value={evt.risk} size="sm" />
@ -318,7 +314,7 @@ export function LiveMapView() {
{/* 실시간 표시 */}
<div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
<div className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
<span className="text-[10px] text-red-600 dark:text-red-400 font-bold">LIVE</span>
<span className="text-[10px] text-red-400 font-bold">LIVE</span>
<span className="text-[9px] text-hint"> {mapEvents.length} · {vesselItems.length}</span>
</div>
</div>
@ -333,7 +329,7 @@ export function LiveMapView() {
<div className="bg-red-950/40 border border-red-900/40 rounded-xl p-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 bg-red-600/30 rounded-lg flex items-center justify-center">
<Ship className="w-4.5 h-4.5 text-red-600 dark:text-red-400" />
<Ship className="w-4.5 h-4.5 text-red-400" />
</div>
<div>
<div className="text-heading font-bold text-sm">{selectedEvent.vesselName}</div>
@ -348,7 +344,7 @@ export function LiveMapView() {
<CardContent className="p-4">
<div className="text-[10px] text-muted-foreground mb-1"> </div>
<div className="flex items-baseline gap-1 mb-2">
<span className="text-3xl font-bold text-red-600 dark:text-red-400">{Math.round(selectedEvent.risk * 100)}</span>
<span className="text-3xl font-bold text-red-400">{Math.round(selectedEvent.risk * 100)}</span>
<span className="text-sm text-hint">/100</span>
</div>
<div className="h-2 bg-switch-background rounded-full overflow-hidden">
@ -364,29 +360,29 @@ export function LiveMapView() {
<Card className="bg-surface-overlay border-slate-700/40">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Zap className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<Zap className="w-4 h-4 text-blue-400" />
<span className="text-sm text-heading font-medium">AI </span>
<Badge intent="critical" size="md">신뢰도: High</Badge>
</div>
<div className="space-y-3">
<div className="border-l-2 border-red-500 pl-3">
<div className="flex items-center gap-1.5 text-xs">
<AlertTriangle className="w-3 h-3 text-red-600 dark:text-red-400" />
<span className="text-red-600 dark:text-red-400 font-medium">{selectedEvent.type}</span>
<AlertTriangle className="w-3 h-3 text-red-400" />
<span className="text-red-400 font-medium">{selectedEvent.type}</span>
</div>
<div className="text-[10px] text-hint mt-0.5">: {selectedEvent.vesselName} ({selectedEvent.mmsi})</div>
</div>
<div className="border-l-2 border-orange-500 pl-3">
<div className="flex items-center gap-1.5 text-xs">
<Activity className="w-3 h-3 text-orange-600 dark:text-orange-400" />
<span className="text-orange-600 dark:text-orange-400 font-medium"> </span>
<Activity className="w-3 h-3 text-orange-400" />
<span className="text-orange-400 font-medium"> </span>
</div>
<div className="text-[10px] text-hint mt-0.5">: {selectedEvent.lat.toFixed(4)}, {selectedEvent.lng.toFixed(4)}</div>
</div>
<div className="border-l-2 border-green-500 pl-3">
<div className="flex items-center gap-1.5 text-xs">
<Clock className="w-3 h-3 text-green-600 dark:text-green-400" />
<span className="text-green-600 dark:text-green-400 font-medium"> </span>
<Clock className="w-3 h-3 text-green-400" />
<span className="text-green-400 font-medium"> </span>
</div>
<div className="text-[10px] text-hint mt-0.5">{selectedEvent.time}</div>
</div>

파일 보기

@ -124,7 +124,7 @@ const NTM_DATA: NtmRecord[] = [
const NTM_CATEGORIES = ['전체', '사격훈련', '군사훈련', '기뢰제거', '해양공사', '항로표지', '항로변경', '해양오염', '수중작업'];
const ntmColumns: DataColumn<NtmRecord>[] = [
{ key: 'no', label: '통보번호', width: '120px', sortable: true, render: v => <span className="text-cyan-600 dark:text-cyan-400 font-mono font-bold text-[10px]">{v as string}</span> },
{ key: 'no', label: '통보번호', width: '120px', sortable: true, render: v => <span className="text-cyan-400 font-mono font-bold text-[10px]">{v as string}</span> },
{ key: 'date', label: '발령일', width: '90px', sortable: true, render: v => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
{ key: 'category', label: '구분', width: '70px', align: 'center', sortable: true,
render: v => {
@ -146,7 +146,7 @@ const ntmColumns: DataColumn<NtmRecord>[] = [
// 훈련구역 색상은 trainingZoneTypes 카탈로그에서 lookup
const columns: DataColumn<TrainingZone>[] = [
{ key: 'id', label: '구역번호', width: '80px', sortable: true, render: v => <span className="text-cyan-600 dark:text-cyan-400 font-mono font-bold">{v as string}</span> },
{ key: 'id', label: '구역번호', width: '80px', sortable: true, render: v => <span className="text-cyan-400 font-mono font-bold">{v as string}</span> },
{ key: 'type', label: '구분', width: '60px', align: 'center', sortable: true,
render: v => <Badge intent={getTrainingZoneIntent(v as string)} size="sm">{v as string}</Badge> },
{ key: 'sea', label: '해역', width: '60px', sortable: true },
@ -301,7 +301,7 @@ export function MapControl() {
{ key: 'ntm' as Tab, label: '항행통보', icon: Bell },
]).map(t => (
<button type="button" key={t.key} onClick={() => setTab(t.key)}
className={`flex items-center gap-1.5 px-4 py-2 text-[11px] font-medium border-b-2 ${tab === t.key ? 'text-cyan-600 dark:text-cyan-400 border-cyan-500 dark:border-cyan-400' : 'text-hint border-transparent hover:text-label'}`}>
className={`flex items-center gap-1.5 px-4 py-2 text-[11px] font-medium border-b-2 ${tab === t.key ? 'text-cyan-400 border-cyan-400' : 'text-hint border-transparent hover:text-label'}`}>
<t.icon className="w-3.5 h-3.5" />{t.label}
</button>
))}
@ -310,7 +310,7 @@ export function MapControl() {
<Filter className="w-3.5 h-3.5 text-hint" />
{['', '서해', '남해', '동해', '제주'].map(s => (
<button type="button" key={s} onClick={() => setSeaFilter(s)}
className={`px-2.5 py-1 rounded text-[10px] ${seaFilter === s ? 'bg-cyan-600 hover:bg-cyan-500 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}>
className={`px-2.5 py-1 rounded text-[10px] ${seaFilter === s ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}>
{s || '전체'}
</button>
))}
@ -324,9 +324,9 @@ export function MapControl() {
<div className="flex gap-2">
{[
{ label: '전체 통보', value: NTM_DATA.length, color: 'text-heading' },
{ label: '발령중', value: NTM_DATA.filter(n => n.status === '발령중').length, color: 'text-red-600 dark:text-red-400' },
{ label: '발령중', value: NTM_DATA.filter(n => n.status === '발령중').length, color: 'text-red-400' },
{ label: '해제', value: NTM_DATA.filter(n => n.status === '해제').length, color: 'text-muted-foreground' },
{ label: '사격훈련', value: NTM_DATA.filter(n => n.category.includes('사격')).length, color: 'text-orange-600 dark:text-orange-400' },
{ label: '사격훈련', value: NTM_DATA.filter(n => n.category.includes('사격')).length, color: 'text-orange-400' },
].map(k => (
<div key={k.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
<span className={`text-base font-bold ${k.color}`}>{k.value}</span>
@ -341,14 +341,14 @@ export function MapControl() {
<span className="text-[10px] text-hint">:</span>
{NTM_CATEGORIES.map(c => (
<button type="button" key={c} onClick={() => setNtmCatFilter(c === '전체' ? '' : c)}
className={`px-2.5 py-1 rounded text-[10px] ${(c === '전체' && !ntmCatFilter) || ntmCatFilter === c ? 'bg-cyan-600 hover:bg-cyan-500 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}>{c}</button>
className={`px-2.5 py-1 rounded text-[10px] ${(c === '전체' && !ntmCatFilter) || ntmCatFilter === c ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}>{c}</button>
))}
</div>
{/* 최근 발령 중 통보 하이라이트 */}
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
<AlertTriangle className="w-4 h-4 text-red-600 dark:text-red-400" />
<AlertTriangle className="w-4 h-4 text-red-400" />
</div>
<div className="space-y-2">
{NTM_DATA.filter(n => n.status === '발령중').map(n => (
@ -411,7 +411,7 @@ export function MapControl() {
</div>
{/* 표시 구역 수 */}
<div className="absolute top-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
<span className="text-[10px] text-cyan-600 dark:text-cyan-400 font-bold">{visibleZones.length}</span>
<span className="text-[10px] text-cyan-400 font-bold">{visibleZones.length}</span>
<span className="text-[9px] text-hint ml-1"> </span>
</div>
</CardContent>

파일 보기

@ -201,11 +201,11 @@ export function VesselDetail() {
<h2 className="text-sm font-bold text-heading"> </h2>
<div className="flex items-center gap-1">
<span className="text-[9px] text-hint w-14 shrink-0">/</span>
<input aria-label={tc('aria.queryFrom')} value={startDate} onChange={(e) => setStartDate(e.target.value)}
<input aria-label="조회 시작 시각" value={startDate} onChange={(e) => setStartDate(e.target.value)}
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none focus:border-blue-500/50"
placeholder="YYYY-MM-DD HH:mm" />
<span className="text-hint text-[10px]">~</span>
<input aria-label={tc('aria.queryTo')} value={endDate} onChange={(e) => setEndDate(e.target.value)}
<input aria-label="조회 종료 시각" value={endDate} onChange={(e) => setEndDate(e.target.value)}
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none focus:border-blue-500/50"
placeholder="YYYY-MM-DD HH:mm" />
</div>

파일 보기

@ -161,7 +161,7 @@
{
"id": "api.vessel_analysis",
"label": "GET /api/vessel-analysis",
"shortDescription": "선박 분석 결과 (레거시 경로, 신규는 /api/analysis/*)",
"shortDescription": "선박 분석 결과 (iran 프록시)",
"stage": "API",
"kind": "api",
"trigger": "on_demand",

파일 보기

@ -1,13 +1,13 @@
[
{
"id": "external.iran_backend",
"label": "Iran 백엔드 (레거시·미사용)",
"shortDescription": "prediction 직접 연동으로 대체됨",
"label": "Iran 백엔드 (레거시)",
"shortDescription": "어구 그룹 read-only 프록시",
"stage": "외부",
"kind": "external",
"trigger": "on_demand",
"status": "deprecated",
"notes": "prediction 이 kcgaidb 에 직접 write 하므로 더 이상 호출하지 않는다. 1~2 릴리즈 후 노드 삭제 예정."
"status": "partial",
"notes": "어구 그룹 read-only proxy (선택적, 향후 자체 prediction으로 대체 예정)"
},
{
"id": "external.redis",

파일 보기

@ -30,7 +30,7 @@ export type NodeKind =
| 'api' // 백엔드 API 엔드포인트
| 'ui' // 프론트 화면
| 'decision' // 운영자 의사결정 액션
| 'external'; // 외부 시스템 (GPKI 등)
| 'external'; // 외부 시스템 (iran, GPKI 등)
export type NodeTrigger =
| 'scheduled' // 5분 주기 등 자동

파일 보기

@ -1,402 +0,0 @@
/**
* useGearReplayLayers
*
* ( ):
*
* 1. animationStore rAF set({ currentTime }) (gearReplayStore)
* 2. zustand.subscribe(currentTime) renderFrame()
* - : ~10fps + pendingRafId로 ( )
* - seek/정지: 즉시
* 3. renderFrame() overlay.setProps({ layers })
*
* :
* - PathLayer: 중심 (gold)
* - TripsLayer: 멤버 fade trail
* - IconLayer: 멤버 ()
* - PolygonLayer: 현재 ( / )
* - TextLayer: MMSI
*
* : 멤버 PathLayer (TripsLayer와 ), - ()
*/
import { useEffect, useRef, useCallback } from 'react';
import type { Layer } from 'deck.gl';
import { ScatterplotLayer, IconLayer, PolygonLayer, TextLayer } from 'deck.gl';
import type { MapboxOverlay } from '@deck.gl/mapbox';
import { useGearReplayStore } from '@stores/gearReplayStore';
import {
findFrameAtTime,
interpolateFromTripsData,
computeConvexHull,
type MemberPosition,
} from '@stores/gearReplayPreprocess';
import { createTripsLayer } from '@lib/map/layers/trips';
// ── SVG Data URI ──
const ICON_SIZE = 64;
const SHIP_URI = (() => {
const s = ICON_SIZE;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 ${s} ${s}"><polygon points="${s / 2},2 ${s * 0.12},${s - 2} ${s / 2},${s * 0.62} ${s * 0.88},${s - 2}" fill="white"/></svg>`;
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
})();
const GEAR_URI = (() => {
const s = ICON_SIZE;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 ${s} ${s}"><polygon points="${s / 2},4 ${s - 4},${s / 2} ${s / 2},${s - 4} 4,${s / 2}" fill="white"/></svg>`;
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
})();
// ── 색상 ──
const CYAN: [number, number, number, number] = [6, 182, 212, 220];
const AMBER: [number, number, number, number] = [245, 158, 11, 200];
const SLATE: [number, number, number, number] = [148, 163, 184, 120];
const POLYGON_FILL: [number, number, number, number] = [245, 158, 11, 30];
const POLYGON_STROKE: [number, number, number, number] = [245, 158, 11, 120];
const RENDER_INTERVAL_MS = 100; // ~10fps 쓰로틀
function memberIconColor(m: MemberPosition): [number, number, number, number] {
if (m.stale) return SLATE;
if (m.isParent) return CYAN;
if (m.isGear) return AMBER;
return [148, 163, 184, 200];
}
// ── 훅 ──
export function useGearReplayLayers(
overlayRef: React.RefObject<MapboxOverlay | null>,
buildBaseLayers: () => Layer[],
) {
const frameCursorRef = useRef(0);
// positionCursors — 멤버별 커서 (재생 시 O(1) 위치 탐색)
const memberCursorsRef = useRef(new Map<string, number>());
// buildBaseLayers를 최신 참조로 유지
const baseLayersRef = useRef(buildBaseLayers);
baseLayersRef.current = buildBaseLayers;
/**
* renderFrame + + overlay.setProps :
* 1. ()
* 2.
* 3. overlay.setProps({ layers })
*/
const renderFrame = useCallback(() => {
const overlay = overlayRef.current;
if (!overlay) return;
const state = useGearReplayStore.getState();
const {
historyFrames, frameTimes, memberTripsData, memberMetadata,
currentTime, startTime, correlationItems,
candidateTripsData, candidateMetadata,
} = state;
if (historyFrames.length === 0) {
overlay.setProps({ layers: baseLayersRef.current() });
return;
}
// 현재 프레임 찾기 (폴리곤 보간용)
const { index: frameIdx, cursor: newCursor } = findFrameAtTime(
frameTimes, currentTime, frameCursorRef.current,
);
frameCursorRef.current = newCursor;
// 멤버 보간 — getCurrentVesselPositions 패턴:
// 프레임 기반이 아닌 멤버별 개별 타임라인에서 보간 → 빈 구간도 연속 보간
const relativeTime = currentTime - startTime;
const members = interpolateFromTripsData(
memberTripsData, memberMetadata, relativeTime, memberCursorsRef.current,
);
// 멤버 위치 기반 convex hull 폴리곤 (프레임 보간이 아닌 실시간 생성)
const hullRing = computeConvexHull(members);
const currentFrame = frameIdx >= 0 ? historyFrames[frameIdx] : null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const replayLayers: any[] = [];
// 1. TripsLayer — 멤버 궤적 fade trail
// TripsLayer가 자체적으로 부드러운 궤적 애니메이션 처리
if (memberTripsData.length > 0) {
replayLayers.push(createTripsLayer(
'replay-member-trails',
memberTripsData,
relativeTime,
3_600_000, // 1시간 fade trail
));
}
// 4. 멤버 현재 위치 IconLayer
const ships = members.filter(m => m.isParent);
const gears = members.filter(m => m.isGear);
const others = members.filter(m => !m.isParent && !m.isGear);
if (ships.length > 0) {
replayLayers.push(new IconLayer<MemberPosition>({
id: 'replay-member-ships',
data: ships,
pickable: true,
iconAtlas: SHIP_URI,
iconMapping: {
ship: { x: 0, y: 0, width: ICON_SIZE, height: ICON_SIZE, anchorY: ICON_SIZE * 0.62, mask: true },
},
getIcon: () => 'ship',
getPosition: d => [d.lon, d.lat],
getSize: 24,
getAngle: d => -(d.cog ?? 0),
getColor: d => memberIconColor(d),
sizeUnits: 'pixels',
sizeMinPixels: 10,
billboard: false,
}));
}
if (gears.length > 0) {
replayLayers.push(new IconLayer<MemberPosition>({
id: 'replay-member-gears',
data: gears,
pickable: true,
iconAtlas: GEAR_URI,
iconMapping: {
gear: { x: 0, y: 0, width: ICON_SIZE, height: ICON_SIZE, anchorY: ICON_SIZE / 2, mask: true },
},
getIcon: () => 'gear',
getPosition: d => [d.lon, d.lat],
getSize: 16,
getAngle: 0,
getColor: d => memberIconColor(d),
sizeUnits: 'pixels',
sizeMinPixels: 6,
billboard: false,
}));
}
if (others.length > 0) {
replayLayers.push(new IconLayer<MemberPosition>({
id: 'replay-member-others',
data: others,
pickable: true,
iconAtlas: SHIP_URI,
iconMapping: {
ship: { x: 0, y: 0, width: ICON_SIZE, height: ICON_SIZE, anchorY: ICON_SIZE * 0.62, mask: true },
},
getIcon: () => 'ship',
getPosition: d => [d.lon, d.lat],
getSize: 18,
getAngle: d => -(d.cog ?? 0),
getColor: d => memberIconColor(d),
sizeUnits: 'pixels',
sizeMinPixels: 8,
billboard: false,
}));
}
// 5. MMSI 라벨
if (members.length > 0) {
replayLayers.push(new TextLayer<MemberPosition>({
id: 'replay-member-labels',
data: members,
getPosition: d => [d.lon, d.lat],
getText: d => d.mmsi,
getColor: [255, 255, 255, 200],
getSize: 10,
getPixelOffset: [0, -18],
fontFamily: 'monospace',
fontWeight: 'bold',
outlineWidth: 2,
outlineColor: [0, 0, 0, 200],
sizeUnits: 'pixels',
sizeMinPixels: 8,
sizeMaxPixels: 12,
billboard: true,
}));
}
// 6. 멤버 위치 기반 convex hull 폴리곤
if (hullRing) {
replayLayers.push(new PolygonLayer({
id: 'replay-polygon',
data: [{ ring: hullRing }],
getPolygon: d => d.ring,
getFillColor: POLYGON_FILL,
getLineColor: POLYGON_STROKE,
getLineWidth: 2,
lineWidthUnits: 'pixels',
lineWidthMinPixels: 1,
filled: true,
stroked: true,
}));
}
// 7. 추론 후보 위치 (correlation)
if (correlationItems.length > 0 && currentFrame) {
const memberMap = new Map(currentFrame.members.map(m => [m.mmsi, m]));
const corrPositions = correlationItems
.filter(c => {
const m = memberMap.get(c.targetMmsi);
return m && m.lat != null && m.lon != null;
})
.map(c => {
const m = memberMap.get(c.targetMmsi)!;
return { ...c, lon: m.lon, lat: m.lat };
});
if (corrPositions.length > 0) {
replayLayers.push(new ScatterplotLayer({
id: 'replay-corr-positions',
data: corrPositions,
getPosition: d => [d.lon, d.lat],
getFillColor: d => {
const s = d.score;
if (s >= 0.72) return [16, 185, 129, 180] as [number, number, number, number];
if (s >= 0.5) return [245, 158, 11, 180] as [number, number, number, number];
return [100, 116, 139, 140] as [number, number, number, number];
},
getRadius: d => 5 + d.score * 8,
radiusUnits: 'pixels',
radiusMinPixels: 4,
lineWidthMinPixels: 1,
stroked: true,
getLineColor: [255, 255, 255, 120],
getLineWidth: 1,
pickable: true,
}));
}
}
// 8. 후보 선박 항적 TripsLayer + 현재 위치 IconLayer
if (candidateTripsData.length > 0) {
// 후보 선박 궤적 fade trail (emerald)
replayLayers.push(createTripsLayer(
'replay-candidate-trails',
candidateTripsData,
relativeTime,
3_600_000,
));
// 후보 선박 현재 위치 (개별 타임라인 보간)
const candPositions = interpolateFromTripsData(
candidateTripsData,
new Map([...candidateMetadata].map(([k, v]) => [k, { name: v.name, role: 'CANDIDATE', isParent: false }])),
relativeTime,
memberCursorsRef.current,
);
if (candPositions.length > 0) {
replayLayers.push(new IconLayer({
id: 'replay-candidate-ships',
data: candPositions,
pickable: true,
iconAtlas: SHIP_URI,
iconMapping: {
ship: { x: 0, y: 0, width: ICON_SIZE, height: ICON_SIZE, anchorY: ICON_SIZE * 0.62, mask: true },
},
getIcon: () => 'ship',
getPosition: (d: MemberPosition) => [d.lon, d.lat],
getSize: 22,
getAngle: (d: MemberPosition) => -(d.cog ?? 0),
getColor: [16, 185, 129, 220] as [number, number, number, number], // emerald
sizeUnits: 'pixels' as const,
sizeMinPixels: 10,
billboard: false,
}));
// 후보 선박 라벨
replayLayers.push(new TextLayer({
id: 'replay-candidate-labels',
data: candPositions,
getPosition: (d: MemberPosition) => [d.lon, d.lat],
getText: (d: MemberPosition) => {
const meta = candidateMetadata.get(d.mmsi);
return meta?.name || d.mmsi;
},
getColor: [16, 185, 129, 220],
getSize: 10,
getPixelOffset: [0, -18],
fontFamily: 'monospace',
fontWeight: 'bold' as const,
outlineWidth: 2,
outlineColor: [0, 0, 0, 200],
sizeUnits: 'pixels' as const,
sizeMinPixels: 8,
sizeMaxPixels: 12,
billboard: true,
}));
}
}
// 핵심: baseLayers + replayLayers 합쳐서 overlay.setProps() 직접 호출
const baseLayers = baseLayersRef.current();
overlay.setProps({ layers: [...baseLayers, ...replayLayers] });
}, [overlayRef]);
/**
* currentTime + seek
*
* 핵심: 재생 pendingRafId로 rAF에
*
*
* renderFrame historyFrames.length===0 baseLayers만
*/
useEffect(() => {
let lastRenderTime = 0;
let pendingRafId: number | null = null;
const unsub = useGearReplayStore.subscribe(
(s) => s.currentTime,
() => {
// 데이터 없으면 무시
if (!useGearReplayStore.getState().groupKey) return;
const isPlaying = useGearReplayStore.getState().isPlaying;
// seek/정지: 즉시 렌더
if (!isPlaying) {
renderFrame();
return;
}
// 재생 중: 쓰로틀 + pending rAF
const now = performance.now();
if (now - lastRenderTime >= RENDER_INTERVAL_MS) {
lastRenderTime = now;
renderFrame();
} else if (!pendingRafId) {
pendingRafId = requestAnimationFrame(() => {
pendingRafId = null;
lastRenderTime = performance.now();
renderFrame();
});
}
},
);
return () => {
unsub();
if (pendingRafId) cancelAnimationFrame(pendingRafId);
};
}, [renderFrame]);
// groupKey 변경 구독
useEffect(() => {
const unsub = useGearReplayStore.subscribe(
s => s.groupKey,
(groupKey) => {
if (groupKey) {
frameCursorRef.current = 0;
memberCursorsRef.current.clear();
renderFrame();
} else {
// 리플레이 종료 → 기본 레이어 복원
const overlay = overlayRef.current;
if (overlay) {
overlay.setProps({ layers: baseLayersRef.current() });
}
}
},
);
return unsub;
}, [renderFrame, overlayRef]);
}

Some files were not shown because too many files have changed in this diff Show More