Compare commits
No commits in common. "main" and "release/2026-04-16.6" have entirely different histories.
main
...
release/20
51
CLAUDE.md
51
CLAUDE.md
@ -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;
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,7 @@ 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();
|
||||
|
||||
@ -26,6 +27,11 @@ public class AppProperties {
|
||||
private String baseUrl;
|
||||
}
|
||||
|
||||
@Getter @Setter
|
||||
public static class IranBackend {
|
||||
private String baseUrl;
|
||||
}
|
||||
|
||||
@Getter @Setter
|
||||
public static class Cors {
|
||||
private String allowedOrigins;
|
||||
|
||||
@ -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,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,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,10 @@
|
||||
package gc.mda.kcg.domain.analysis;
|
||||
|
||||
import gc.mda.kcg.config.AppProperties;
|
||||
import gc.mda.kcg.permission.annotation.RequirePermission;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
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;
|
||||
@ -13,7 +14,7 @@ import java.util.Map;
|
||||
|
||||
/**
|
||||
* Prediction FastAPI 서비스 프록시.
|
||||
* 기본 baseUrl: app.prediction.base-url (개발 http://localhost:8001, 운영 http://192.168.1.19:18092)
|
||||
* app.prediction.base-url (기본: http://localhost:8001, 운영: http://192.168.1.19:18092)
|
||||
*
|
||||
* 엔드포인트:
|
||||
* GET /api/prediction/health → FastAPI /health
|
||||
@ -29,8 +30,20 @@ import java.util.Map;
|
||||
@RequiredArgsConstructor
|
||||
public class PredictionProxyController {
|
||||
|
||||
@Qualifier("predictionRestClient")
|
||||
private final RestClient predictionClient;
|
||||
private final AppProperties appProperties;
|
||||
private final RestClient.Builder restClientBuilder;
|
||||
|
||||
private RestClient predictionClient;
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
String baseUrl = appProperties.getPrediction().getBaseUrl();
|
||||
predictionClient = restClientBuilder
|
||||
.baseUrl(baseUrl != null && !baseUrl.isBlank() ? baseUrl : "http://localhost:8001")
|
||||
.defaultHeader("Accept", "application/json")
|
||||
.build();
|
||||
log.info("PredictionProxyController initialized: baseUrl={}", baseUrl);
|
||||
}
|
||||
|
||||
@GetMapping("/health")
|
||||
public ResponseEntity<?> health() {
|
||||
|
||||
@ -13,7 +13,8 @@ import java.util.List;
|
||||
|
||||
/**
|
||||
* vessel_analysis_results 직접 조회 API.
|
||||
* prediction이 kcgaidb에 저장한 분석 결과를 프론트엔드에 직접 제공한다 (/api/analysis/*).
|
||||
* prediction이 kcgaidb에 저장한 분석 결과를 프론트엔드에 직접 제공.
|
||||
* 기존 iran proxy와 별도 경로 (/api/analysis/*).
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/analysis")
|
||||
|
||||
@ -2,7 +2,6 @@ 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;
|
||||
@ -291,7 +290,6 @@ public class VesselAnalysisGroupService {
|
||||
/**
|
||||
* 모선 확정/제외 처리.
|
||||
*/
|
||||
@Auditable(action = "PARENT_RESOLVE", resourceType = "GEAR_GROUP")
|
||||
public Map<String, Object> resolveParent(String groupKey, String action, String targetMmsi, String comment) {
|
||||
try {
|
||||
// 먼저 resolution 존재 확인
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
package gc.mda.kcg.domain.analysis;
|
||||
|
||||
import gc.mda.kcg.config.AppProperties;
|
||||
import gc.mda.kcg.permission.annotation.RequirePermission;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
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;
|
||||
@ -15,6 +16,10 @@ import java.util.Map;
|
||||
/**
|
||||
* 분석 데이터 API — group_polygon_snapshots / gear_correlation_scores 직접 DB 조회
|
||||
* + signal-batch 선박 항적 프록시.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 분석 데이터 API — group_polygon_snapshots / gear_correlation_scores 직접 DB 조회.
|
||||
*
|
||||
* 라우팅:
|
||||
* GET /api/vessel-analysis/groups → 어구/선단 그룹 + parentResolution 합성
|
||||
@ -30,9 +35,19 @@ import java.util.Map;
|
||||
public class VesselAnalysisProxyController {
|
||||
|
||||
private final VesselAnalysisGroupService groupService;
|
||||
private final AppProperties appProperties;
|
||||
private final RestClient.Builder restClientBuilder;
|
||||
|
||||
@Qualifier("signalBatchRestClient")
|
||||
private final RestClient signalBatchClient;
|
||||
private RestClient signalBatchClient;
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
String sbUrl = appProperties.getSignalBatch().getBaseUrl();
|
||||
signalBatchClient = restClientBuilder
|
||||
.baseUrl(sbUrl != null && !sbUrl.isBlank() ? sbUrl : "http://192.168.1.18:18090/signal-batch")
|
||||
.defaultHeader("Accept", "application/json")
|
||||
.build();
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
|
||||
@ -54,7 +69,7 @@ public class VesselAnalysisProxyController {
|
||||
@GetMapping("/groups")
|
||||
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
|
||||
public ResponseEntity<?> getGroups(
|
||||
@RequestParam(required = false) String groupType
|
||||
@org.springframework.web.bind.annotation.RequestParam(required = false) String groupType
|
||||
) {
|
||||
Map<String, Object> result = groupService.getGroups(groupType);
|
||||
return ResponseEntity.ok(result);
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
@ -66,6 +66,9 @@ app:
|
||||
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,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,52 +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]
|
||||
|
||||
### 추가
|
||||
|
||||
@ -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)
|
||||
|
||||
252
docs/data-sharing-analysis.md
Normal file
252
docs/data-sharing-analysis.md
Normal file
@ -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
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
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 })),
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -94,12 +94,12 @@ 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'));
|
||||
}
|
||||
};
|
||||
|
||||
@ -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-heading" 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-label" title="잠금 해제">
|
||||
<Key className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
|
||||
@ -341,7 +341,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'>('');
|
||||
@ -443,7 +442,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)}
|
||||
|
||||
@ -124,7 +124,7 @@ export function DataModelVerification() {
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={ListChecks}
|
||||
iconColor="text-green-600 dark:text-green-400"
|
||||
iconColor="text-green-400"
|
||||
title="데이터 모델 검증"
|
||||
description="DAR-11 | 논리·물리 데이터 모델 검증 기준 정의·실시 및 결과 관리"
|
||||
demo
|
||||
@ -157,7 +157,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 +168,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 +190,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 +207,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 +229,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 +249,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 +279,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 +302,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 +315,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 +335,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 +348,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 +372,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>
|
||||
))}
|
||||
|
||||
@ -108,7 +108,7 @@ export function DataRetentionPolicy() {
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Database}
|
||||
iconColor="text-blue-600 dark:text-blue-400"
|
||||
iconColor="text-blue-400"
|
||||
title="데이터 보관기간 및 파기 정책"
|
||||
description="DAR-10 | 데이터 유형별 보관기간 기준표, 파기 절차, 보존 연장 예외 관리"
|
||||
demo
|
||||
@ -141,14 +141,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 +184,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 +213,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 +235,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 +273,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 +284,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 +305,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 +326,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 +341,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 +364,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 +375,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 +398,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 +424,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>
|
||||
|
||||
@ -74,7 +74,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');
|
||||
@ -266,7 +265,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 +275,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 +287,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}
|
||||
@ -344,7 +343,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 +353,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 })}
|
||||
|
||||
@ -153,7 +153,7 @@ export function PerformanceMonitoring() {
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Activity}
|
||||
iconColor="text-cyan-600 dark:text-cyan-400"
|
||||
iconColor="text-cyan-400"
|
||||
title="성능 모니터링"
|
||||
description="PER-01~06 | 응답성·처리용량·AI 모델·가용성·확장성 실시간 현황"
|
||||
demo
|
||||
@ -192,7 +192,7 @@ export function PerformanceMonitoring() {
|
||||
{/* 사용자 그룹별 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" />
|
||||
<Users className="w-4 h-4 text-cyan-400" />
|
||||
<span className="text-[12px] font-bold text-heading">사용자 그룹별 SLO (총 2,900명 + 추정)</span>
|
||||
<Badge intent="info" size="xs">본청 200 · 상황실 100 확정</Badge>
|
||||
</div>
|
||||
@ -225,20 +225,20 @@ export function PerformanceMonitoring() {
|
||||
{/* 성능 영향 최소화 전략 */}
|
||||
<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" />
|
||||
<Zap className="w-4 h-4 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="w-5 h-5 rounded-full bg-amber-500/20 flex items-center justify-center shrink-0 text-[9px] font-bold 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 className="text-[9px] text-green-400">효과: {s.effect}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -253,7 +253,7 @@ export function PerformanceMonitoring() {
|
||||
<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" />
|
||||
<Gauge className="w-4 h-4 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>
|
||||
@ -273,7 +273,7 @@ export function PerformanceMonitoring() {
|
||||
{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-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>
|
||||
@ -287,7 +287,7 @@ export function PerformanceMonitoring() {
|
||||
<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" />
|
||||
<Shield className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-[12px] font-bold text-heading">상황실 전용 SLO (24/7 100명)</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@ -304,8 +304,8 @@ export function PerformanceMonitoring() {
|
||||
<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" />}
|
||||
<span className="text-[11px] text-green-400 font-bold">{s.current}</span>
|
||||
{s.met ? <CheckCircle className="w-4 h-4 text-green-500" /> : <AlertTriangle className="w-4 h-4 text-amber-500" />}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -314,32 +314,32 @@ export function PerformanceMonitoring() {
|
||||
|
||||
<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" />
|
||||
<Clock className="w-4 h-4 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" />
|
||||
<CheckCircle className="w-3.5 h-3.5 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" />
|
||||
<CheckCircle className="w-3.5 h-3.5 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" />
|
||||
<CheckCircle className="w-3.5 h-3.5 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" />
|
||||
<CheckCircle className="w-3.5 h-3.5 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" />
|
||||
<CheckCircle className="w-3.5 h-3.5 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" />
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
|
||||
<div><strong className="text-heading">원인 분석:</strong> <span className="text-label">RED/USE 방법론 + 로그·메트릭·추적 상관 분석</span></div>
|
||||
</li>
|
||||
</ul>
|
||||
@ -354,7 +354,7 @@ export function PerformanceMonitoring() {
|
||||
{/* 동시접속·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" />
|
||||
<Users className="w-4 h-4 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">
|
||||
@ -375,7 +375,7 @@ export function PerformanceMonitoring() {
|
||||
{/* 배치 작업 현황 */}
|
||||
<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">PER-03 배치 · 대용량 처리 현황</span>
|
||||
<Badge intent="success" size="xs">SLA 준수 6/7</Badge>
|
||||
</div>
|
||||
@ -396,7 +396,7 @@ export function PerformanceMonitoring() {
|
||||
<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-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>
|
||||
@ -408,7 +408,7 @@ export function PerformanceMonitoring() {
|
||||
{/* 처리 볼륨 산정 */}
|
||||
<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" />
|
||||
<HardDrive className="w-4 h-4 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]">
|
||||
@ -420,12 +420,12 @@ export function PerformanceMonitoring() {
|
||||
<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 className="text-[9px] 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 className="text-[9px] text-amber-400 mt-1">NAS 100TB → 객체스토리지 이관</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
@ -438,7 +438,7 @@ export function PerformanceMonitoring() {
|
||||
<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" />
|
||||
<Brain className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-[12px] font-bold text-heading">PER-04 AI 모델 성능 지표</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@ -467,7 +467,7 @@ export function PerformanceMonitoring() {
|
||||
<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-right 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>
|
||||
@ -479,28 +479,28 @@ export function PerformanceMonitoring() {
|
||||
<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" />
|
||||
<TrendingUp className="w-4 h-4 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" />
|
||||
<CheckCircle className="w-3.5 h-3.5 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" />
|
||||
<CheckCircle className="w-3.5 h-3.5 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" />
|
||||
<CheckCircle className="w-3.5 h-3.5 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" />
|
||||
<CheckCircle className="w-3.5 h-3.5 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" />
|
||||
<CheckCircle className="w-3.5 h-3.5 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>
|
||||
@ -508,7 +508,7 @@ export function PerformanceMonitoring() {
|
||||
|
||||
<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" />
|
||||
<Cpu className="w-4 h-4 text-amber-400" />
|
||||
<span className="text-[12px] font-bold text-heading">추론 성능 (GPU 활용)</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
@ -551,7 +551,7 @@ export function PerformanceMonitoring() {
|
||||
{/* 가용성 */}
|
||||
<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">PER-05 가용성 및 장애복구 (목표 ≥ 99.9%)</span>
|
||||
</div>
|
||||
<table className="w-full text-[11px]">
|
||||
@ -570,7 +570,7 @@ export function PerformanceMonitoring() {
|
||||
<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-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>
|
||||
@ -583,7 +583,7 @@ export function PerformanceMonitoring() {
|
||||
{/* 확장성 */}
|
||||
<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" />
|
||||
<Server className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-[12px] font-bold text-heading">PER-06 확장성 및 자원 사용률</span>
|
||||
<Badge intent="info" size="xs">2배(6,000명) 확장 목표</Badge>
|
||||
</div>
|
||||
@ -613,34 +613,34 @@ export function PerformanceMonitoring() {
|
||||
<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" />
|
||||
<Wifi className="w-4 h-4 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-2xl font-bold 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" />
|
||||
<Clock className="w-4 h-4 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-2xl font-bold 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" />
|
||||
<Database className="w-4 h-4 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-2xl font-bold 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" />
|
||||
<TrendingUp className="w-4 h-4 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-2xl font-bold text-amber-400">×2</div>
|
||||
<div className="text-[9px] text-hint mt-1">6,000명까지 선형 확장</div>
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
|
||||
@ -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-label 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-heading">에러: {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-label" 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-heading" 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">
|
||||
|
||||
@ -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('');
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,10 +1,6 @@
|
||||
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,
|
||||
@ -13,7 +9,6 @@ import {
|
||||
} 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';
|
||||
@ -57,29 +52,18 @@ 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: hasType ? vt : '중국어선',
|
||||
type: item.classification.fishingPct > 0.5 ? 'Fishing' : hasType ? 'Cargo' : '미분류',
|
||||
country: 'China',
|
||||
status: deriveVesselStatus(score),
|
||||
riskPct: score,
|
||||
@ -305,8 +289,8 @@ export function ChinaFishing() {
|
||||
|
||||
// 특이운항 선박 리스트 (서버에서 이미 riskScore >= 40 로 필터링된 상위 20척)
|
||||
const vesselList: VesselItem[] = useMemo(
|
||||
() => topVessels.map((item, idx) => mapToVesselItem(item, idx, tcCommon, lang)),
|
||||
[topVessels, tcCommon, lang],
|
||||
() => topVessels.map((item, idx) => mapToVesselItem(item, idx)),
|
||||
[topVessels],
|
||||
);
|
||||
|
||||
// 위험도별 분포 (도넛 차트용) — apiStats 기반
|
||||
@ -343,19 +327,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 />}
|
||||
@ -373,7 +360,7 @@ export function ChinaFishing() {
|
||||
</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 +368,7 @@ export function ChinaFishing() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 중국 선박 실시간 분석 결과 */}
|
||||
{/* iran 백엔드 실시간 분석 결과 */}
|
||||
<RealAllVessels />
|
||||
|
||||
{/* ── 상단 바: 기준일 + 검색 ── */}
|
||||
@ -390,21 +377,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>
|
||||
|
||||
@ -462,13 +444,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>
|
||||
@ -486,32 +468,29 @@ export function ChinaFishing() {
|
||||
<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')}
|
||||
>
|
||||
<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
|
||||
@ -536,26 +515,30 @@ export function ChinaFishing() {
|
||||
<Card className="bg-surface-raised border-slate-700/30">
|
||||
<CardContent className="p-0">
|
||||
{/* 탭 헤더 — 특이운항만 활성, 나머지 3개는 데이터 소스 미연동 */}
|
||||
<TabBar variant="underline" className="border-slate-700/30">
|
||||
<div className="flex border-b border-slate-700/30">
|
||||
{vesselTabs.map((tab) => {
|
||||
const disabled = tab !== '특이운항';
|
||||
return (
|
||||
<TabButton
|
||||
<button type="button"
|
||||
key={tab}
|
||||
variant="underline"
|
||||
active={vesselTab === tab}
|
||||
onClick={() => !disabled && setVesselTab(tab)}
|
||||
disabled={disabled}
|
||||
className="flex-1 justify-center"
|
||||
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors flex items-center justify-center gap-1 ${
|
||||
vesselTab === tab
|
||||
? 'text-heading border-b-2 border-blue-500 bg-surface-overlay'
|
||||
: disabled
|
||||
? 'text-hint opacity-50 cursor-not-allowed'
|
||||
: 'text-hint hover:text-label'
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
{disabled && (
|
||||
<Badge intent="warning" size="xs" className="font-normal">준비중</Badge>
|
||||
)}
|
||||
</TabButton>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</TabBar>
|
||||
</div>
|
||||
|
||||
{/* 선박 목록 */}
|
||||
<div className="max-h-[420px] overflow-y-auto">
|
||||
@ -604,20 +587,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 flex items-center justify-center gap-1 ${
|
||||
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 +647,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>
|
||||
@ -680,7 +665,7 @@ export function ChinaFishing() {
|
||||
<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>
|
||||
<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">
|
||||
@ -707,11 +692,11 @@ export function ChinaFishing() {
|
||||
<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>
|
||||
<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>
|
||||
@ -733,7 +718,7 @@ export function ChinaFishing() {
|
||||
<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>
|
||||
<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 +726,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>
|
||||
|
||||
@ -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,8 +2,6 @@ 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';
|
||||
@ -145,13 +143,11 @@ function FilterCheckGroup({ label, selected, onChange, options }: {
|
||||
<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"
|
||||
/>
|
||||
<label key={o.value} className="flex items-center gap-1.5 text-[10px] text-label cursor-pointer hover:text-heading">
|
||||
<input type="checkbox" checked={selected.has(o.value)} onChange={() => toggle(o.value)}
|
||||
className="w-3 h-3 rounded border-border accent-primary" />
|
||||
{o.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@ -173,7 +169,7 @@ export function GearDetection() {
|
||||
{ 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> },
|
||||
render: v => <span className="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,
|
||||
@ -195,13 +191,7 @@ export function GearDetection() {
|
||||
</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>;
|
||||
} },
|
||||
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: '90px', sortable: true,
|
||||
render: v => {
|
||||
const s = v as string;
|
||||
@ -210,13 +200,13 @@ export function GearDetection() {
|
||||
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>; } },
|
||||
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: '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';
|
||||
const c = pct >= 72 ? 'text-green-400' : pct >= 50 ? 'text-yellow-400' : 'text-hint';
|
||||
return <span className={`font-mono text-[10px] font-bold ${c}`}>{pct}%</span>;
|
||||
} },
|
||||
{ key: 'lastSignal', label: '최종 신호', width: '80px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
|
||||
@ -330,7 +320,7 @@ export function GearDetection() {
|
||||
const mapRef = useRef<MapHandle>(null);
|
||||
|
||||
// overlay Proxy ref — mapRef.current.overlay를 항상 최신으로 참조
|
||||
// 리플레이 훅이 overlay.setProps() 직접 호출 (단일 렌더링 경로)
|
||||
// iran 패턴: 리플레이 훅이 overlay.setProps() 직접 호출
|
||||
const overlayRef = useMemo<React.RefObject<MapboxOverlay | null>>(() => ({
|
||||
get current() { return mapRef.current?.overlay ?? null; },
|
||||
}), []);
|
||||
@ -434,7 +424,7 @@ export function GearDetection() {
|
||||
}, [DATA, selectedId, isReplayActive, replayGroupKey]);
|
||||
|
||||
// 리플레이 비활성 시만 useMapLayers가 overlay 제어
|
||||
// 리플레이 활성 시 useGearReplayLayers가 overlay 독점 (단일 렌더링 경로)
|
||||
// 리플레이 활성 시 useGearReplayLayers가 overlay 독점 (iran 패턴: 단일 렌더링 경로)
|
||||
useEffect(() => {
|
||||
if (isReplayActive) return; // replay hook이 overlay 독점
|
||||
const raf = requestAnimationFrame(() => {
|
||||
@ -466,11 +456,11 @@ 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">
|
||||
@ -482,9 +472,9 @@ export function GearDetection() {
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{[
|
||||
{ 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: filteredData.filter(d => d.status.includes('불법')).length, c: 'text-red-400' },
|
||||
{ l: '확인 중', v: filteredData.filter(d => d.status === '확인 중').length, c: 'text-yellow-400' },
|
||||
{ l: '정상', v: filteredData.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">
|
||||
<span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span>
|
||||
@ -503,7 +493,7 @@ export function GearDetection() {
|
||||
|
||||
{/* 필터 토글 버튼 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={() => setFilterOpen(!filterOpen)} aria-label={tc('aria.filterToggle')}
|
||||
<button type="button" onClick={() => setFilterOpen(!filterOpen)} aria-label="필터 설정"
|
||||
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'
|
||||
@ -520,15 +510,11 @@ export function GearDetection() {
|
||||
{hasActiveFilter && (
|
||||
<>
|
||||
<span className="text-[10px] text-hint">{filteredData.length}/{DATA.length}건</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={tc('aria.filterReset')}
|
||||
<button type="button" aria-label="필터 초기화"
|
||||
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>
|
||||
className="flex items-center gap-1 px-2 py-1 text-[10px] text-hint hover:text-label rounded hover:bg-surface-raised">
|
||||
<X className="w-3 h-3" /> 초기화
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@ -561,12 +547,12 @@ export function GearDetection() {
|
||||
<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')}
|
||||
aria-label="최소 멤버 수"
|
||||
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')}
|
||||
aria-label="최대 멤버 수"
|
||||
className="flex-1 h-1 accent-primary cursor-pointer" />
|
||||
<span className="text-[9px] text-hint w-6">{filterMemberMax === Infinity ? filterOptions.maxMember : filterMemberMax}</span>
|
||||
</div>
|
||||
@ -576,15 +562,11 @@ export function GearDetection() {
|
||||
{/* 패널 내 초기화 */}
|
||||
<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')}
|
||||
<button type="button" aria-label="필터 초기화"
|
||||
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>
|
||||
className="flex items-center gap-1 px-2 py-1 text-[10px] text-hint hover:text-label rounded hover:bg-surface-overlay">
|
||||
<X className="w-3 h-3" /> 전체 초기화
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -638,8 +620,8 @@ export function GearDetection() {
|
||||
</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>
|
||||
<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>
|
||||
{/* 리플레이 컨트롤러 (활성 시 표시) */}
|
||||
|
||||
@ -7,8 +7,7 @@ import {
|
||||
} 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 { getAlertLevelIntent } 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 +573,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 +593,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>
|
||||
@ -688,7 +687,9 @@ export function GearIdentification() {
|
||||
if (v.gearJudgment === 'GEAR_MISMATCH') warnings.push('허가 어구와 실제 탐지 어구 불일치');
|
||||
if (v.gearJudgment === 'MULTIPLE_VIOLATION') warnings.push('복합 위반 — 두 개 이상 항목 동시 탐지');
|
||||
|
||||
const alertLevel = isValidAlertLevel(v.riskLevel) ? v.riskLevel : 'LOW';
|
||||
const alertLevel = (v.riskLevel === 'CRITICAL' || v.riskLevel === 'HIGH' || v.riskLevel === 'MEDIUM' || v.riskLevel === 'LOW')
|
||||
? v.riskLevel
|
||||
: 'LOW';
|
||||
|
||||
setResult({
|
||||
origin: 'china',
|
||||
@ -715,7 +716,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,14 +724,13 @@ 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>
|
||||
|
||||
@ -743,7 +743,7 @@ export function GearIdentification() {
|
||||
<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="font-mono 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>
|
||||
@ -753,14 +753,12 @@ export function GearIdentification() {
|
||||
</Badge>
|
||||
<span className="text-hint ml-2">하단 자동탐지 목록에서 클릭한 선박 정보가 아래 폼에 프리필되었습니다. 추가 정보 입력 후 판별 실행하세요.</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
<button type="button"
|
||||
onClick={() => { setAutoSelected(null); setInput(DEFAULT_INPUT); setResult(null); }}
|
||||
className="shrink-0 text-[10px]"
|
||||
className="text-[10px] text-hint hover:text-heading shrink-0"
|
||||
>
|
||||
해제
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -882,22 +880,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 +952,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 +960,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 +972,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 +981,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 +994,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 +1015,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 +1030,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 +1046,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 +1072,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>
|
||||
@ -1171,23 +1166,20 @@ function AutoGearDetectionSection({
|
||||
<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" />
|
||||
<Radar className="w-4 h-4 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" />}
|
||||
/>
|
||||
<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>
|
||||
|
||||
{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 && (
|
||||
@ -1222,7 +1214,7 @@ function AutoGearDetectionSection({
|
||||
}`}
|
||||
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-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">
|
||||
|
||||
@ -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,13 +1,8 @@
|
||||
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,
|
||||
@ -26,12 +21,6 @@ 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;
|
||||
}
|
||||
|
||||
const ZONE_LABELS: Record<string, string> = {
|
||||
@ -51,9 +40,7 @@ const ENDPOINT_LABEL: Record<Props['mode'], string> = {
|
||||
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 [available, setAvailable] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -64,10 +51,10 @@ export function RealVesselAnalysis({ mode, title, icon, mmsiPrefix, minRiskScore
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const page = mode === 'dark'
|
||||
? await getDarkVessels({ hours: 1, size })
|
||||
? await getDarkVessels({ hours: 1, size: 200 })
|
||||
: mode === 'transship'
|
||||
? await getTransshipSuspects({ hours: 1, size })
|
||||
: await getAnalysisVessels({ hours: 1, size, mmsiPrefix, minRiskScore });
|
||||
? await getTransshipSuspects({ hours: 1, size: 200 })
|
||||
: await getAnalysisVessels({ hours: 1, size: 200 });
|
||||
setItems(page.content.map(toVesselItem));
|
||||
setAvailable(true);
|
||||
} catch (e: unknown) {
|
||||
@ -76,7 +63,7 @@ export function RealVesselAnalysis({ mode, title, icon, mmsiPrefix, minRiskScore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [mode, mmsiPrefix, minRiskScore, size]);
|
||||
}, [mode]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
@ -121,38 +108,31 @@ export function RealVesselAnalysis({ mode, title, icon, mmsiPrefix, minRiskScore
|
||||
</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" />
|
||||
<StatBox label="전체" value={stats.total} color="text-heading" />
|
||||
<StatBox label="CRITICAL" value={stats.criticalCount} color="text-red-400" />
|
||||
<StatBox label="HIGH" value={stats.highCount} color="text-orange-400" />
|
||||
<StatBox label="MEDIUM" value={stats.mediumCount} color="text-yellow-400" />
|
||||
<StatBox label="Dark" value={stats.darkCount} 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 +158,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 +181,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 +208,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 | undefined; 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 ?? 0).toLocaleString()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -254,12 +221,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">
|
||||
|
||||
@ -68,7 +68,6 @@ interface GearDetailPanelProps {
|
||||
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);
|
||||
@ -270,14 +269,14 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
|
||||
{/* 헤더 */}
|
||||
<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" />
|
||||
<ShieldAlert className="w-4 h-4 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')}>
|
||||
<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>
|
||||
@ -287,7 +286,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
|
||||
{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" />
|
||||
<ShieldAlert className="w-3.5 h-3.5 text-red-400" />
|
||||
<span className="text-label font-medium">G코드 위반 내역</span>
|
||||
<span className="text-hint text-[10px]">총 {gear.gearViolationScore}점</span>
|
||||
</div>
|
||||
@ -312,7 +311,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
|
||||
{/* 어구 그룹 정보 */}
|
||||
<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" />
|
||||
<Anchor className="w-3.5 h-3.5 text-orange-400" />
|
||||
<span className="text-label font-medium">어구 그룹 정보</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-y-1 text-xs">
|
||||
@ -344,7 +343,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
|
||||
{/* 모선 추론 정보 */}
|
||||
<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>
|
||||
<Badge intent={parentStatusIntent} size="sm">{parentStatusLabel}</Badge>
|
||||
</div>
|
||||
@ -352,7 +351,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
|
||||
<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"
|
||||
<button type="button" className="text-cyan-400 hover:underline"
|
||||
onClick={() => navigate(`/vessel/${gear.parentMmsi}`)}>
|
||||
{gear.parentMmsi}
|
||||
</button>
|
||||
@ -366,7 +365,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
|
||||
{/* 모선 추론 후보 상세 (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" />
|
||||
<TrendingUp className="w-3.5 h-3.5 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>
|
||||
@ -393,7 +392,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
|
||||
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]"
|
||||
className="text-cyan-400 hover:underline font-mono text-[11px]"
|
||||
onClick={() => navigate(`/vessel/${c.targetMmsi}`)}>
|
||||
{c.targetMmsi}
|
||||
</button>
|
||||
@ -467,9 +466,9 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
|
||||
<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" />
|
||||
<BarChart3 className="w-3.5 h-3.5 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-cyan-400 font-mono text-[11px]">{cand.targetMmsi}</span>
|
||||
<span className="text-hint text-[9px] truncate">{cand.targetName}</span>
|
||||
</div>
|
||||
|
||||
@ -575,7 +574,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
|
||||
{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" />
|
||||
<Users className="w-3.5 h-3.5 text-red-400" />
|
||||
<span className="text-label font-medium">쌍끌이 트롤 공조</span>
|
||||
<Badge intent="critical" size="sm">G-06</Badge>
|
||||
</div>
|
||||
@ -583,7 +582,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
|
||||
<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"
|
||||
<button type="button" className="text-cyan-400 hover:underline"
|
||||
onClick={() => navigate(`/vessel/${gear.pairTrawlPairMmsi}`)}>
|
||||
{gear.pairTrawlPairMmsi}
|
||||
</button>
|
||||
@ -615,7 +614,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
|
||||
{/* 위치 + 액션 */}
|
||||
<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">위치</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-y-1 text-xs">
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
* 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';
|
||||
@ -28,7 +27,6 @@ function formatEpochTime(epochMs: number): string {
|
||||
}
|
||||
|
||||
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);
|
||||
@ -135,7 +133,7 @@ export function GearReplayController({ onClose }: GearReplayControllerProps) {
|
||||
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-label="재생 위치"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(initialPct)}
|
||||
@ -169,7 +167,7 @@ export function GearReplayController({ onClose }: GearReplayControllerProps) {
|
||||
{/* Close */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={tc('aria.replayClose')}
|
||||
aria-label="재생 닫기"
|
||||
onClick={onClose}
|
||||
className="shrink-0 p-1 hover:bg-surface-raised rounded"
|
||||
>
|
||||
|
||||
@ -38,14 +38,14 @@ export function VesselAnomalyPanel({ segments, loading, error, totalHistoryCount
|
||||
<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" />
|
||||
<ShieldAlert className="w-3.5 h-3.5 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>}
|
||||
{criticalCount > 0 && <span className="text-red-400 ml-0.5">CRITICAL {criticalCount}</span>}
|
||||
{warningCount > 0 && <span className="text-orange-400 ml-0.5">WARNING {warningCount}</span>}
|
||||
{infoCount > 0 && <span className="text-blue-400 ml-0.5">INFO {infoCount}</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@ -55,7 +55,7 @@ export function VesselAnomalyPanel({ segments, loading, error, totalHistoryCount
|
||||
</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">
|
||||
<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-400">
|
||||
<AlertTriangle className="w-3 h-3 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
*/
|
||||
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';
|
||||
@ -34,7 +33,6 @@ function fmt(ts: string | number): string {
|
||||
}
|
||||
|
||||
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);
|
||||
@ -182,7 +180,7 @@ export function VesselMiniMap({ mmsi, vesselName, hoursBack = 24, segments = [],
|
||||
<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" />
|
||||
<Ship className="w-3.5 h-3.5 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}
|
||||
@ -203,7 +201,7 @@ export function VesselMiniMap({ mmsi, vesselName, hoursBack = 24, segments = [],
|
||||
</div>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button type="button" onClick={onClose} aria-label={tc('aria.miniMapClose')}
|
||||
<button type="button" onClick={onClose} aria-label="미니맵 닫기"
|
||||
className="p-1 rounded text-hint hover:text-heading hover:bg-surface-overlay shrink-0">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@ -218,7 +216,7 @@ export function VesselMiniMap({ mmsi, vesselName, hoursBack = 24, segments = [],
|
||||
</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">
|
||||
<div className="absolute inset-0 bg-background/80 flex items-center justify-center text-xs text-red-400 px-3 text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -12,14 +12,6 @@
|
||||
* 좌표가 없어도 이상 신호가 있으면 패널에는 표시하고, 미니맵 포인트만 생략한다.
|
||||
*/
|
||||
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'
|
||||
@ -121,15 +113,18 @@ export function classifyAnomaly(v: VesselAnalysis): AnomalyPoint | null {
|
||||
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 (v.riskLevel === 'CRITICAL') {
|
||||
if (!cats.includes('HIGH_RISK')) cats.push('HIGH_RISK');
|
||||
descs.push(`고위험 CRITICAL (score ${v.riskScore ?? 0})`);
|
||||
severity = bumpSeverity(severity, 'critical');
|
||||
} else if (v.riskLevel === 'HIGH') {
|
||||
if (!cats.includes('HIGH_RISK')) cats.push('HIGH_RISK');
|
||||
descs.push(`위험 HIGH (score ${v.riskScore ?? 0})`);
|
||||
severity = bumpSeverity(severity, 'warning');
|
||||
} else if (v.riskLevel === 'MEDIUM' && (v.riskScore ?? 0) >= 40) {
|
||||
if (!cats.includes('HIGH_RISK')) cats.push('HIGH_RISK');
|
||||
descs.push(`주의 MEDIUM (score ${v.riskScore ?? 0})`);
|
||||
severity = bumpSeverity(severity, 'info');
|
||||
}
|
||||
|
||||
if (cats.length === 0) return null;
|
||||
|
||||
@ -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,7 +1,7 @@
|
||||
/**
|
||||
* useGearReplayLayers — 어구 궤적 리플레이 레이어 빌더 훅
|
||||
*
|
||||
* 검증된 리플레이 렌더링 패턴 (단일 렌더링 경로):
|
||||
* iran 프로젝트 useReplayLayer.ts 패턴 그대로 적용:
|
||||
*
|
||||
* 1. animationStore rAF 루프 → set({ currentTime }) 매 프레임 (gearReplayStore)
|
||||
* 2. zustand.subscribe(currentTime) → renderFrame()
|
||||
@ -9,10 +9,10 @@
|
||||
* - seek/정지: 즉시 렌더
|
||||
* 3. renderFrame() → 레이어 빌드 → overlay.setProps({ layers }) 직접 호출
|
||||
*
|
||||
* 레이어 구성:
|
||||
* - PathLayer: 중심 궤적 (gold)
|
||||
* - TripsLayer: 멤버 궤적 fade trail
|
||||
* - IconLayer: 멤버 현재 위치 (보간)
|
||||
* 레이어 구성 (iran 대비 KCG 적용):
|
||||
* - PathLayer: 중심 궤적 (gold) — iran의 정적 PathLayer에 대응
|
||||
* - TripsLayer: 멤버 궤적 fade trail — iran과 동일
|
||||
* - IconLayer: 멤버 현재 위치 (보간) — iran의 가상 선박 레이어에 대응
|
||||
* - PolygonLayer: 현재 폴리곤 (보간으로 확장/축소 애니메이션)
|
||||
* - TextLayer: MMSI 라벨
|
||||
*
|
||||
@ -55,7 +55,7 @@ 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 쓰로틀
|
||||
const RENDER_INTERVAL_MS = 100; // iran과 동일: ~10fps 쓰로틀
|
||||
|
||||
function memberIconColor(m: MemberPosition): [number, number, number, number] {
|
||||
if (m.stale) return SLATE;
|
||||
@ -71,7 +71,7 @@ export function useGearReplayLayers(
|
||||
buildBaseLayers: () => Layer[],
|
||||
) {
|
||||
const frameCursorRef = useRef(0);
|
||||
// positionCursors — 멤버별 커서 (재생 시 O(1) 위치 탐색)
|
||||
// iran의 positionCursors — 멤버별 커서 (재생 시 O(1) 위치 탐색)
|
||||
const memberCursorsRef = useRef(new Map<string, number>());
|
||||
|
||||
// buildBaseLayers를 최신 참조로 유지
|
||||
@ -79,7 +79,7 @@ export function useGearReplayLayers(
|
||||
baseLayersRef.current = buildBaseLayers;
|
||||
|
||||
/**
|
||||
* renderFrame — 보간 + 레이어 빌드 + overlay.setProps 직접 호출:
|
||||
* renderFrame — iran의 renderFrame과 동일 구조:
|
||||
* 1. 현재 위치 계산 (보간)
|
||||
* 2. 레이어 빌드
|
||||
* 3. overlay.setProps({ layers }) 직접 호출
|
||||
@ -106,7 +106,7 @@ export function useGearReplayLayers(
|
||||
);
|
||||
frameCursorRef.current = newCursor;
|
||||
|
||||
// 멤버 보간 — getCurrentVesselPositions 패턴:
|
||||
// 멤버 보간 — iran의 getCurrentVesselPositions 패턴:
|
||||
// 프레임 기반이 아닌 멤버별 개별 타임라인에서 보간 → 빈 구간도 연속 보간
|
||||
const relativeTime = currentTime - startTime;
|
||||
const members = interpolateFromTripsData(
|
||||
@ -121,7 +121,7 @@ export function useGearReplayLayers(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const replayLayers: any[] = [];
|
||||
|
||||
// 1. TripsLayer — 멤버 궤적 fade trail
|
||||
// 1. TripsLayer — 멤버 궤적 fade trail (iran과 동일 패턴)
|
||||
// TripsLayer가 자체적으로 부드러운 궤적 애니메이션 처리
|
||||
if (memberTripsData.length > 0) {
|
||||
replayLayers.push(createTripsLayer(
|
||||
@ -132,7 +132,7 @@ export function useGearReplayLayers(
|
||||
));
|
||||
}
|
||||
|
||||
// 4. 멤버 현재 위치 IconLayer
|
||||
// 4. 멤버 현재 위치 IconLayer (iran의 createVirtualShipLayers에 대응)
|
||||
const ships = members.filter(m => m.isParent);
|
||||
const gears = members.filter(m => m.isGear);
|
||||
const others = members.filter(m => !m.isParent && !m.isGear);
|
||||
@ -330,13 +330,13 @@ export function useGearReplayLayers(
|
||||
}
|
||||
}
|
||||
|
||||
// 핵심: baseLayers + replayLayers 합쳐서 overlay.setProps() 직접 호출
|
||||
// iran 핵심: baseLayers + replayLayers 합쳐서 overlay.setProps() 직접 호출
|
||||
const baseLayers = baseLayersRef.current();
|
||||
overlay.setProps({ layers: [...baseLayers, ...replayLayers] });
|
||||
}, [overlayRef]);
|
||||
|
||||
/**
|
||||
* currentTime 구독 — 재생 시 쓰로틀 + seek 시 즉시 렌더
|
||||
* currentTime 구독 — iran useReplayLayer.ts:425~458 그대로 적용
|
||||
*
|
||||
* 핵심: 재생 중 쓰로틀에 걸려도 pendingRafId로 다음 rAF에 반드시 렌더 예약
|
||||
* → 프레임 드롭 없이 부드러운 애니메이션
|
||||
@ -354,12 +354,12 @@ export function useGearReplayLayers(
|
||||
if (!useGearReplayStore.getState().groupKey) return;
|
||||
|
||||
const isPlaying = useGearReplayStore.getState().isPlaying;
|
||||
// seek/정지: 즉시 렌더
|
||||
// seek/정지: 즉시 렌더 (iran:437~439)
|
||||
if (!isPlaying) {
|
||||
renderFrame();
|
||||
return;
|
||||
}
|
||||
// 재생 중: 쓰로틀 + pending rAF
|
||||
// 재생 중: 쓰로틀 + pending rAF (iran:441~451)
|
||||
const now = performance.now();
|
||||
if (now - lastRenderTime >= RENDER_INTERVAL_MS) {
|
||||
lastRenderTime = now;
|
||||
|
||||
@ -8,7 +8,6 @@
|
||||
"darkVessel": "Dark Vessel",
|
||||
"gearDetection": "Gear Detection",
|
||||
"chinaFishing": "Chinese Vessel",
|
||||
"gearCollision": "Gear Collision",
|
||||
"patrolRoute": "Patrol Route",
|
||||
"fleetOptimization": "Fleet Optimize",
|
||||
"enforcementHistory": "History",
|
||||
@ -251,84 +250,5 @@
|
||||
"statistics": "Statistics",
|
||||
"aiOps": "AI Ops",
|
||||
"admin": "Admin"
|
||||
},
|
||||
"aria": {
|
||||
"close": "Close",
|
||||
"closeDialog": "Close dialog",
|
||||
"closeNotification": "Close notification",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"search": "Search",
|
||||
"clearSearch": "Clear search",
|
||||
"searchInPage": "Search in page",
|
||||
"refresh": "Refresh",
|
||||
"filter": "Filter",
|
||||
"filterToggle": "Toggle filter",
|
||||
"filterReset": "Reset filter",
|
||||
"statusFilter": "Status filter",
|
||||
"scopeFilter": "Scope filter",
|
||||
"groupTypeFilter": "Group type filter",
|
||||
"categoryFilter": "Category filter",
|
||||
"regionFilter": "Region filter",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"send": "Send",
|
||||
"confirmAction": "Confirm",
|
||||
"dateFrom": "Start date",
|
||||
"dateTo": "End date",
|
||||
"queryFrom": "Query start time",
|
||||
"queryTo": "Query end time",
|
||||
"roleCode": "Role code",
|
||||
"roleName": "Role name",
|
||||
"roleDesc": "Role description",
|
||||
"groupKey": "Group key",
|
||||
"subClusterId": "Sub cluster ID",
|
||||
"excludedMmsi": "Excluded MMSI",
|
||||
"exclusionReason": "Exclusion reason",
|
||||
"globalExclusionReason": "Global exclusion reason",
|
||||
"correctParentMmsi": "Correct parent MMSI",
|
||||
"uploadPanelClose": "Close upload panel",
|
||||
"noticeTitle": "Notice title",
|
||||
"noticeContent": "Notice content",
|
||||
"languageToggle": "Language toggle",
|
||||
"searchCode": "Search code",
|
||||
"searchAreaOrZone": "Search area or zone",
|
||||
"areaOfInterestSelect": "Select area of interest",
|
||||
"replayPosition": "Replay position",
|
||||
"replayClose": "Close replay",
|
||||
"miniMapClose": "Close mini map",
|
||||
"memberCountMin": "Min members",
|
||||
"memberCountMax": "Max members",
|
||||
"receiptDate": "Receipt reference date",
|
||||
"copyExampleUrl": "Copy example URL",
|
||||
"vesselDetail": "Vessel detail",
|
||||
"enforcementRegister": "Register enforcement",
|
||||
"falsePositiveProcess": "Mark false positive"
|
||||
},
|
||||
"error": {
|
||||
"operationFailed": "Operation failed: {{msg}}",
|
||||
"createFailed": "Create failed: {{msg}}",
|
||||
"updateFailed": "Update failed: {{msg}}",
|
||||
"deleteFailed": "Delete failed: {{msg}}",
|
||||
"registerFailed": "Register failed: {{msg}}",
|
||||
"processFailed": "Process failed: {{msg}}",
|
||||
"errorPrefix": "Error: {{msg}}"
|
||||
},
|
||||
"dialog": {
|
||||
"cancelSession": "Cancel this session?",
|
||||
"deleteRole": "Delete this role?",
|
||||
"genericDelete": "Delete?",
|
||||
"genericRemove": "Remove?"
|
||||
},
|
||||
"success": {
|
||||
"permissionUpdated": "Permissions updated",
|
||||
"saved": "Saved"
|
||||
},
|
||||
"message": {
|
||||
"noPermission": "No access permission",
|
||||
"loading": "Loading...",
|
||||
"builtinRoleCannotDelete": "Built-in role cannot be deleted",
|
||||
"switchToEnglish": "Switch to English",
|
||||
"switchToKorean": "Switch to Korean"
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,77 +14,5 @@
|
||||
"gearId": {
|
||||
"title": "Gear Identification",
|
||||
"desc": "SFR-10 | AI-based gear origin & type automatic identification"
|
||||
},
|
||||
"gearCollision": {
|
||||
"title": "Gear Identity Collision",
|
||||
"desc": "Same gear name broadcasting from multiple MMSIs in the same cycle — gear duplication / spoofing suspicion",
|
||||
"stats": {
|
||||
"title": "Overview",
|
||||
"total": "Total",
|
||||
"open": "Open",
|
||||
"reviewed": "Reviewed",
|
||||
"confirmed": "Confirmed Illegal",
|
||||
"falsePositive": "False Positive"
|
||||
},
|
||||
"list": {
|
||||
"title": "Collision Log",
|
||||
"empty": "No collisions detected in the last {{hours}} hours.",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"columns": {
|
||||
"name": "Gear Name",
|
||||
"mmsiPair": "MMSI Pair",
|
||||
"parentName": "Parent Vessel (est.)",
|
||||
"coexistenceCount": "Coexistence",
|
||||
"maxDistance": "Max Distance (km)",
|
||||
"severity": "Severity",
|
||||
"status": "Status",
|
||||
"lastSeen": "Last Seen",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"filters": {
|
||||
"status": "Status",
|
||||
"severity": "Severity",
|
||||
"name": "Search by name",
|
||||
"hours": "Window (hours)",
|
||||
"allStatus": "All statuses",
|
||||
"allSeverity": "All severities"
|
||||
},
|
||||
"detail": {
|
||||
"title": "Collision Detail",
|
||||
"evidence": "Observations",
|
||||
"trajectoryCompare": "Trajectory Compare",
|
||||
"firstSeenAt": "First Seen",
|
||||
"lastSeenAt": "Last Seen",
|
||||
"swapCount": "Swap Count"
|
||||
},
|
||||
"resolve": {
|
||||
"title": "Operator Review",
|
||||
"reviewed": "Mark as Reviewed",
|
||||
"confirmedIllegal": "Confirm Illegal",
|
||||
"falsePositive": "Mark False Positive",
|
||||
"reopen": "Reopen",
|
||||
"note": "Note",
|
||||
"notePlaceholder": "Record rationale or supporting evidence",
|
||||
"submit": "Save",
|
||||
"cancel": "Cancel",
|
||||
"confirmPrompt": "Update status to the selected classification. Continue?"
|
||||
},
|
||||
"status": {
|
||||
"open": "Open",
|
||||
"reviewed": "Reviewed",
|
||||
"confirmedIllegal": "Confirmed Illegal",
|
||||
"falsePositive": "False Positive"
|
||||
},
|
||||
"severity": {
|
||||
"CRITICAL": "Critical",
|
||||
"HIGH": "High",
|
||||
"MEDIUM": "Medium",
|
||||
"LOW": "Low"
|
||||
},
|
||||
"error": {
|
||||
"loadFailed": "Failed to load collisions",
|
||||
"resolveFailed": "Failed to save classification"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,7 +8,6 @@
|
||||
"darkVessel": "다크베셀 탐지",
|
||||
"gearDetection": "어구 탐지",
|
||||
"chinaFishing": "중국어선 분석",
|
||||
"gearCollision": "어구 정체성 충돌",
|
||||
"patrolRoute": "순찰경로 추천",
|
||||
"fleetOptimization": "다함정 최적화",
|
||||
"enforcementHistory": "단속 이력",
|
||||
@ -251,84 +250,5 @@
|
||||
"statistics": "통계·보고",
|
||||
"aiOps": "AI 운영",
|
||||
"admin": "시스템 관리"
|
||||
},
|
||||
"aria": {
|
||||
"close": "닫기",
|
||||
"closeDialog": "대화상자 닫기",
|
||||
"closeNotification": "알림 닫기",
|
||||
"edit": "편집",
|
||||
"delete": "삭제",
|
||||
"search": "검색",
|
||||
"clearSearch": "검색어 지우기",
|
||||
"searchInPage": "페이지 내 검색",
|
||||
"refresh": "새로고침",
|
||||
"filter": "필터",
|
||||
"filterToggle": "필터 설정",
|
||||
"filterReset": "필터 초기화",
|
||||
"statusFilter": "상태 필터",
|
||||
"scopeFilter": "스코프 필터",
|
||||
"groupTypeFilter": "그룹 유형 필터",
|
||||
"categoryFilter": "대분류 필터",
|
||||
"regionFilter": "해역 필터",
|
||||
"previous": "이전",
|
||||
"next": "다음",
|
||||
"send": "전송",
|
||||
"confirmAction": "확인",
|
||||
"dateFrom": "시작일",
|
||||
"dateTo": "종료일",
|
||||
"queryFrom": "조회 시작 시각",
|
||||
"queryTo": "조회 종료 시각",
|
||||
"roleCode": "역할 코드",
|
||||
"roleName": "역할 이름",
|
||||
"roleDesc": "역할 설명",
|
||||
"groupKey": "그룹 키",
|
||||
"subClusterId": "서브 클러스터 ID",
|
||||
"excludedMmsi": "제외 MMSI",
|
||||
"exclusionReason": "제외 사유",
|
||||
"globalExclusionReason": "전역 제외 사유",
|
||||
"correctParentMmsi": "정답 parent MMSI",
|
||||
"uploadPanelClose": "업로드 패널 닫기",
|
||||
"noticeTitle": "알림 제목",
|
||||
"noticeContent": "알림 내용",
|
||||
"languageToggle": "언어 전환",
|
||||
"searchCode": "코드 검색",
|
||||
"searchAreaOrZone": "해역 또는 해구 번호 검색",
|
||||
"areaOfInterestSelect": "관심영역 선택",
|
||||
"replayPosition": "재생 위치",
|
||||
"replayClose": "재생 닫기",
|
||||
"miniMapClose": "미니맵 닫기",
|
||||
"memberCountMin": "최소 멤버 수",
|
||||
"memberCountMax": "최대 멤버 수",
|
||||
"receiptDate": "수신 현황 기준일",
|
||||
"copyExampleUrl": "예시 URL 복사",
|
||||
"vesselDetail": "선박 상세",
|
||||
"enforcementRegister": "단속 등록",
|
||||
"falsePositiveProcess": "오탐 처리"
|
||||
},
|
||||
"error": {
|
||||
"operationFailed": "작업 실패: {{msg}}",
|
||||
"createFailed": "생성 실패: {{msg}}",
|
||||
"updateFailed": "갱신 실패: {{msg}}",
|
||||
"deleteFailed": "삭제 실패: {{msg}}",
|
||||
"registerFailed": "등록 실패: {{msg}}",
|
||||
"processFailed": "처리 실패: {{msg}}",
|
||||
"errorPrefix": "에러: {{msg}}"
|
||||
},
|
||||
"dialog": {
|
||||
"cancelSession": "세션을 취소하시겠습니까?",
|
||||
"deleteRole": "해당 역할을 삭제하시겠습니까?",
|
||||
"genericDelete": "삭제하시겠습니까?",
|
||||
"genericRemove": "제거하시겠습니까?"
|
||||
},
|
||||
"success": {
|
||||
"permissionUpdated": "권한 갱신",
|
||||
"saved": "저장되었습니다"
|
||||
},
|
||||
"message": {
|
||||
"noPermission": "접근 권한이 없습니다",
|
||||
"loading": "로딩 중...",
|
||||
"builtinRoleCannotDelete": "내장 역할은 삭제할 수 없습니다",
|
||||
"switchToEnglish": "Switch to English",
|
||||
"switchToKorean": "한국어로 전환"
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,77 +14,5 @@
|
||||
"gearId": {
|
||||
"title": "어구 식별 분석",
|
||||
"desc": "SFR-10 | AI 기반 어구 원산지·유형 자동 식별 및 판정"
|
||||
},
|
||||
"gearCollision": {
|
||||
"title": "어구 정체성 충돌 탐지",
|
||||
"desc": "동일 어구 이름이 서로 다른 MMSI 로 같은 사이클에 동시 송출되는 공존 패턴 — 어구 복제/스푸핑 의심",
|
||||
"stats": {
|
||||
"title": "현황 요약",
|
||||
"total": "전체",
|
||||
"open": "미검토",
|
||||
"reviewed": "검토됨",
|
||||
"confirmed": "불법 확정",
|
||||
"falsePositive": "오탐"
|
||||
},
|
||||
"list": {
|
||||
"title": "충돌 이력",
|
||||
"empty": "최근 {{hours}}시간 내 감지된 충돌이 없습니다.",
|
||||
"refresh": "새로고침"
|
||||
},
|
||||
"columns": {
|
||||
"name": "어구명",
|
||||
"mmsiPair": "MMSI 쌍",
|
||||
"parentName": "추정 모선",
|
||||
"coexistenceCount": "공존 횟수",
|
||||
"maxDistance": "최대 거리(km)",
|
||||
"severity": "심각도",
|
||||
"status": "상태",
|
||||
"lastSeen": "마지막 감지",
|
||||
"actions": "액션"
|
||||
},
|
||||
"filters": {
|
||||
"status": "상태",
|
||||
"severity": "심각도",
|
||||
"name": "어구명 검색",
|
||||
"hours": "조회 기간(시간)",
|
||||
"allStatus": "전체 상태",
|
||||
"allSeverity": "전체 심각도"
|
||||
},
|
||||
"detail": {
|
||||
"title": "공존 상세",
|
||||
"evidence": "관측 이력",
|
||||
"trajectoryCompare": "궤적 비교",
|
||||
"firstSeenAt": "최초 감지",
|
||||
"lastSeenAt": "마지막 감지",
|
||||
"swapCount": "교체 누적"
|
||||
},
|
||||
"resolve": {
|
||||
"title": "운영자 분류",
|
||||
"reviewed": "검토 완료",
|
||||
"confirmedIllegal": "불법으로 확정",
|
||||
"falsePositive": "오탐으로 분류",
|
||||
"reopen": "재오픈",
|
||||
"note": "판정 메모",
|
||||
"notePlaceholder": "분류 사유·추가 증거 등을 기록하세요",
|
||||
"submit": "저장",
|
||||
"cancel": "취소",
|
||||
"confirmPrompt": "선택한 분류로 상태를 갱신합니다. 계속할까요?"
|
||||
},
|
||||
"status": {
|
||||
"open": "미검토",
|
||||
"reviewed": "검토됨",
|
||||
"confirmedIllegal": "불법 확정",
|
||||
"falsePositive": "오탐"
|
||||
},
|
||||
"severity": {
|
||||
"CRITICAL": "매우 심각",
|
||||
"HIGH": "심각",
|
||||
"MEDIUM": "주의",
|
||||
"LOW": "경미"
|
||||
},
|
||||
"error": {
|
||||
"loadFailed": "충돌 목록을 불러오지 못했습니다",
|
||||
"resolveFailed": "분류 저장에 실패했습니다"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,114 +0,0 @@
|
||||
/**
|
||||
* gear_identity_collisions 조회 + 분류 액션 API 서비스.
|
||||
* 백엔드 /api/analysis/gear-collisions 연동.
|
||||
*/
|
||||
|
||||
import type { AnalysisPageResponse } from './analysisApi';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
|
||||
|
||||
// ─── DTO (백엔드 GearCollisionResponse 1:1 매핑) ─────────────
|
||||
|
||||
export interface GearCollision {
|
||||
id: number;
|
||||
name: string;
|
||||
mmsiLo: string;
|
||||
mmsiHi: string;
|
||||
parentName: string | null;
|
||||
parentVesselId: number | null;
|
||||
firstSeenAt: string;
|
||||
lastSeenAt: string;
|
||||
coexistenceCount: number;
|
||||
swapCount: number;
|
||||
maxDistanceKm: number | null;
|
||||
lastLatLo: number | null;
|
||||
lastLonLo: number | null;
|
||||
lastLatHi: number | null;
|
||||
lastLonHi: number | null;
|
||||
severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | string;
|
||||
status: 'OPEN' | 'REVIEWED' | 'CONFIRMED_ILLEGAL' | 'FALSE_POSITIVE' | string;
|
||||
resolutionNote: string | null;
|
||||
evidence: Array<Record<string, unknown>> | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface GearCollisionStats {
|
||||
total: number;
|
||||
byStatus: Record<string, number>;
|
||||
bySeverity: Record<string, number>;
|
||||
hours: number;
|
||||
}
|
||||
|
||||
export type GearCollisionResolveAction =
|
||||
| 'REVIEWED'
|
||||
| 'CONFIRMED_ILLEGAL'
|
||||
| 'FALSE_POSITIVE'
|
||||
| 'REOPEN';
|
||||
|
||||
export interface GearCollisionResolveRequest {
|
||||
action: GearCollisionResolveAction;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
// ─── 내부 헬퍼 ─────────────
|
||||
|
||||
function buildQuery(params: Record<string, unknown>): string {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v === undefined || v === null || v === '') continue;
|
||||
qs.set(k, String(v));
|
||||
}
|
||||
const s = qs.toString();
|
||||
return s ? `?${s}` : '';
|
||||
}
|
||||
|
||||
async function apiGet<T>(path: string, params: Record<string, unknown> = {}): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}${buildQuery(params)}`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function apiPost<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ─── 공개 함수 ─────────────
|
||||
|
||||
/** 어구 정체성 충돌 목록 조회 (필터 + 페이징). */
|
||||
export function listGearCollisions(params?: {
|
||||
status?: string;
|
||||
severity?: string;
|
||||
name?: string;
|
||||
hours?: number;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}): Promise<AnalysisPageResponse<GearCollision>> {
|
||||
return apiGet('/analysis/gear-collisions', {
|
||||
hours: 48, page: 0, size: 50, ...params,
|
||||
});
|
||||
}
|
||||
|
||||
/** status/severity 집계 */
|
||||
export function getGearCollisionStats(hours = 48): Promise<GearCollisionStats> {
|
||||
return apiGet('/analysis/gear-collisions/stats', { hours });
|
||||
}
|
||||
|
||||
/** 단건 상세 조회 */
|
||||
export function getGearCollision(id: number): Promise<GearCollision> {
|
||||
return apiGet(`/analysis/gear-collisions/${id}`);
|
||||
}
|
||||
|
||||
/** 운영자 분류 액션 */
|
||||
export function resolveGearCollision(
|
||||
id: number,
|
||||
body: GearCollisionResolveRequest,
|
||||
): Promise<GearCollision> {
|
||||
return apiPost(`/analysis/gear-collisions/${id}/resolve`, body);
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 모선 워크플로우 API 클라이언트.
|
||||
* - 후보/리뷰/운영자 결정 모두 자체 백엔드 + 자체 DB(gear_group_parent_resolution) 경유.
|
||||
* - 후보/리뷰: 자체 백엔드 (자체 DB의 운영자 결정)
|
||||
* - 향후: iran 백엔드의 후보 데이터와 조합 (HYBRID)
|
||||
*/
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
/**
|
||||
* prediction 분석 결과 조회 API (레거시 proxy 경로).
|
||||
* 새 화면은 @/services/analysisApi 의 /api/analysis/* 를 직접 사용한다.
|
||||
* iran 백엔드의 분석 데이터 프록시 API.
|
||||
* - 백엔드(우리)가 iran 백엔드를 호출 + HYBRID 합성하여 응답.
|
||||
*/
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, AlertTriangle, Info, Bell, Megaphone } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/*
|
||||
* SFR-02 공통컴포넌트: 알림 배너/팝업
|
||||
@ -37,7 +36,6 @@ interface NotificationBannerProps {
|
||||
}
|
||||
|
||||
export function NotificationBanner({ notices, userRole }: NotificationBannerProps) {
|
||||
const { t } = useTranslation('common');
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(() => {
|
||||
const stored = sessionStorage.getItem('dismissed_notices');
|
||||
return new Set(stored ? JSON.parse(stored) : []);
|
||||
@ -82,7 +80,7 @@ export function NotificationBanner({ notices, userRole }: NotificationBannerProp
|
||||
{notice.dismissible && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('aria.closeNotification')}
|
||||
aria-label="알림 닫기"
|
||||
onClick={() => dismiss(notice.id)}
|
||||
className="text-hint hover:text-muted-foreground shrink-0"
|
||||
>
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/*
|
||||
* SFR-02 공통컴포넌트: 검색 입력
|
||||
@ -12,24 +11,22 @@ interface SearchInputProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SearchInput({ value, onChange, placeholder, className = '' }: SearchInputProps) {
|
||||
const { t } = useTranslation('common');
|
||||
const effectivePlaceholder = placeholder ?? `${t('action.search')}...`;
|
||||
export function SearchInput({ value, onChange, placeholder = '검색...', className = '' }: SearchInputProps) {
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
|
||||
<input
|
||||
type="text"
|
||||
aria-label={effectivePlaceholder}
|
||||
aria-label={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={effectivePlaceholder}
|
||||
placeholder={placeholder}
|
||||
className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg pl-9 pr-8 py-2 text-[11px] text-label placeholder:text-hint focus:outline-none focus:border-blue-500/50"
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('aria.clearSearch')}
|
||||
aria-label="검색어 지우기"
|
||||
onClick={() => onChange('')}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-hint hover:text-muted-foreground"
|
||||
>
|
||||
|
||||
@ -123,54 +123,3 @@ export function getAlertLevelHex(level: string): string {
|
||||
export function getAlertLevelIntent(level: string): BadgeIntent {
|
||||
return getAlertLevelMeta(level)?.intent ?? 'muted';
|
||||
}
|
||||
|
||||
/** 타입 가드 — 외부 문자열이 유효한 AlertLevel 코드인지 확인 */
|
||||
export function isValidAlertLevel(value: unknown): value is AlertLevel {
|
||||
return typeof value === 'string' && value in ALERT_LEVELS;
|
||||
}
|
||||
|
||||
/** CRITICAL / HIGH 필터용 (상위 위험만 잡는 KPI·경보 집계에서 사용) */
|
||||
export function isHighSeverity(level: string): boolean {
|
||||
return level === 'CRITICAL' || level === 'HIGH';
|
||||
}
|
||||
|
||||
/** 정렬용 우선순위 (CRITICAL 이 가장 앞, UNKNOWN 은 마지막) */
|
||||
export function getAlertLevelOrder(level: string): number {
|
||||
return getAlertLevelMeta(level)?.order ?? 999;
|
||||
}
|
||||
|
||||
/** 지도 마커 불투명도 (CRITICAL 이 가장 선명) */
|
||||
export const ALERT_LEVEL_MARKER_OPACITY: Record<AlertLevel, number> = {
|
||||
CRITICAL: 0.94,
|
||||
HIGH: 0.91,
|
||||
MEDIUM: 0.88,
|
||||
LOW: 0.85,
|
||||
};
|
||||
|
||||
export function getAlertLevelMarkerOpacity(level: string): number {
|
||||
return ALERT_LEVEL_MARKER_OPACITY[level as AlertLevel] ?? 0.85;
|
||||
}
|
||||
|
||||
/** 지도 마커 반경 (미터) — 위험도가 높을수록 크게 */
|
||||
export const ALERT_LEVEL_MARKER_RADIUS: Record<AlertLevel, number> = {
|
||||
CRITICAL: 900,
|
||||
HIGH: 750,
|
||||
MEDIUM: 600,
|
||||
LOW: 500,
|
||||
};
|
||||
|
||||
export function getAlertLevelMarkerRadius(level: string): number {
|
||||
return ALERT_LEVEL_MARKER_RADIUS[level as AlertLevel] ?? 500;
|
||||
}
|
||||
|
||||
/** Tier(다크베셀 등) 점수 매핑 — 시각화용 숫자 가중치 */
|
||||
export const ALERT_LEVEL_TIER_SCORE: Record<AlertLevel, number> = {
|
||||
CRITICAL: 90,
|
||||
HIGH: 60,
|
||||
MEDIUM: 30,
|
||||
LOW: 10,
|
||||
};
|
||||
|
||||
export function getAlertLevelTierScore(level: string): number {
|
||||
return ALERT_LEVEL_TIER_SCORE[level as AlertLevel] ?? 0;
|
||||
}
|
||||
|
||||
@ -43,9 +43,6 @@ import { MLOPS_JOB_STATUSES } from './mlopsJobStatuses';
|
||||
import { THREAT_LEVELS, AGENT_PERM_TYPES, AGENT_EXEC_RESULTS } from './aiSecurityStatuses';
|
||||
import { ZONE_CODES } from './zoneCodes';
|
||||
import { GEAR_VIOLATION_CODES } from './gearViolationCodes';
|
||||
import { VESSEL_TYPES } from './vesselTypes';
|
||||
import { PERFORMANCE_STATUS_META } from './performanceStatus';
|
||||
import { GEAR_COLLISION_STATUSES } from './gearCollisionStatuses';
|
||||
|
||||
/**
|
||||
* 카탈로그 공통 메타 — 쇼케이스 렌더와 UI 일관성을 위한 최소 스키마
|
||||
@ -94,15 +91,6 @@ export const CATALOG_REGISTRY: CatalogEntry[] = [
|
||||
source: 'backend ViolationType enum',
|
||||
items: VIOLATION_TYPES,
|
||||
},
|
||||
{
|
||||
id: 'vessel-type',
|
||||
showcaseId: 'TRK-CAT-vessel-type',
|
||||
titleKo: '선박 유형',
|
||||
titleEn: 'Vessel Type',
|
||||
description: 'TRAWL / PURSE / GILLNET / LONGLINE / TRAP / CARGO / UNKNOWN — prediction 분류 + fleet_vessels 매핑',
|
||||
source: 'prediction AnalysisResult.vessel_type',
|
||||
items: VESSEL_TYPES,
|
||||
},
|
||||
{
|
||||
id: 'event-status',
|
||||
showcaseId: 'TRK-CAT-event-status',
|
||||
@ -329,24 +317,6 @@ export const CATALOG_REGISTRY: CatalogEntry[] = [
|
||||
source: 'prediction/algorithms/gear_violation.py',
|
||||
items: GEAR_VIOLATION_CODES,
|
||||
},
|
||||
{
|
||||
id: 'performance-status',
|
||||
showcaseId: 'TRK-CAT-performance-status',
|
||||
titleKo: '성능/시스템 상태',
|
||||
titleEn: 'Performance / System Status',
|
||||
description: 'good / normal / warning / critical / running / passed / failed / active / scheduled / archived',
|
||||
source: 'admin 성능·데이터 보관·모델 검증 공통',
|
||||
items: PERFORMANCE_STATUS_META,
|
||||
},
|
||||
{
|
||||
id: 'gear-collision-status',
|
||||
showcaseId: 'TRK-CAT-gear-collision-status',
|
||||
titleKo: '어구 정체성 충돌 상태',
|
||||
titleEn: 'Gear Identity Collision Status',
|
||||
description: 'OPEN / REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE',
|
||||
source: 'backend gear_identity_collisions.status (V030)',
|
||||
items: GEAR_COLLISION_STATUSES,
|
||||
},
|
||||
];
|
||||
|
||||
/** ID로 특정 카탈로그 조회 */
|
||||
|
||||
@ -1,74 +0,0 @@
|
||||
/**
|
||||
* 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 운영자 분류 상태 카탈로그
|
||||
*
|
||||
* SSOT: backend GearIdentityCollision.status 컬럼 (V030 마이그레이션)
|
||||
* 사용처: GearCollisionDetection 페이지 필터/테이블 Badge
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
export type GearCollisionStatus =
|
||||
| 'OPEN' // 신규 탐지 (미검토)
|
||||
| 'REVIEWED' // 검토됨 (확정 보류)
|
||||
| 'CONFIRMED_ILLEGAL' // 불법 확정
|
||||
| 'FALSE_POSITIVE'; // 오탐 처리
|
||||
|
||||
export interface GearCollisionStatusMeta {
|
||||
code: GearCollisionStatus;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
intent: BadgeIntent;
|
||||
}
|
||||
|
||||
export const GEAR_COLLISION_STATUSES: Record<GearCollisionStatus, GearCollisionStatusMeta> = {
|
||||
OPEN: {
|
||||
code: 'OPEN',
|
||||
i18nKey: 'gearCollision.status.open',
|
||||
fallback: { ko: '미검토', en: 'Open' },
|
||||
intent: 'warning',
|
||||
},
|
||||
REVIEWED: {
|
||||
code: 'REVIEWED',
|
||||
i18nKey: 'gearCollision.status.reviewed',
|
||||
fallback: { ko: '검토됨', en: 'Reviewed' },
|
||||
intent: 'info',
|
||||
},
|
||||
CONFIRMED_ILLEGAL: {
|
||||
code: 'CONFIRMED_ILLEGAL',
|
||||
i18nKey: 'gearCollision.status.confirmedIllegal',
|
||||
fallback: { ko: '불법 확정', en: 'Confirmed Illegal' },
|
||||
intent: 'critical',
|
||||
},
|
||||
FALSE_POSITIVE: {
|
||||
code: 'FALSE_POSITIVE',
|
||||
i18nKey: 'gearCollision.status.falsePositive',
|
||||
fallback: { ko: '오탐', en: 'False Positive' },
|
||||
intent: 'muted',
|
||||
},
|
||||
};
|
||||
|
||||
export function getGearCollisionStatusMeta(s: string): GearCollisionStatusMeta | undefined {
|
||||
return GEAR_COLLISION_STATUSES[s as GearCollisionStatus];
|
||||
}
|
||||
|
||||
export function getGearCollisionStatusIntent(s: string): BadgeIntent {
|
||||
return getGearCollisionStatusMeta(s)?.intent ?? 'muted';
|
||||
}
|
||||
|
||||
export function getGearCollisionStatusLabel(
|
||||
s: string,
|
||||
t: (key: string, opts?: Record<string, unknown>) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = getGearCollisionStatusMeta(s);
|
||||
if (!meta) return s;
|
||||
const translated = t(meta.i18nKey, { defaultValue: '' });
|
||||
return translated || meta.fallback[lang];
|
||||
}
|
||||
|
||||
export const GEAR_COLLISION_STATUS_ORDER: GearCollisionStatus[] = [
|
||||
'OPEN',
|
||||
'REVIEWED',
|
||||
'CONFIRMED_ILLEGAL',
|
||||
'FALSE_POSITIVE',
|
||||
];
|
||||
@ -1,53 +0,0 @@
|
||||
/**
|
||||
* 선박 유형 공통 카탈로그
|
||||
*
|
||||
* SSOT: prediction `AnalysisResult.vessel_type` (TRAWL / PURSE / LONGLINE / TRAP / GILLNET / CARGO / UNKNOWN).
|
||||
* prediction 은 분류 파이프라인 결과 + fleet_vessels 등록 fishery_code → vessel_type 매핑으로 값을 채운다.
|
||||
*
|
||||
* 사용처: RealVesselAnalysis '선박 유형' 컬럼, ChinaFishing 특이운항 리스트,
|
||||
* VesselDetail / EventList 등 선박 유형 배지.
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
export type VesselTypeCode =
|
||||
| 'TRAWL'
|
||||
| 'PURSE'
|
||||
| 'LONGLINE'
|
||||
| 'TRAP'
|
||||
| 'GILLNET'
|
||||
| 'CARGO'
|
||||
| 'UNKNOWN';
|
||||
|
||||
export interface VesselTypeMeta {
|
||||
code: VesselTypeCode;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
intent: BadgeIntent;
|
||||
}
|
||||
|
||||
export const VESSEL_TYPES: Record<VesselTypeCode, VesselTypeMeta> = {
|
||||
TRAWL: { code: 'TRAWL', i18nKey: 'vesselType.trawl', fallback: { ko: '저인망', en: 'Trawl' }, intent: 'warning' },
|
||||
PURSE: { code: 'PURSE', i18nKey: 'vesselType.purse', fallback: { ko: '선망', en: 'Purse Seine' }, intent: 'warning' },
|
||||
GILLNET: { code: 'GILLNET', i18nKey: 'vesselType.gillnet', fallback: { ko: '유자망', en: 'Gillnet' }, intent: 'warning' },
|
||||
LONGLINE: { code: 'LONGLINE', i18nKey: 'vesselType.longline', fallback: { ko: '연승', en: 'Longline' }, intent: 'info' },
|
||||
TRAP: { code: 'TRAP', i18nKey: 'vesselType.trap', fallback: { ko: '통발', en: 'Trap' }, intent: 'info' },
|
||||
CARGO: { code: 'CARGO', i18nKey: 'vesselType.cargo', fallback: { ko: '운반선', en: 'Cargo' }, intent: 'muted' },
|
||||
UNKNOWN: { code: 'UNKNOWN', i18nKey: 'vesselType.unknown', fallback: { ko: '미분류', en: 'Unknown' }, intent: 'muted' },
|
||||
};
|
||||
|
||||
export function getVesselTypeLabel(
|
||||
code: string | null | undefined,
|
||||
t: (k: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
if (!code) return t(VESSEL_TYPES.UNKNOWN.i18nKey, { defaultValue: VESSEL_TYPES.UNKNOWN.fallback[lang] });
|
||||
const meta = VESSEL_TYPES[code as VesselTypeCode];
|
||||
if (!meta) return code;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
|
||||
export function getVesselTypeIntent(code: string | null | undefined): BadgeIntent {
|
||||
if (!code) return VESSEL_TYPES.UNKNOWN.intent;
|
||||
return VESSEL_TYPES[code as VesselTypeCode]?.intent ?? 'muted';
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
* 어구 그룹 궤적 리플레이 전처리 모듈
|
||||
*
|
||||
* API 데이터(history frames) → deck.gl TripsLayer 포맷으로 변환.
|
||||
* iran 프로젝트의 검증된 패턴을 KCG에 맞게 재구현.
|
||||
*/
|
||||
|
||||
// ── 타입 ──────────────────────────────────────────────────────────────
|
||||
@ -316,6 +317,7 @@ export function buildMemberMetadata(
|
||||
|
||||
/**
|
||||
* 두 좌표 사이의 heading(침로) 계산.
|
||||
* iran animationStore.ts의 calculateHeading과 동일.
|
||||
*/
|
||||
function calcHeading(p1: [number, number], p2: [number, number]): number {
|
||||
const dx = p2[0] - p1[0];
|
||||
@ -326,11 +328,11 @@ function calcHeading(p1: [number, number], p2: [number, number]): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* 멤버별 개별 타임라인에서 보간하여 현재 위치를 반환.
|
||||
* iran의 getCurrentVesselPositions 패턴 — 멤버별 개별 타임라인에서 보간.
|
||||
*
|
||||
* 프레임 기반(frameA/frameB) 대신 멤버별 경로(memberTripsData)를 사용하여
|
||||
* 각 멤버가 24시간 내내 연속 경로로 유지.
|
||||
* 커서 기반 전진 스캔(O(1)) + 이진탐색 fallback (seek 대비).
|
||||
* 커서 기반 전진 스캔(O(1)) + 이진탐색 fallback (iran과 동일).
|
||||
*
|
||||
* @param memberTripsData — 전처리된 멤버별 경로 (timestamps는 startTime 기준 상대값)
|
||||
* @param memberMeta — 멤버 메타 정보 (name, role, isParent)
|
||||
@ -373,7 +375,7 @@ export function interpolateFromTripsData(
|
||||
continue;
|
||||
}
|
||||
|
||||
// 커서 기반 탐색 (positionCursors 패턴)
|
||||
// 커서 기반 탐색 (iran positionCursors 패턴)
|
||||
let cursor = cursors.get(mmsi) ?? 0;
|
||||
|
||||
if (
|
||||
@ -404,7 +406,7 @@ export function interpolateFromTripsData(
|
||||
const cog = idx1 > 0 ? calcHeading(path[idx1 - 1], path[idx1]) : 0;
|
||||
positions.push({ ...base, lon: path[idx1][0], lat: path[idx1][1], cog });
|
||||
} else {
|
||||
// 선형 보간
|
||||
// 선형 보간 (iran의 interpolatePosition과 동일)
|
||||
const ratio = (relativeTimeMs - timestamps[idx1]) / (timestamps[idx2] - timestamps[idx1]);
|
||||
const lon = path[idx1][0] + (path[idx2][0] - path[idx1][0]) * ratio;
|
||||
const lat = path[idx1][1] + (path[idx2][1] - path[idx1][1]) * ratio;
|
||||
|
||||
@ -1,157 +0,0 @@
|
||||
"""
|
||||
어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 탐지 알고리즘.
|
||||
|
||||
동일 어구 이름이 서로 다른 MMSI 로 같은 5분 사이클 내 동시 AIS 송출되는 케이스를
|
||||
스푸핑/복제 의심 패턴으로 탐지한다. fleet_tracker.track_gear_identity() 루프 진입
|
||||
전에 사이클 단위로 사전 집계하는 데 사용된다.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from itertools import combinations
|
||||
from typing import Optional
|
||||
|
||||
from algorithms.location import haversine_nm
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# 공존 판정 · 심각도 임계
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
MIN_COEXISTENCE_GROUP = 2 # 같은 이름에 MMSI 2개 이상
|
||||
IMPOSSIBLE_SPEED_KTS = 60.0 # 두 위치 이동에 필요한 속도가 이보다 크면 물리 불가능
|
||||
CRITICAL_DISTANCE_KM = 50.0 # 단발이라도 이 거리 이상이면 즉시 CRITICAL
|
||||
HIGH_DISTANCE_KM = 10.0 # HIGH 기준 거리
|
||||
CRITICAL_COEXISTENCE_COUNT = 3 # 누적 공존 N회 이상이면 CRITICAL 승격
|
||||
HIGH_COEXISTENCE_COUNT = 2 # 누적 공존 N회 이상이면 HIGH
|
||||
NM_TO_KM = 1.852 # 1 nautical mile = 1.852 km
|
||||
|
||||
|
||||
def detect_gear_name_collisions(
|
||||
gear_signals: list[dict],
|
||||
now: datetime,
|
||||
) -> list[dict]:
|
||||
"""동일 이름 · 다중 MMSI 공존 세트 추출.
|
||||
|
||||
Args:
|
||||
gear_signals: [{mmsi, name, lat, lon}, ...] — track_gear_identity 와 동일 입력.
|
||||
now: 사이클 기준 시각(UTC).
|
||||
|
||||
Returns:
|
||||
공존 쌍 리스트. 세 개 이상 동시 송출 케이스는 모든 2-조합을 생성한다.
|
||||
각 원소:
|
||||
{
|
||||
'name': str,
|
||||
'mmsi_lo': str, # 사전순으로 작은 MMSI
|
||||
'mmsi_hi': str,
|
||||
'lat_lo', 'lon_lo': float,
|
||||
'lat_hi', 'lon_hi': float,
|
||||
'distance_km': float,
|
||||
'parent_name': Optional[str], # 힌트 (GEAR_PATTERN parent 그룹, 있으면)
|
||||
'observed_at': datetime,
|
||||
}
|
||||
"""
|
||||
if not gear_signals:
|
||||
return []
|
||||
|
||||
# 이름 기준 그룹핑
|
||||
by_name: dict[str, list[dict]] = {}
|
||||
for sig in gear_signals:
|
||||
name = sig.get('name')
|
||||
mmsi = sig.get('mmsi')
|
||||
if not name or not mmsi:
|
||||
continue
|
||||
by_name.setdefault(name, []).append(sig)
|
||||
|
||||
collisions: list[dict] = []
|
||||
for name, signals in by_name.items():
|
||||
if len(signals) < MIN_COEXISTENCE_GROUP:
|
||||
continue
|
||||
# 같은 MMSI 중복은 제거 (한 cycle 에 동일 MMSI 가 다수 신호로 들어올 수 있음)
|
||||
unique_by_mmsi: dict[str, dict] = {}
|
||||
for sig in signals:
|
||||
unique_by_mmsi.setdefault(sig['mmsi'], sig)
|
||||
if len(unique_by_mmsi) < MIN_COEXISTENCE_GROUP:
|
||||
continue
|
||||
|
||||
parent_name = _infer_parent_name(name)
|
||||
mmsis = sorted(unique_by_mmsi.keys())
|
||||
for a, b in combinations(mmsis, 2):
|
||||
sa, sb = unique_by_mmsi[a], unique_by_mmsi[b]
|
||||
dist_km = _haversine_km(
|
||||
sa.get('lat'), sa.get('lon'),
|
||||
sb.get('lat'), sb.get('lon'),
|
||||
)
|
||||
collisions.append({
|
||||
'name': name,
|
||||
'mmsi_lo': a,
|
||||
'mmsi_hi': b,
|
||||
'lat_lo': _to_float(sa.get('lat')),
|
||||
'lon_lo': _to_float(sa.get('lon')),
|
||||
'lat_hi': _to_float(sb.get('lat')),
|
||||
'lon_hi': _to_float(sb.get('lon')),
|
||||
'distance_km': dist_km,
|
||||
'parent_name': parent_name,
|
||||
'observed_at': now,
|
||||
})
|
||||
return collisions
|
||||
|
||||
|
||||
def classify_severity(
|
||||
coexistence_count: int,
|
||||
max_distance_km: Optional[float],
|
||||
swap_count: int = 0,
|
||||
) -> str:
|
||||
"""충돌 심각도 산정.
|
||||
|
||||
- CRITICAL: 거리 불가능 / 누적 공존 N회 이상
|
||||
- HIGH: 상당 거리 / 2회 이상
|
||||
- MEDIUM: 단발 근거리
|
||||
- LOW: 근거리 + 거리 정보 없음
|
||||
"""
|
||||
distance = max_distance_km or 0.0
|
||||
if distance >= CRITICAL_DISTANCE_KM:
|
||||
return 'CRITICAL'
|
||||
if coexistence_count >= CRITICAL_COEXISTENCE_COUNT:
|
||||
return 'CRITICAL'
|
||||
if distance >= HIGH_DISTANCE_KM:
|
||||
return 'HIGH'
|
||||
if coexistence_count >= HIGH_COEXISTENCE_COUNT:
|
||||
return 'HIGH'
|
||||
if swap_count >= HIGH_COEXISTENCE_COUNT:
|
||||
return 'HIGH'
|
||||
if max_distance_km is None or max_distance_km < 0.1:
|
||||
return 'LOW'
|
||||
return 'MEDIUM'
|
||||
|
||||
|
||||
def _haversine_km(lat1, lon1, lat2, lon2) -> float:
|
||||
"""두 좌표 사이 거리를 km 로 반환. 입력 누락 시 0.0."""
|
||||
try:
|
||||
if lat1 is None or lon1 is None or lat2 is None or lon2 is None:
|
||||
return 0.0
|
||||
nm = haversine_nm(float(lat1), float(lon1), float(lat2), float(lon2))
|
||||
return round(nm * NM_TO_KM, 2)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _to_float(val) -> Optional[float]:
|
||||
if val is None:
|
||||
return None
|
||||
try:
|
||||
return float(val)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _infer_parent_name(gear_name: str) -> Optional[str]:
|
||||
"""어구 이름에서 모선명 부분 추출 (느슨).
|
||||
|
||||
fleet_tracker 가 이미 GEAR_PATTERN 으로 정교하게 파싱하지만, 알고리즘 모듈 독립성을
|
||||
위해 단순 휴리스틱만 유지한다. 값이 필요한 경우 fleet_tracker 호출부에서 덮어쓴다.
|
||||
"""
|
||||
if not gear_name:
|
||||
return None
|
||||
# '_숫자' 로 끝나는 서픽스 제거
|
||||
base = gear_name
|
||||
parts = base.rsplit('_', 2)
|
||||
if len(parts) >= 2 and any(ch.isdigit() for ch in parts[-1]):
|
||||
return parts[0]
|
||||
return None
|
||||
@ -14,71 +14,42 @@ def compute_lightweight_risk_score(
|
||||
is_dark: bool = False,
|
||||
gap_duration_min: int = 0,
|
||||
spoofing_score: float = 0.0,
|
||||
dark_suspicion_score: int = 0,
|
||||
dist_from_baseline_nm: float = 999.0,
|
||||
dark_history_24h: int = 0,
|
||||
) -> Tuple[int, str]:
|
||||
"""위치·허가·다크/스푸핑 기반 경량 위험도 (파이프라인 미통과 선박용).
|
||||
|
||||
compute_dark_suspicion 의 패턴 기반 0~100 점수를 직접 반영해 해상도를 높인다.
|
||||
이중계산 방지: dark_suspicion_score 는 이미 무허가/반복을 포함하므로 dark_suspicion_score > 0
|
||||
인 경우 허가/반복 가산을 축소한다.
|
||||
|
||||
임계값 70/50/30 은 pipeline path(compute_vessel_risk_score)와 동일.
|
||||
pipeline path의 compute_vessel_risk_score와 동일한 임계값(70/50/30)을 사용해
|
||||
분류 결과의 일관성을 유지한다. dark/spoofing 신호를 추가하여 max 100점 도달 가능.
|
||||
|
||||
Returns: (risk_score, risk_level)
|
||||
"""
|
||||
score = 0
|
||||
|
||||
# 1. 위치 기반 (최대 40점) — EEZ 외 기선 근접도 추가
|
||||
# 1. 위치 기반 (최대 40점)
|
||||
zone = zone_info.get('zone', '')
|
||||
if zone == 'TERRITORIAL_SEA':
|
||||
score += 40
|
||||
elif zone == 'CONTIGUOUS_ZONE':
|
||||
score += 15
|
||||
score += 10
|
||||
elif zone.startswith('ZONE_'):
|
||||
if is_permitted is not None and not is_permitted:
|
||||
score += 25
|
||||
elif zone == 'EEZ_OR_BEYOND':
|
||||
# EEZ 외라도 기선 근접 시 가산 (공해·외해 분산)
|
||||
if dist_from_baseline_nm < 12:
|
||||
score += 15
|
||||
elif dist_from_baseline_nm < 24:
|
||||
score += 8
|
||||
|
||||
# 2. 다크 베셀 (최대 30점) — dark_suspicion_score 우선
|
||||
# 2. 다크 베셀 (최대 25점)
|
||||
if is_dark:
|
||||
if dark_suspicion_score >= 1:
|
||||
# compute_dark_suspicion 이 산출한 패턴 기반 의심도 반영
|
||||
score += min(30, round(dark_suspicion_score * 0.3))
|
||||
else:
|
||||
# fallback: gap 길이만 기준
|
||||
if gap_duration_min >= 720:
|
||||
score += 25
|
||||
elif gap_duration_min >= 180:
|
||||
score += 20
|
||||
elif gap_duration_min >= 60:
|
||||
score += 15
|
||||
elif gap_duration_min >= 30:
|
||||
score += 8
|
||||
if gap_duration_min >= 60:
|
||||
score += 25
|
||||
elif gap_duration_min >= 30:
|
||||
score += 10
|
||||
|
||||
# 3. 스푸핑 (최대 15점) — 현재 중국 선박은 거의 0 (별도 PR 에서 산출 로직 재설계 예정)
|
||||
# 3. 스푸핑 (최대 15점)
|
||||
if spoofing_score > 0.7:
|
||||
score += 15
|
||||
elif spoofing_score > 0.5:
|
||||
score += 8
|
||||
|
||||
# 4. 허가 이력 (최대 15점) — 이중계산 방지
|
||||
# 4. 허가 이력 (최대 20점)
|
||||
if is_permitted is not None and not is_permitted:
|
||||
# dark_suspicion_score 에 이미 무허가 +10 반영됨 → 축소
|
||||
score += 8 if dark_suspicion_score > 0 else 15
|
||||
|
||||
# 5. 반복 이력 (최대 10점) — dark_suspicion_score 미반영 케이스만
|
||||
if dark_suspicion_score == 0 and dark_history_24h > 0:
|
||||
if dark_history_24h >= 5:
|
||||
score += 10
|
||||
elif dark_history_24h >= 2:
|
||||
score += 5
|
||||
score += 20
|
||||
|
||||
score = min(score, 100)
|
||||
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
"""한중어업협정 fishery_code → VesselType 매핑.
|
||||
|
||||
파이프라인 미통과 선박(경량 분석 경로)은 AIS 샘플 부족으로 분류기가 UNKNOWN 을 반환한다.
|
||||
등록선은 fishery_code 가 이미 확정이므로 이를 활용해 vessel_type 을 채운다.
|
||||
|
||||
VesselType 값 확장:
|
||||
기존: TRAWL / PURSE / LONGLINE / TRAP / UNKNOWN
|
||||
신규: GILLNET (유자망) / CARGO (운반선)
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
|
||||
FISHERY_CODE_TO_VESSEL_TYPE = {
|
||||
'PT': 'TRAWL', # 쌍끌이 저인망
|
||||
'PT-S': 'TRAWL', # 쌍끌이 부속선
|
||||
'OT': 'TRAWL', # 단선 저인망
|
||||
'GN': 'GILLNET', # 유자망
|
||||
'PS': 'PURSE', # 대형선망/위망
|
||||
'FC': 'CARGO', # 운반선 (조업 아님)
|
||||
}
|
||||
|
||||
|
||||
def fishery_code_to_vessel_type(fishery_code: Optional[str]) -> str:
|
||||
"""등록 어업 코드 → 선박 유형. 매칭 없으면 'UNKNOWN'."""
|
||||
if not fishery_code:
|
||||
return 'UNKNOWN'
|
||||
return FISHERY_CODE_TO_VESSEL_TYPE.get(fishery_code.upper(), 'UNKNOWN')
|
||||
Some files were not shown because too many files have changed in this diff Show More
불러오는 중...
Reference in New Issue
Block a user