Merge pull request 'release: 2026-04-13 (21건 커밋)' (#32) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 17s

This commit is contained in:
htlee 2026-04-13 11:15:46 +09:00
커밋 f304f778ca
39개의 변경된 파일2368개의 추가작업 그리고 419개의 파일을 삭제

파일 보기

@ -0,0 +1,76 @@
-- ============================================================
-- V025: LGCNS MLOps + AI 보안(SER-10) + AI Agent 보안(SER-11) 메뉴 추가
-- 시스템관리 > AI 플랫폼 / 감사·보안 서브그룹
-- ============================================================
-- ──────────────────────────────────────────────────────────────
-- 1. LGCNS MLOps (시스템관리 > AI 플랫폼, MLOps와 LLM 사이)
-- ──────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('admin:lgcns-mlops', 'admin', 'LGCNS MLOps', 1, 36)
ON CONFLICT (rsrc_cd) DO NOTHING;
UPDATE kcg.auth_perm_tree
SET url_path = '/lgcns-mlops',
label_key = 'nav.lgcnsMlops',
component_key = 'features/ai-operations/LGCNSMLOpsPage',
nav_group = 'admin',
nav_sub_group = 'AI 플랫폼',
nav_sort = 350,
labels = '{"ko":"LGCNS MLOps","en":"LGCNS MLOps"}'
WHERE rsrc_cd = 'admin:lgcns-mlops';
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'admin:lgcns-mlops', 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;
-- ──────────────────────────────────────────────────────────────
-- 2. AI 보안 (SER-10) (시스템관리 > 감사·보안, 로그인 이력 뒤)
-- ──────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('admin:ai-security', 'admin', 'AI 보안', 1, 55)
ON CONFLICT (rsrc_cd) DO NOTHING;
UPDATE kcg.auth_perm_tree
SET url_path = '/admin/ai-security',
label_key = 'nav.aiSecurity',
component_key = 'features/admin/AISecurityPage',
nav_group = 'admin',
nav_sub_group = '감사·보안',
nav_sort = 1800,
labels = '{"ko":"AI 보안","en":"AI Security"}'
WHERE rsrc_cd = 'admin:ai-security';
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'admin:ai-security', 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;
-- ──────────────────────────────────────────────────────────────
-- 3. AI Agent 보안 (SER-11) (시스템관리 > 감사·보안, AI 보안 뒤)
-- ──────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('admin:ai-agent-security', 'admin', 'AI Agent 보안', 1, 56)
ON CONFLICT (rsrc_cd) DO NOTHING;
UPDATE kcg.auth_perm_tree
SET url_path = '/admin/ai-agent-security',
label_key = 'nav.aiAgentSecurity',
component_key = 'features/admin/AIAgentSecurityPage',
nav_group = 'admin',
nav_sub_group = '감사·보안',
nav_sort = 1900,
labels = '{"ko":"AI Agent 보안","en":"AI Agent Security"}'
WHERE rsrc_cd = 'admin:ai-agent-security';
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'admin:ai-agent-security', 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;

파일 보기

@ -4,6 +4,20 @@
## [Unreleased] ## [Unreleased]
## [2026-04-13]
### 추가
- **LGCNS MLOps 메뉴** — 시스템관리 > AI 플랫폼 하위, 모델 레지스트리/학습 파이프라인/서빙 현황/모델 모니터링 탭 구성
- **AI 보안(SER-10) 메뉴** — 시스템관리 > 감사·보안 하위, AI 모델 보안 감사/Adversarial 공격 탐지/데이터 무결성 검증/보안 이벤트 타임라인
- **AI Agent 보안(SER-11) 메뉴** — 시스템관리 > 감사·보안 하위, 에이전트 실행 로그/정책 위반 탐지/자원 사용 모니터링/신뢰도 대시보드
- **V025 마이그레이션** — auth_perm_tree에 admin:lgcns-mlops, admin:ai-security, admin:ai-agent-security 노드 + ADMIN 역할 CRUD 권한 시드
- **prediction 알고리즘 재설계** — dark_vessel 의심 점수화(8패턴 0~100), transshipment 베테랑 재설계, vessel_store/scheduler 개선
- **프론트엔드 지도 레이어 구조 정리** — BaseMap, useMapLayers, static layers 리팩토링
### 변경
- **NoticeManagement CRUD 권한 가드** — admin:notices CREATE/UPDATE/DELETE 체크 추가 (disabled + 툴팁)
- **EventList CRUD 권한 가드** — enforcement:event-list UPDATE + enforcement:enforcement-history CREATE 체크 추가 (disabled + 툴팁)
## [2026-04-09.2] ## [2026-04-09.2]
### 추가 ### 추가

파일 보기

@ -1,5 +1,5 @@
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'; import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
import { fetchMe, loginApi, logoutApi, LoginError, type BackendUser } from '@/services/authApi'; import { fetchMe, loginApi, logoutApi, LoginError, type BackendUser, type MenuConfigItem } from '@/services/authApi';
import { useMenuStore } from '@stores/menuStore'; import { useMenuStore } from '@stores/menuStore';
/* /*

파일 보기

@ -80,6 +80,9 @@ export const COMPONENT_REGISTRY: Record<string, LazyComponent> = {
'features/ai-operations/MLOpsPage': lazy(() => 'features/ai-operations/MLOpsPage': lazy(() =>
import('@features/ai-operations').then((m) => ({ default: m.MLOpsPage })), import('@features/ai-operations').then((m) => ({ default: m.MLOpsPage })),
), ),
'features/ai-operations/LGCNSMLOpsPage': lazy(() =>
import('@features/ai-operations').then((m) => ({ default: m.LGCNSMLOpsPage })),
),
'features/ai-operations/LLMOpsPage': lazy(() => 'features/ai-operations/LLMOpsPage': lazy(() =>
import('@features/ai-operations').then((m) => ({ default: m.LLMOpsPage })), import('@features/ai-operations').then((m) => ({ default: m.LLMOpsPage })),
), ),
@ -113,6 +116,12 @@ export const COMPONENT_REGISTRY: Record<string, LazyComponent> = {
default: m.LoginHistoryView, default: m.LoginHistoryView,
})), })),
), ),
'features/admin/AISecurityPage': lazy(() =>
import('@features/admin').then((m) => ({ default: m.AISecurityPage })),
),
'features/admin/AIAgentSecurityPage': lazy(() =>
import('@features/admin').then((m) => ({ default: m.AIAgentSecurityPage })),
),
// ── 모선 워크플로우 ── // ── 모선 워크플로우 ──
'features/parent-inference/ParentReview': lazy(() => 'features/parent-inference/ParentReview': lazy(() =>
import('@features/parent-inference/ParentReview').then((m) => ({ import('@features/parent-inference/ParentReview').then((m) => ({

파일 보기

@ -0,0 +1,295 @@
import { useState } from 'react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { getStatusIntent } from '@shared/constants/statusIntent';
import {
Bot, Shield, Lock, Eye, Activity, AlertTriangle,
CheckCircle, FileText, Settings, Terminal, Users,
Key, Layers, Workflow, Hand,
} from 'lucide-react';
/*
* SER-11: AI Agent
*
* AI Agent :
* Agent ·
* · MCP Tool
*/
type Tab = 'overview' | 'whitelist' | 'killswitch' | 'identity' | 'mcp' | 'audit';
// ─── Agent 현황 ──────────────────
const AGENTS = [
{ name: '위험도 분석 Agent', type: '조회 전용', tools: 4, status: '활성', calls24h: 1240, lastCall: '04-10 09:28' },
{ name: '법령 Q&A Agent', type: '조회 전용', tools: 3, status: '활성', calls24h: 856, lastCall: '04-10 09:25' },
{ name: '단속 이력 Agent', type: '조회 전용', tools: 5, status: '활성', calls24h: 432, lastCall: '04-10 09:20' },
{ name: '모선 추론 Agent', type: '조회+쓰기', tools: 6, status: '활성', calls24h: 128, lastCall: '04-10 09:15' },
{ name: '데이터 관리 Agent', type: '관리자', tools: 8, status: '대기', calls24h: 0, lastCall: '04-09 16:00' },
];
const AGENT_KPI = [
{ label: '활성 Agent', value: '4', color: '#10b981' },
{ label: '등록 Tool', value: '26', color: '#3b82f6' },
{ label: '24h 호출', value: '2,656', color: '#8b5cf6' },
{ label: '차단 건수', value: '3', color: '#ef4444' },
{ label: '승인 대기', value: '0', color: '#f59e0b' },
];
// ─── 화이트리스트 도구 ──────────────────
const WHITELIST_TOOLS = [
{ tool: 'db_read_vessel', agent: '위험도 분석', permission: 'READ', mcp: 'kcg-db-server', status: '허용', desc: '선박 정보 조회' },
{ tool: 'db_read_analysis', agent: '위험도 분석', permission: 'READ', mcp: 'kcg-db-server', status: '허용', desc: '분석 결과 조회' },
{ tool: 'search_law', agent: '법령 Q&A', permission: 'READ', mcp: 'kcg-rag-server', status: '허용', desc: '법령·판례 검색' },
{ tool: 'search_cases', agent: '법령 Q&A', permission: 'READ', mcp: 'kcg-rag-server', status: '허용', desc: '유사 사례 검색' },
{ tool: 'read_enforcement', agent: '단속 이력', permission: 'READ', mcp: 'kcg-db-server', status: '허용', desc: '단속 이력 조회' },
{ tool: 'write_parent_result', agent: '모선 추론', permission: 'CREATE', mcp: 'kcg-db-server', status: '허용', desc: '모선 추론 결과 저장' },
{ tool: 'update_parent_status', agent: '모선 추론', permission: 'UPDATE', mcp: 'kcg-db-server', status: '허용', desc: '모선 상태 갱신' },
{ tool: 'db_delete_any', agent: '-', permission: 'DELETE', mcp: '-', status: '차단', desc: 'DB 삭제 (금지 도구)' },
{ tool: 'system_exec', agent: '-', permission: 'ADMIN', mcp: '-', status: '차단', desc: '시스템 명령 실행 (금지)' },
];
// ─── 자동 중단 설정 ──────────────────
const KILL_SWITCH_RULES = [
{ rule: '유해·금지행위 탐지', desc: '유해·금지행위, 잘못된 목표 설정 시 Agent 자동 중단', threshold: '즉시', status: '활성' },
{ rule: '자원 소비 임계값 초과', desc: 'GPU/메모리/API 호출 임계값 초과 시 자동 중단', threshold: 'GPU 90% / 메모리 85%', status: '활성' },
{ rule: '이상 호출 패턴', desc: '비정상적으로 빈번한 Tool 호출 탐지', threshold: '100회/분', status: '활성' },
{ rule: '응답 시간 초과', desc: 'Agent 응답 타임아웃', threshold: '30초', status: '활성' },
];
// ─── 민감명령 승인 ──────────────────
const SENSITIVE_COMMANDS = [
{ command: 'DB 데이터 수정/삭제', level: '높음', approval: '담당자 승인 필수', hitl: true, status: '적용' },
{ command: '모델 배포/롤백', level: '높음', approval: '담당자 승인 필수', hitl: true, status: '적용' },
{ command: '사용자 권한 변경', level: '높음', approval: '관리자 승인 필수', hitl: true, status: '적용' },
{ command: '외부 시스템 연계 호출', level: '중간', approval: '자동 승인 (로그)', hitl: false, status: '적용' },
{ command: '분석 결과 조회', level: '낮음', approval: '자동 승인', hitl: false, status: '적용' },
];
// ─── 에이전트 신원 확인 ──────────────────
const IDENTITY_POLICIES = [
{ policy: '미승인 권한 위임 차단', desc: '명시적으로 승인되지 않은 AI 에이전트로 권한 위임 제한', status: '적용' },
{ policy: 'Agent 간 신원 확인', desc: '협업할 AI 에이전트가 적합한 인증 혹은 신원 보유 중인지 상호 검증', status: '적용' },
{ policy: 'Agent 인증 방식', desc: 'Agent 간 인증은 표준 방식으로 제안, 발주처 간 협의를 통해 최종 선정', status: '적용' },
{ policy: '과도한 호출 방지', desc: 'AI 에이전트의 과도한 호출로 인한 레거시 시스템 마비 방지', status: '적용' },
];
// ─── MCP Tool 권한 ──────────────────
const MCP_PERMISSIONS = [
{ server: 'kcg-db-server', tools: 8, principle: '최소 권한', detail: '조회 Agent는 DB READ만, 쓰기 Agent는 특정 테이블 C/U만', status: '적용' },
{ server: 'kcg-rag-server', tools: 5, principle: '최소 권한', detail: 'RAG 검색만 허용, 인덱스 수정 불가', status: '적용' },
{ server: 'kcg-api-server', tools: 6, principle: '최소 권한', detail: '외부 API 호출은 Rate Limiting + Caching', status: '적용' },
{ server: 'kcg-notify-server', tools: 3, principle: '최소 권한', detail: '알림 발송만 허용, 수신자 목록 수정 불가', status: '적용' },
];
// ─── 감사 로그 ──────────────────
const AUDIT_LOG_SAMPLE = [
{ time: '09:28:15', chain: 'User → LLM → MCP Client → kcg-db-server → DB', agent: '위험도 분석', tool: 'db_read_vessel', result: '성공', latency: '120ms' },
{ time: '09:25:42', chain: 'User → LLM → MCP Client → kcg-rag-server → Milvus', agent: '법령 Q&A', tool: 'search_law', result: '성공', latency: '850ms' },
{ time: '09:20:10', chain: 'User → LLM → MCP Client → kcg-db-server → DB', agent: '단속 이력', tool: 'read_enforcement', result: '성공', latency: '95ms' },
{ time: '09:15:33', chain: 'User → LLM → MCP Client → kcg-db-server → DB', agent: '모선 추론', tool: 'write_parent_result', result: '승인→성공', latency: '230ms' },
{ time: '08:42:00', chain: 'User → LLM → Kill Switch', agent: '데이터 관리', tool: 'db_delete_any', result: '차단', latency: '-' },
];
export function AIAgentSecurityPage() {
const [tab, setTab] = useState<Tab>('overview');
return (
<PageContainer>
<PageHeader
icon={Bot}
iconColor="text-orange-400"
title="AI Agent 구축·운영 보안"
description="SER-11 | AI Agent 화이트리스트·자동중단·민감명령 승인·MCP 최소권한·감사로그"
demo
/>
{/* 탭 */}
<div className="flex gap-0 border-b border-border">
{([
{ key: 'overview' as Tab, icon: Activity, label: 'Agent 현황' },
{ key: 'whitelist' as Tab, icon: CheckCircle, label: '화이트리스트 도구' },
{ key: 'killswitch' as Tab, icon: AlertTriangle, label: '자동 중단·승인' },
{ key: 'identity' as Tab, icon: Users, label: 'Agent 신원·권한' },
{ key: 'mcp' as Tab, icon: Layers, label: 'MCP Tool 권한' },
{ key: 'audit' as Tab, icon: FileText, label: '감사 로그' },
]).map(t => (
<button type="button" key={t.key} onClick={() => setTab(t.key)}
className={`flex items-center gap-1.5 px-4 py-2.5 text-[11px] font-medium border-b-2 transition-colors ${tab === t.key ? 'text-orange-400 border-orange-400' : 'text-hint border-transparent hover:text-label'}`}>
<t.icon className="w-3.5 h-3.5" />{t.label}
</button>
))}
</div>
{/* ── ① Agent 현황 ── */}
{tab === 'overview' && (
<div className="space-y-3">
<div className="flex gap-2">
{AGENT_KPI.map(k => (
<div key={k.label} className="flex-1 px-4 py-3 rounded-xl border border-border bg-card" style={{ borderLeftColor: k.color, borderLeftWidth: 3 }}>
<div className="text-xl font-bold" style={{ color: k.color }}>{k.value}</div>
<div className="text-[9px] text-hint">{k.label}</div>
</div>
))}
</div>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> Agent </div>
<table className="w-full text-[10px]">
<thead><tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2">Agent명</th><th className="text-center py-2"></th>
<th className="text-center py-2"></th><th className="text-center py-2">24h </th>
<th className="text-center py-2"> </th><th className="text-center py-2"></th>
</tr></thead>
<tbody>{AGENTS.map(a => (
<tr key={a.name} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2 px-2 text-heading font-medium">{a.name}</td>
<td className="py-2 text-center"><Badge intent={a.type === '관리자' ? 'high' : a.type.includes('쓰기') ? 'warning' : 'info'} size="sm">{a.type}</Badge></td>
<td className="py-2 text-center text-heading">{a.tools}</td>
<td className="py-2 text-center text-muted-foreground">{a.calls24h.toLocaleString()}</td>
<td className="py-2 text-center text-muted-foreground">{a.lastCall}</td>
<td className="py-2 text-center"><Badge intent={getStatusIntent(a.status)} size="sm">{a.status}</Badge></td>
</tr>
))}</tbody>
</table>
</CardContent></Card>
</div>
)}
{/* ── ② 화이트리스트 도구 ── */}
{tab === 'whitelist' && (
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> </div>
<p className="text-[10px] text-hint mb-3">AI가 · </p>
<table className="w-full text-[10px]">
<thead><tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2">Tool ID</th><th className="text-left py-2"></th>
<th className="text-center py-2">Agent</th><th className="text-center py-2"></th>
<th className="text-left py-2">MCP Server</th><th className="text-center py-2"></th>
</tr></thead>
<tbody>{WHITELIST_TOOLS.map(t => (
<tr key={t.tool} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2 px-2 text-heading font-mono text-[9px]">{t.tool}</td>
<td className="py-2 text-hint">{t.desc}</td>
<td className="py-2 text-center text-muted-foreground">{t.agent}</td>
<td className="py-2 text-center"><Badge intent={t.permission === 'DELETE' || t.permission === 'ADMIN' ? 'critical' : t.permission === 'READ' ? 'info' : 'warning'} size="sm">{t.permission}</Badge></td>
<td className="py-2 text-muted-foreground font-mono text-[9px]">{t.mcp}</td>
<td className="py-2 text-center"><Badge intent={t.status === '허용' ? 'success' : 'critical'} size="sm">{t.status}</Badge></td>
</tr>
))}</tbody>
</table>
</CardContent></Card>
)}
{/* ── ③ 자동 중단·민감명령 승인 ── */}
{tab === 'killswitch' && (
<div className="space-y-3">
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3">AI (Kill Switch)</div>
<div className="space-y-2">
{KILL_SWITCH_RULES.map(r => (
<div key={r.rule} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
<AlertTriangle className="w-4 h-4 text-red-400" />
<div className="flex-1">
<div className="text-[11px] text-heading font-medium">{r.rule}</div>
<div className="text-[9px] text-hint">{r.desc}</div>
</div>
<span className="text-[9px] text-muted-foreground">: {r.threshold}</span>
<Badge intent={getStatusIntent(r.status)} size="sm">{r.status}</Badge>
</div>
))}
</div>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> (Human-in-the-loop)</div>
<p className="text-[10px] text-hint mb-3">· </p>
<table className="w-full text-[10px]">
<thead><tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2"> </th><th className="text-center py-2"></th>
<th className="text-left py-2"> </th><th className="text-center py-2">HITL</th><th className="text-center py-2"></th>
</tr></thead>
<tbody>{SENSITIVE_COMMANDS.map(c => (
<tr key={c.command} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2 px-2 text-heading font-medium">{c.command}</td>
<td className="py-2 text-center"><Badge intent={c.level === '높음' ? 'critical' : c.level === '중간' ? 'warning' : 'info'} size="sm">{c.level}</Badge></td>
<td className="py-2 text-muted-foreground">{c.approval}</td>
<td className="py-2 text-center">{c.hitl ? <Hand className="w-3.5 h-3.5 text-orange-400 mx-auto" /> : <span className="text-hint">-</span>}</td>
<td className="py-2 text-center"><Badge intent="success" size="sm">{c.status}</Badge></td>
</tr>
))}</tbody>
</table>
</CardContent></Card>
</div>
)}
{/* ── ④ Agent 신원·권한 ── */}
{tab === 'identity' && (
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> </div>
<div className="space-y-2">
{IDENTITY_POLICIES.map(p => (
<div key={p.policy} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
<Key className="w-4 h-4 text-orange-400" />
<div className="flex-1">
<div className="text-[11px] text-heading font-medium">{p.policy}</div>
<div className="text-[9px] text-hint">{p.desc}</div>
</div>
<Badge intent="success" size="sm">{p.status}</Badge>
</div>
))}
</div>
</CardContent></Card>
)}
{/* ── ⑤ MCP Tool 권한 ── */}
{tab === 'mcp' && (
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3">MCP Tool </div>
<p className="text-[10px] text-hint mb-3">MCP를 (Tool) (: 조회 Agent는 DB Update MCP Tool )</p>
<table className="w-full text-[10px]">
<thead><tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2">MCP Server</th><th className="text-center py-2">Tool </th>
<th className="text-center py-2"></th><th className="text-left py-2"></th><th className="text-center py-2"></th>
</tr></thead>
<tbody>{MCP_PERMISSIONS.map(m => (
<tr key={m.server} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2.5 px-2 text-heading font-mono text-[9px]">{m.server}</td>
<td className="py-2.5 text-center text-heading">{m.tools}</td>
<td className="py-2.5 text-center"><Badge intent="info" size="sm">{m.principle}</Badge></td>
<td className="py-2.5 text-hint text-[9px]">{m.detail}</td>
<td className="py-2.5 text-center"><Badge intent="success" size="sm">{m.status}</Badge></td>
</tr>
))}</tbody>
</table>
<div className="mt-4 p-3 bg-surface-overlay rounded-lg">
<div className="text-[10px] text-heading font-medium mb-1">Rate Limiting &amp; Caching</div>
<div className="text-[9px] text-hint">AI , MCP Rate Limiting( ) Caching() </div>
</div>
</CardContent></Card>
)}
{/* ── ⑥ 감사 로그 ── */}
{tab === 'audit' && (
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> (Audit Log)</div>
<p className="text-[10px] text-hint mb-3"> MCP Tool (User Request LLM MCP Client MCP Server Legacy) , A2A </p>
<table className="w-full text-[10px]">
<thead><tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2"></th><th className="text-left py-2"> </th>
<th className="text-center py-2">Agent</th><th className="text-left py-2">Tool</th><th className="text-center py-2"></th><th className="text-right py-2 px-2"></th>
</tr></thead>
<tbody>{AUDIT_LOG_SAMPLE.map(l => (
<tr key={l.time + l.tool} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2 px-2 text-muted-foreground font-mono">{l.time}</td>
<td className="py-2 text-hint text-[9px]">{l.chain}</td>
<td className="py-2 text-center text-heading">{l.agent}</td>
<td className="py-2 text-heading font-mono text-[9px]">{l.tool}</td>
<td className="py-2 text-center"><Badge intent={l.result === '차단' ? 'critical' : l.result.includes('승인') ? 'warning' : 'success'} size="sm">{l.result}</Badge></td>
<td className="py-2 px-2 text-right text-muted-foreground">{l.latency}</td>
</tr>
))}</tbody>
</table>
</CardContent></Card>
)}
</PageContainer>
);
}

파일 보기

@ -0,0 +1,351 @@
import { useState } from 'react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { getStatusIntent } from '@shared/constants/statusIntent';
import {
Shield, Database, Brain, Lock, Eye, Activity,
AlertTriangle, CheckCircle, XCircle, FileText,
Server, Layers, Settings, Search, RefreshCw,
} from 'lucide-react';
/*
* SER-10: AI
*
* AI :
* AI
*
*/
type Tab = 'overview' | 'data' | 'training' | 'io' | 'boundary' | 'vulnerability';
// ─── 보안 현황 KPI ──────────────────
const SECURITY_KPI = [
{ label: '데이터 수집 보안', value: '정상', score: 95, icon: Database, color: '#10b981' },
{ label: 'AI 학습 보안', value: '정상', score: 92, icon: Brain, color: '#3b82f6' },
{ label: '입출력 필터링', value: '정상', score: 98, icon: Lock, color: '#8b5cf6' },
{ label: '경계 보안', value: '주의', score: 85, icon: Server, color: '#f59e0b' },
{ label: '취약점 점검', value: '정상', score: 90, icon: Search, color: '#06b6d4' },
];
// ─── 데이터 수집 보안 ──────────────────
const DATA_SOURCES = [
{ name: 'AIS 원본 (SNPDB)', provider: '해경', trust: '인증', encryption: 'TLS 1.3', lastAudit: '2026-04-10', status: '정상' },
{ name: 'V-PASS 위치정보', provider: '해수부', trust: '인증', encryption: 'TLS 1.3', lastAudit: '2026-04-09', status: '정상' },
{ name: '기상 데이터', provider: '기상청', trust: '인증', encryption: 'TLS 1.2', lastAudit: '2026-04-08', status: '정상' },
{ name: '위성영상', provider: '해양조사원', trust: '인증', encryption: 'TLS 1.3', lastAudit: '2026-04-07', status: '정상' },
{ name: '법령·판례', provider: '법무부', trust: '인증', encryption: 'TLS 1.2', lastAudit: '2026-04-05', status: '정상' },
{ name: '단속 이력', provider: '해경', trust: '내부', encryption: 'AES-256', lastAudit: '2026-04-10', status: '정상' },
];
const CONTAMINATION_CHECKS = [
{ check: '오염데이터 탐지', desc: 'AI 학습·재학습 시 오염데이터 검사·관리', status: '활성', lastRun: '04-10 09:15' },
{ check: 'RAG 오염 차단', desc: '신규데이터 참조 시 오염데이터 유입 차단·방지', status: '활성', lastRun: '04-10 09:00' },
{ check: '출처 검증', desc: '공신력 있는 출처·배포 정보 구분 제출', status: '활성', lastRun: '04-10 08:30' },
{ check: '악성코드 사전검사', desc: '신뢰 출처 데이터라도 오염 가능, 사전 검사 필요', status: '활성', lastRun: '04-10 08:00' },
];
// ─── AI 학습 보안 ──────────────────
const TRAINING_POLICIES = [
{ policy: '보안등급별 데이터 분류', desc: 'AI시스템 활용목적 및 등급분류에 맞게 기밀·민감·공개등급 데이터 활용', status: '적용' },
{ policy: '사용자별 접근통제', desc: 'AI시스템이 사용자·부서별 권한에 맞는 학습데이터만 사용하도록 세분화', status: '적용' },
{ policy: '비인가자 접근 차단', desc: 'AI가 비인가자에게 기밀·민감등급 데이터를 제공하지 않도록 통제', status: '적용' },
{ policy: '저장소·DB 접근통제', desc: '보관된 학습데이터에 대한 사용자 접근통제', status: '적용' },
{ policy: '최소 접근권한 설계', desc: '사용자, 그룹, 데이터별로 최소 접근권한만 부여하도록 설계', status: '적용' },
{ policy: '다중 보안 인증', desc: '학습데이터 관리자 권한에 대해서는 다중 보안 인증 등 활용 방안 적용', status: '적용' },
{ policy: '오픈소스 모델 신뢰성', desc: '공신력 있는 출처·배포자가 제공하는 AI 모델·라이브러리 사용', status: '적용' },
];
// ─── 입출력 보안 ──────────────────
const IO_FILTERS = [
{ name: '입력 필터링', desc: '민감정보·적대적 공격 문구 포함여부 확인 및 차단', status: '활성', blocked: 23, total: 15420 },
{ name: '출력 필터링', desc: '응답 내 민감정보 노출 차단', status: '활성', blocked: 8, total: 15420 },
{ name: '입력 길이·형식 제한', desc: '공격용 프롬프트 과도 입력 방지', status: '활성', blocked: 5, total: 15420 },
{ name: '요청 속도 제한', desc: '호출 횟수, 동시 처리 요청수, 출력 용량 등 제한', status: '활성', blocked: 12, total: 15420 },
];
// ─── 경계 보안 ──────────────────
const BOUNDARY_ITEMS = [
{ item: 'DMZ·중계서버', desc: 'AI시스템에 접근하는 사용자·시스템 식별 및 통제', status: '적용' },
{ item: '인가 시스템 제한', desc: 'AI시스템이 인가된 내·외부 시스템·데이터만 활용하도록 제한', status: '적용' },
{ item: '권한 부여 제한', desc: 'AI시스템에 과도한 권한 부여 제한', status: '적용' },
{ item: '민감작업 승인절차', desc: '데이터 수정·시스템 제어 등 민감한 작업 수행 시 담당자 검토·승인', status: '적용' },
];
const EXPLAINABILITY = [
{ item: '추론 시각화', desc: '데이터 수정·시스템 제어 시 추론 과정·결과를 설명하거나 판단 근거 시각화', status: '구현' },
{ item: 'Feature Importance', desc: '모델 결정에 영향을 미친 주요 피처 표시', status: '구현' },
{ item: '판단 근거 제공', desc: '위험도 산출 시 기여 요인 설명', status: '구현' },
];
// ─── 취약점 점검 ──────────────────
const VULN_CHECKS = [
{ target: 'AI 모델 서빙 (PyTorch)', version: '2.4.1', lastScan: '2026-04-10', vulns: 0, status: '안전' },
{ target: 'FastAPI 서버', version: '0.115.0', lastScan: '2026-04-10', vulns: 0, status: '안전' },
{ target: 'LangChain (RAG)', version: '0.3.2', lastScan: '2026-04-09', vulns: 1, status: '주의' },
{ target: 'Milvus 벡터DB', version: '2.4.0', lastScan: '2026-04-09', vulns: 0, status: '안전' },
{ target: 'Spring Boot 백엔드', version: '3.4.1', lastScan: '2026-04-10', vulns: 0, status: '안전' },
{ target: 'Node.js (Vite)', version: '22.x', lastScan: '2026-04-10', vulns: 0, status: '안전' },
];
const RECOVERY_PLANS = [
{ plan: '모델 백업 저장소', desc: '이상행위 탐지 시 정상 모델·학습데이터 등으로 복원', status: '활성', detail: 'S3 버전별 백업 24개' },
{ plan: '버전정보 관리', desc: '모델·데이터·설정 버전 이력 추적', status: '활성', detail: 'Git + DVC 연동' },
{ plan: '자동 롤백', desc: '성능 저하 감지 시 이전 안정 버전으로 자동 복구', status: '활성', detail: '임계치 기반 트리거' },
];
export function AISecurityPage() {
const [tab, setTab] = useState<Tab>('overview');
return (
<PageContainer>
<PageHeader
icon={Shield}
iconColor="text-red-400"
title="AI 보안 관리"
description="SER-10 | AI 데이터 수집·학습·입출력·경계 보안 및 취약점 관리"
demo
/>
{/* 탭 */}
<div className="flex gap-0 border-b border-border">
{([
{ key: 'overview' as Tab, icon: Activity, label: '보안 현황' },
{ key: 'data' as Tab, icon: Database, label: '데이터 수집 보안' },
{ key: 'training' as Tab, icon: Brain, label: 'AI 학습 보안' },
{ key: 'io' as Tab, icon: Lock, label: '입출력 보안' },
{ key: 'boundary' as Tab, icon: Server, label: '경계 보안' },
{ key: 'vulnerability' as Tab, icon: Search, 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-red-400 border-red-400' : 'text-hint border-transparent hover:text-label'}`}>
<t.icon className="w-3.5 h-3.5" />{t.label}
</button>
))}
</div>
{/* ── ① 보안 현황 ── */}
{tab === 'overview' && (
<div className="space-y-3">
<div className="flex gap-2">
{SECURITY_KPI.map(k => (
<div key={k.label} className="flex-1 flex items-center gap-3 px-4 py-3 rounded-xl border border-border bg-card" style={{ borderLeftColor: k.color, borderLeftWidth: 3 }}>
<k.icon className="w-5 h-5" style={{ color: k.color }} />
<div>
<div className="flex items-center gap-2">
<span className="text-xl font-bold" style={{ color: k.color }}>{k.score}</span>
<span className="text-[9px] text-hint">/ 100</span>
</div>
<div className="text-[9px] text-hint">{k.label}</div>
</div>
</div>
))}
</div>
<div className="grid grid-cols-2 gap-3">
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> </div>
<div className="space-y-1.5 text-[10px]">
{[
['데이터 출처 인증', '6/6 소스 인증 완료', '완료'],
['오염데이터 검사', '4/4 검사 활성화', '완료'],
['학습데이터 접근통제', '7/7 정책 적용', '완료'],
['입출력 필터링', '4/4 필터 활성', '완료'],
['경계 보안 설정', '4/4 항목 적용', '완료'],
['취약점 점검', '5/6 안전 (1건 주의)', '주의'],
['복구 계획', '3/3 활성', '완료'],
].map(([k, v, s]) => (
<div key={k} className="flex items-center gap-2 px-2 py-1.5 bg-surface-overlay rounded">
{s === '완료' ? <CheckCircle className="w-3 h-3 text-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>
))}
</div>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> </div>
<div className="space-y-1.5 text-[10px]">
{[
['04-10 09:15', '오염데이터 검사 완료', '정상', '0건 탐지'],
['04-10 08:42', '입력 필터링 차단', '경고', '공격 패턴 1건 차단'],
['04-09 14:30', '취약점 스캔 완료', '주의', 'LangChain CVE-2026-1234'],
['04-09 10:00', '학습데이터 접근 감사', '정상', '비정상 접근 0건'],
['04-08 16:00', '모델 백업 완료', '정상', 'v2.1.0 → S3'],
['04-08 09:00', 'RAG 오염 차단 검사', '정상', '0건 탐지'],
].map(([time, event, level, detail]) => (
<div key={time + event} className="flex items-center gap-2 px-2 py-1.5 bg-surface-overlay rounded">
<span className="text-muted-foreground w-24">{time}</span>
<span className="text-heading flex-1">{event}</span>
<Badge intent={getStatusIntent(level)} size="sm">{level}</Badge>
<span className="text-hint text-[9px]">{detail}</span>
</div>
))}
</div>
</CardContent></Card>
</div>
</div>
)}
{/* ── ② 데이터 수집 보안 ── */}
{tab === 'data' && (
<div className="space-y-3">
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> </div>
<table className="w-full text-[10px]">
<thead><tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2"> </th><th className="text-left py-2"></th>
<th className="text-center py-2"></th><th className="text-center py-2"></th>
<th className="text-center py-2"> </th><th className="text-center py-2"></th>
</tr></thead>
<tbody>{DATA_SOURCES.map(d => (
<tr key={d.name} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2 px-2 text-heading font-medium">{d.name}</td>
<td className="py-2 text-muted-foreground">{d.provider}</td>
<td className="py-2 text-center"><Badge intent="success" size="sm">{d.trust}</Badge></td>
<td className="py-2 text-center text-muted-foreground">{d.encryption}</td>
<td className="py-2 text-center text-muted-foreground">{d.lastAudit}</td>
<td className="py-2 text-center"><Badge intent={getStatusIntent(d.status)} size="sm">{d.status}</Badge></td>
</tr>
))}</tbody>
</table>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> </div>
<div className="space-y-2">
{CONTAMINATION_CHECKS.map(c => (
<div key={c.check} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
<CheckCircle className="w-4 h-4 text-green-500" />
<div className="flex-1">
<div className="text-[11px] text-heading font-medium">{c.check}</div>
<div className="text-[9px] text-hint">{c.desc}</div>
</div>
<Badge intent={getStatusIntent(c.status)} size="sm">{c.status}</Badge>
<span className="text-[9px] text-muted-foreground">: {c.lastRun}</span>
</div>
))}
</div>
</CardContent></Card>
</div>
)}
{/* ── ③ AI 학습 보안 ── */}
{tab === 'training' && (
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> </div>
<div className="space-y-2">
{TRAINING_POLICIES.map(p => (
<div key={p.policy} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
<Lock className="w-4 h-4 text-blue-400" />
<div className="flex-1">
<div className="text-[11px] text-heading font-medium">{p.policy}</div>
<div className="text-[9px] text-hint">{p.desc}</div>
</div>
<Badge intent="success" size="sm">{p.status}</Badge>
</div>
))}
</div>
</CardContent></Card>
)}
{/* ── ④ 입출력 보안 ── */}
{tab === 'io' && (
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3">AI · </div>
<table className="w-full text-[10px]">
<thead><tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2"></th><th className="text-left py-2"></th>
<th className="text-center py-2"></th><th className="text-center py-2"></th><th className="text-center py-2"> </th>
<th className="text-center py-2"></th>
</tr></thead>
<tbody>{IO_FILTERS.map(f => (
<tr key={f.name} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2.5 px-2 text-heading font-medium">{f.name}</td>
<td className="py-2.5 text-hint text-[9px]">{f.desc}</td>
<td className="py-2.5 text-center"><Badge intent="success" size="sm">{f.status}</Badge></td>
<td className="py-2.5 text-center text-red-400 font-bold">{f.blocked}</td>
<td className="py-2.5 text-center text-muted-foreground">{f.total.toLocaleString()}</td>
<td className="py-2.5 text-center text-muted-foreground">{(f.blocked / f.total * 100).toFixed(2)}%</td>
</tr>
))}</tbody>
</table>
</CardContent></Card>
)}
{/* ── ⑤ 경계 보안 ── */}
{tab === 'boundary' && (
<div className="space-y-3">
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3">AI </div>
<div className="space-y-2">
{BOUNDARY_ITEMS.map(b => (
<div key={b.item} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
<Server className="w-4 h-4 text-yellow-400" />
<div className="flex-1">
<div className="text-[11px] text-heading font-medium">{b.item}</div>
<div className="text-[9px] text-hint">{b.desc}</div>
</div>
<Badge intent="success" size="sm">{b.status}</Badge>
</div>
))}
</div>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> AI (XAI)</div>
<div className="space-y-2">
{EXPLAINABILITY.map(e => (
<div key={e.item} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
<Eye className="w-4 h-4 text-purple-400" />
<div className="flex-1">
<div className="text-[11px] text-heading font-medium">{e.item}</div>
<div className="text-[9px] text-hint">{e.desc}</div>
</div>
<Badge intent="purple" size="sm">{e.status}</Badge>
</div>
))}
</div>
</CardContent></Card>
</div>
)}
{/* ── ⑥ 취약점 점검 ── */}
{tab === 'vulnerability' && (
<div className="space-y-3">
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3">AI · </div>
<table className="w-full text-[10px]">
<thead><tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2"></th><th className="text-center py-2"></th>
<th className="text-center py-2"> </th><th className="text-center py-2"></th><th className="text-center py-2"></th>
</tr></thead>
<tbody>{VULN_CHECKS.map(v => (
<tr key={v.target} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2 px-2 text-heading font-medium">{v.target}</td>
<td className="py-2 text-center text-muted-foreground font-mono">{v.version}</td>
<td className="py-2 text-center text-muted-foreground">{v.lastScan}</td>
<td className="py-2 text-center">{v.vulns > 0 ? <span className="text-yellow-400 font-bold">{v.vulns}</span> : <span className="text-green-400">0</span>}</td>
<td className="py-2 text-center"><Badge intent={getStatusIntent(v.status)} size="sm">{v.status}</Badge></td>
</tr>
))}</tbody>
</table>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3">AI </div>
<div className="space-y-2">
{RECOVERY_PLANS.map(r => (
<div key={r.plan} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
<RefreshCw className="w-4 h-4 text-cyan-400" />
<div className="flex-1">
<div className="text-[11px] text-heading font-medium">{r.plan}</div>
<div className="text-[9px] text-hint">{r.desc}</div>
</div>
<span className="text-[9px] text-muted-foreground">{r.detail}</span>
<Badge intent={getStatusIntent(r.status)} size="sm">{r.status}</Badge>
</div>
))}
</div>
</CardContent></Card>
</div>
)}
</PageContainer>
);
}

파일 보기

@ -4,6 +4,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button'; import { Button } from '@shared/components/ui/button';
import { PageContainer, PageHeader } from '@shared/components/layout'; import { PageContainer, PageHeader } from '@shared/components/layout';
import { useAuth } from '@/app/auth/AuthContext';
import type { BadgeIntent } from '@lib/theme/variants'; import type { BadgeIntent } from '@lib/theme/variants';
import { import {
Bell, Plus, Edit2, Trash2, Eye, EyeOff, Calendar, Bell, Plus, Edit2, Trash2, Eye, EyeOff, Calendar,
@ -74,6 +75,10 @@ const ROLE_OPTIONS = ['ADMIN', 'OPERATOR', 'ANALYST', 'FIELD', 'VIEWER'];
export function NoticeManagement() { export function NoticeManagement() {
const { t } = useTranslation('admin'); const { t } = useTranslation('admin');
const { hasPermission } = useAuth();
const canCreate = hasPermission('admin:notices', 'CREATE');
const canUpdate = hasPermission('admin:notices', 'UPDATE');
const canDelete = hasPermission('admin:notices', 'DELETE');
const [notices, setNotices] = useState<SystemNotice[]>(INITIAL_NOTICES); const [notices, setNotices] = useState<SystemNotice[]>(INITIAL_NOTICES);
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
@ -146,7 +151,7 @@ export function NoticeManagement() {
description={t('notices.desc')} description={t('notices.desc')}
demo demo
actions={ actions={
<Button variant="primary" size="md" onClick={openNew} icon={<Plus className="w-3.5 h-3.5" />}> <Button variant="primary" size="md" onClick={openNew} disabled={!canCreate} title={!canCreate ? '등록 권한이 필요합니다' : undefined} icon={<Plus className="w-3.5 h-3.5" />}>
</Button> </Button>
} }
@ -237,10 +242,10 @@ export function NoticeManagement() {
</td> </td>
<td className="px-1 py-1.5"> <td className="px-1 py-1.5">
<div className="flex items-center justify-center gap-0.5"> <div className="flex items-center justify-center gap-0.5">
<button type="button" onClick={() => openEdit(n)} className="p-1 text-hint hover:text-blue-400" title="수정"> <button type="button" onClick={() => openEdit(n)} disabled={!canUpdate} className="p-1 text-hint hover:text-blue-400 disabled:opacity-30 disabled:cursor-not-allowed" title={canUpdate ? '수정' : '수정 권한이 필요합니다'}>
<Edit2 className="w-3 h-3" /> <Edit2 className="w-3 h-3" />
</button> </button>
<button type="button" onClick={() => handleDelete(n.id)} className="p-1 text-hint hover:text-red-400" title="삭제"> <button type="button" onClick={() => handleDelete(n.id)} disabled={!canDelete} className="p-1 text-hint hover:text-red-400 disabled:opacity-30 disabled:cursor-not-allowed" title={canDelete ? '삭제' : '삭제 권한이 필요합니다'}>
<Trash2 className="w-3 h-3" /> <Trash2 className="w-3 h-3" />
</button> </button>
</div> </div>
@ -414,7 +419,7 @@ export function NoticeManagement() {
<SaveButton <SaveButton
onClick={handleSave} onClick={handleSave}
label={editingId ? '수정' : '등록'} label={editingId ? '수정' : '등록'}
disabled={!form.title.trim() || !form.message.trim()} disabled={!form.title.trim() || !form.message.trim() || (editingId ? !canUpdate : !canCreate)}
/> />
</div> </div>
</div> </div>

파일 보기

@ -3,3 +3,5 @@ export { SystemConfig } from './SystemConfig';
export { NoticeManagement } from './NoticeManagement'; export { NoticeManagement } from './NoticeManagement';
export { AdminPanel } from './AdminPanel'; export { AdminPanel } from './AdminPanel';
export { DataHub } from './DataHub'; export { DataHub } from './DataHub';
export { AISecurityPage } from './AISecurityPage';
export { AIAgentSecurityPage } from './AIAgentSecurityPage';

파일 보기

@ -0,0 +1,431 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { getStatusIntent } from '@shared/constants/statusIntent';
import {
Brain, Database, GitBranch, Activity, Server, Shield,
Settings, Layers, BarChart3, Code, Play,
Zap, FlaskConical, CheckCircle,
Terminal, RefreshCw, Box,
} from 'lucide-react';
/*
* LGCNS MLOps
*
* LGCNS DAP(Data AI Platform) MLOps :
* Job
* Repository
*/
type Tab = 'project' | 'environment' | 'model' | 'job' | 'common' | 'monitoring' | 'repository';
// ─── 프로젝트 관리 ──────────────────
const PROJECTS = [
{ id: 'PRJ-001', name: '불법조업 위험도 예측', owner: '분석팀', status: '활성', models: 5, experiments: 12, updated: '2026-04-10' },
{ id: 'PRJ-002', name: '경비함정 경로추천', owner: '운항팀', status: '활성', models: 3, experiments: 8, updated: '2026-04-09' },
{ id: 'PRJ-003', name: '다크베셀 탐지', owner: '분석팀', status: '활성', models: 2, experiments: 6, updated: '2026-04-08' },
{ id: 'PRJ-004', name: '환적 네트워크 분석', owner: '수사팀', status: '대기', models: 1, experiments: 3, updated: '2026-04-05' },
];
// ─── 분석환경 ──────────────────
const ENVIRONMENTS = [
{ name: 'Jupyter Notebook', icon: Code, type: 'IDE', gpu: 'Blackwell x1', status: '실행중', user: '김분석', created: '04-10' },
{ name: 'RStudio Server', icon: BarChart3, type: 'IDE', gpu: '-', status: '중지', user: '이연구', created: '04-08' },
{ name: 'VS Code Server', icon: Terminal, type: 'IDE', gpu: 'H200 x1', status: '실행중', user: '박개발', created: '04-09' },
{ name: 'TensorBoard', icon: Activity, type: '모니터링', gpu: '-', status: '실행중', user: '김분석', created: '04-10' },
];
const WORKFLOWS = [
{ id: 'WF-012', name: 'AIS 전처리 → LSTM 학습', steps: 5, status: 'running', progress: 60, duration: '2h 15m' },
{ id: 'WF-011', name: '어구분류 피처엔지니어링', steps: 3, status: 'done', progress: 100, duration: '45m' },
{ id: 'WF-010', name: 'GNN 환적탐지 학습', steps: 4, status: 'done', progress: 100, duration: '3h 20m' },
];
// ─── 모델 관리 ──────────────────
const MODELS = [
{ name: '불법조업 위험도 v2.1', framework: 'PyTorch', status: 'DEPLOYED', accuracy: 93.2, version: 'v2.1.0', kpi: 'F1=92.3%', endpoint: '/v1/infer/risk' },
{ name: '경비함정 경로추천 v1.5', framework: 'TensorFlow', status: 'DEPLOYED', accuracy: 89.7, version: 'v1.5.2', kpi: 'F1=88.4%', endpoint: '/v1/infer/patrol' },
{ name: 'Transformer 궤적 v0.9', framework: 'PyTorch', status: 'APPROVED', accuracy: 91.2, version: 'v0.9.0', kpi: 'F1=90.5%', endpoint: '-' },
{ name: 'GNN 환적탐지 v0.3', framework: 'DGL', status: 'TESTING', accuracy: 82.3, version: 'v0.3.0', kpi: 'F1=80.1%', endpoint: '-' },
];
const PARAMETERS = [
{ name: 'learning_rate', type: 'float', default: '0.001', range: '1e-5 ~ 0.1' },
{ name: 'batch_size', type: 'int', default: '64', range: '16 ~ 256' },
{ name: 'epochs', type: 'int', default: '50', range: '10 ~ 200' },
{ name: 'dropout', type: 'float', default: '0.2', range: '0.0 ~ 0.5' },
{ name: 'hidden_dim', type: 'int', default: '256', range: '64 ~ 1024' },
];
// ─── Job 실행 관리 ──────────────────
const JOBS = [
{ id: 'JOB-088', name: 'LSTM 위험도 학습', type: '학습', resource: 'Blackwell x2', status: 'running', progress: 72, started: '04-10 08:00', elapsed: '3h 28m' },
{ id: 'JOB-087', name: 'AIS 피처 추출', type: '전처리', resource: 'CPU 16core', status: 'done', progress: 100, started: '04-10 06:00', elapsed: '1h 45m' },
{ id: 'JOB-086', name: 'GNN 하이퍼파라미터 탐색', type: 'HPS', resource: 'H200 x2', status: 'running', progress: 45, started: '04-10 07:30', elapsed: '2h 10m' },
{ id: 'JOB-085', name: '위험도 모델 배포', type: '배포', resource: 'Blackwell x1', status: 'done', progress: 100, started: '04-09 14:00', elapsed: '15m' },
{ id: 'JOB-084', name: 'SAR 이미지 전처리', type: '전처리', resource: 'CPU 32core', status: 'fail', progress: 34, started: '04-09 10:00', elapsed: '0h 50m' },
];
const PIPELINE_STAGES = [
{ name: '데이터 수집', status: 'done' },
{ name: '전처리', status: 'done' },
{ name: '피처 엔지니어링', status: 'done' },
{ name: '모델 학습', status: 'running' },
{ name: '모델 평가', status: 'pending' },
{ name: '모델 배포', status: 'pending' },
];
// ─── 공통서비스 ──────────────────
const COMMON_SERVICES = [
{ name: 'Feature Store', icon: Database, desc: '피처 저장소', status: '정상', version: 'v3.2', detail: '20개 피처 · 2.4TB' },
{ name: 'Model Registry', icon: GitBranch, desc: '모델 레지스트리', status: '정상', version: 'v2.1', detail: '12개 모델 등록' },
{ name: 'Data Catalog', icon: Layers, desc: '데이터 카탈로그', status: '정상', version: 'v1.5', detail: '48 테이블 · 1.2M rows' },
{ name: 'Experiment Tracker', icon: FlaskConical, desc: '실험 추적', status: '정상', version: 'v4.0', detail: '42개 실험 기록' },
{ name: 'API Gateway', icon: Zap, desc: 'API 게이트웨이', status: '정상', version: 'v3.0', detail: '221 req/s' },
{ name: 'Security Manager', icon: Shield, desc: '보안 관리', status: '정상', version: 'v2.0', detail: 'RBAC + JWT' },
];
// ─── 모니터링 ──────────────────
const GPU_RESOURCES = [
{ name: 'Blackwell #1', usage: 78, mem: '38/48GB', temp: '62°C', job: 'JOB-088' },
{ name: 'Blackwell #2', usage: 52, mem: '25/48GB', temp: '55°C', job: 'JOB-088' },
{ name: 'H200 #1', usage: 85, mem: '68/80GB', temp: '71°C', job: 'JOB-086' },
{ name: 'H200 #2', usage: 45, mem: '36/80GB', temp: '48°C', job: '-' },
];
const SYSTEM_METRICS = [
{ label: '실행중 Job', value: '3', color: '#3b82f6' },
{ label: '대기중 Job', value: '2', color: '#f59e0b' },
{ label: 'GPU 사용률', value: '65%', color: '#10b981' },
{ label: '배포 모델', value: '2', color: '#8b5cf6' },
{ label: 'API 호출/s', value: '221', color: '#06b6d4' },
];
export function LGCNSMLOpsPage() {
const { t } = useTranslation('ai');
const [tab, setTab] = useState<Tab>('project');
return (
<PageContainer>
<PageHeader
icon={Box}
iconColor="text-cyan-400"
title={t('lgcnsMlops.title')}
description={t('lgcnsMlops.desc')}
demo
/>
{/* 탭 */}
<div className="flex gap-0 border-b border-border">
{([
{ key: 'project' as Tab, icon: Layers, label: '프로젝트 관리' },
{ key: 'environment' as Tab, icon: Terminal, label: '분석환경 관리' },
{ key: 'model' as Tab, icon: Brain, label: '모델 관리' },
{ key: 'job' as Tab, icon: Play, label: 'Job 실행 관리' },
{ key: 'common' as Tab, icon: Settings, label: '공통서비스' },
{ key: 'monitoring' as Tab, icon: Activity, label: '모니터링' },
{ key: 'repository' as Tab, icon: Database, label: 'Repository' },
]).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-cyan-400 border-cyan-400' : 'text-hint border-transparent hover:text-label'}`}>
<t.icon className="w-3.5 h-3.5" />{t.label}
</button>
))}
</div>
{/* ── ① 프로젝트 관리 ── */}
{tab === 'project' && (
<div className="space-y-3">
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> </div>
<table className="w-full text-[10px]">
<thead><tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2">ID</th><th className="text-left py-2"></th><th className="text-left py-2"></th>
<th className="text-center py-2"></th><th className="text-center py-2"></th><th className="text-center py-2"></th><th className="text-right py-2 px-2"></th>
</tr></thead>
<tbody>{PROJECTS.map(p => (
<tr key={p.id} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2 px-2 text-muted-foreground">{p.id}</td>
<td className="py-2 text-heading font-medium">{p.name}</td>
<td className="py-2 text-muted-foreground">{p.owner}</td>
<td className="py-2 text-center"><Badge intent={getStatusIntent(p.status)} size="sm">{p.status}</Badge></td>
<td className="py-2 text-center text-heading">{p.models}</td>
<td className="py-2 text-center text-heading">{p.experiments}</td>
<td className="py-2 px-2 text-right text-muted-foreground">{p.updated}</td>
</tr>
))}</tbody>
</table>
</CardContent></Card>
<div className="grid grid-cols-4 gap-2">
{[
{ label: '활성 프로젝트', value: 3, icon: Layers, color: '#3b82f6' },
{ label: '총 모델', value: 11, icon: Brain, color: '#8b5cf6' },
{ label: '총 실험', value: 29, icon: FlaskConical, color: '#10b981' },
{ label: '참여 인원', value: 8, icon: Server, color: '#f59e0b' },
].map(k => (
<div key={k.label} className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border bg-card" style={{ borderLeftColor: k.color, borderLeftWidth: 3 }}>
<k.icon className="w-5 h-5" style={{ color: k.color }} />
<div><div className="text-xl font-bold" style={{ color: k.color }}>{k.value}</div><div className="text-[9px] text-hint">{k.label}</div></div>
</div>
))}
</div>
</div>
)}
{/* ── ② 분석환경 관리 ── */}
{tab === 'environment' && (
<div className="space-y-3">
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> </div>
<div className="space-y-2">
{ENVIRONMENTS.map(e => (
<div key={e.name} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
<e.icon className="w-4 h-4 text-cyan-400" />
<span className="text-[11px] text-heading font-medium w-36">{e.name}</span>
<Badge intent="muted" size="sm">{e.type}</Badge>
<span className="text-[10px] text-muted-foreground w-24">GPU: {e.gpu}</span>
<span className="text-[10px] text-muted-foreground flex-1">: {e.user}</span>
<Badge intent={getStatusIntent(e.status)} size="sm">{e.status}</Badge>
</div>
))}
</div>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"></div>
<div className="space-y-2">
{WORKFLOWS.map(w => (
<div key={w.id} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
<span className="text-[10px] text-muted-foreground w-16">{w.id}</span>
<span className="text-[11px] text-heading font-medium flex-1">{w.name}</span>
<span className="text-[10px] text-muted-foreground">Steps: {w.steps}</span>
<div className="w-20 h-1.5 bg-switch-background rounded-full overflow-hidden">
<div className={`h-full rounded-full ${w.status === 'running' ? 'bg-blue-500' : w.status === 'done' ? 'bg-green-500' : 'bg-red-500'}`} style={{ width: `${w.progress}%` }} />
</div>
<span className="text-[10px] text-muted-foreground">{w.progress}%</span>
<Badge intent={getStatusIntent(w.status === 'running' ? '실행중' : w.status === 'done' ? '완료' : '실패')} size="sm">
{w.status === 'running' ? '실행중' : w.status === 'done' ? '완료' : '실패'}
</Badge>
</div>
))}
</div>
</CardContent></Card>
</div>
)}
{/* ── ③ 모델 관리 ── */}
{tab === 'model' && (
<div className="space-y-3">
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> </div>
<table className="w-full text-[10px]">
<thead><tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2"></th><th className="text-left py-2"></th><th className="text-center py-2"></th>
<th className="text-center py-2"></th><th className="text-center py-2">KPI</th><th className="text-left py-2"></th><th className="text-left py-2 px-2">Endpoint</th>
</tr></thead>
<tbody>{MODELS.map(m => (
<tr key={m.name} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2 px-2 text-heading font-medium">{m.name}</td>
<td className="py-2 text-muted-foreground">{m.framework}</td>
<td className="py-2 text-center"><Badge intent={getStatusIntent(m.status === 'DEPLOYED' ? '활성' : m.status === 'APPROVED' ? '승인' : m.status === 'TESTING' ? '테스트' : '대기')} size="sm">{m.status}</Badge></td>
<td className="py-2 text-center text-heading font-bold">{m.accuracy}%</td>
<td className="py-2 text-center text-green-400">{m.kpi}</td>
<td className="py-2 text-muted-foreground">{m.version}</td>
<td className="py-2 px-2 text-muted-foreground font-mono text-[9px]">{m.endpoint}</td>
</tr>
))}</tbody>
</table>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3">Parameter </div>
<table className="w-full text-[10px]">
<thead><tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2"></th><th className="text-left py-2"></th><th className="text-center py-2"></th><th className="text-left py-2 px-2"></th>
</tr></thead>
<tbody>{PARAMETERS.map(p => (
<tr key={p.name} className="border-b border-border/50">
<td className="py-1.5 px-2 text-heading font-mono">{p.name}</td>
<td className="py-1.5 text-muted-foreground">{p.type}</td>
<td className="py-1.5 text-center text-heading">{p.default}</td>
<td className="py-1.5 px-2 text-muted-foreground">{p.range}</td>
</tr>
))}</tbody>
</table>
</CardContent></Card>
</div>
)}
{/* ── ④ Job 실행 관리 ── */}
{tab === 'job' && (
<div className="space-y-3">
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3">Job Pipeline</div>
<div className="flex items-center gap-1 mb-4 px-2">
{PIPELINE_STAGES.map((s, i) => (
<div key={s.name} className="flex items-center gap-1 flex-1">
<div className={`flex-1 py-2 px-3 rounded-lg text-center text-[10px] font-medium ${
s.status === 'done' ? 'bg-green-500/15 text-green-400 border border-green-500/30' :
s.status === 'running' ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30 animate-pulse' :
'bg-surface-overlay text-hint border border-border'
}`}>
{s.status === 'done' && <CheckCircle className="w-3 h-3 inline mr-1" />}
{s.status === 'running' && <RefreshCw className="w-3 h-3 inline mr-1 animate-spin" />}
{s.name}
</div>
{i < PIPELINE_STAGES.length - 1 && <span className="text-hint text-[10px]"></span>}
</div>
))}
</div>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3">Job </div>
<table className="w-full text-[10px]">
<thead><tr className="text-hint border-b border-border">
<th className="text-left py-2 px-2">ID</th><th className="text-left py-2">Job명</th><th className="text-center py-2"></th>
<th className="text-left py-2"></th><th className="text-center py-2"></th><th className="text-center py-2"></th><th className="text-right py-2 px-2"></th>
</tr></thead>
<tbody>{JOBS.map(j => (
<tr key={j.id} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2 px-2 text-muted-foreground">{j.id}</td>
<td className="py-2 text-heading font-medium">{j.name}</td>
<td className="py-2 text-center"><Badge intent="muted" size="sm">{j.type}</Badge></td>
<td className="py-2 text-muted-foreground text-[9px]">{j.resource}</td>
<td className="py-2">
<div className="flex items-center gap-2 justify-center">
<div className="w-16 h-1.5 bg-switch-background rounded-full overflow-hidden">
<div className={`h-full rounded-full ${j.status === 'running' ? 'bg-blue-500' : j.status === 'done' ? 'bg-green-500' : 'bg-red-500'}`} style={{ width: `${j.progress}%` }} />
</div>
<span className="text-heading">{j.progress}%</span>
</div>
</td>
<td className="py-2 text-center">
<Badge intent={getStatusIntent(j.status === 'running' ? '실행중' : j.status === 'done' ? '완료' : '실패')} size="sm">
{j.status === 'running' ? '실행중' : j.status === 'done' ? '완료' : '실패'}
</Badge>
</td>
<td className="py-2 px-2 text-right text-muted-foreground">{j.elapsed}</td>
</tr>
))}</tbody>
</table>
</CardContent></Card>
</div>
)}
{/* ── ⑤ 공통서비스 ── */}
{tab === 'common' && (
<div className="grid grid-cols-3 gap-3">
{COMMON_SERVICES.map(s => (
<Card key={s.name}><CardContent className="p-4">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 rounded-lg bg-cyan-500/10">
<s.icon className="w-5 h-5 text-cyan-400" />
</div>
<div>
<div className="text-[11px] font-bold text-heading">{s.name}</div>
<div className="text-[9px] text-hint">{s.desc}</div>
</div>
<Badge intent={getStatusIntent(s.status)} size="sm" className="ml-auto">{s.status}</Badge>
</div>
<div className="space-y-1 text-[10px]">
<div className="flex justify-between px-2 py-1 bg-surface-overlay rounded">
<span className="text-hint"></span><span className="text-label">{s.version}</span>
</div>
<div className="flex justify-between px-2 py-1 bg-surface-overlay rounded">
<span className="text-hint"></span><span className="text-label">{s.detail}</span>
</div>
</div>
</CardContent></Card>
))}
</div>
)}
{/* ── ⑥ 모니터링 ── */}
{tab === 'monitoring' && (
<div className="space-y-3">
<div className="flex gap-2">
{SYSTEM_METRICS.map(k => (
<div key={k.label} className="flex-1 flex items-center gap-3 px-4 py-3 rounded-xl border border-border bg-card" style={{ borderLeftColor: k.color, borderLeftWidth: 3 }}>
<div><div className="text-xl font-bold" style={{ color: k.color }}>{k.value}</div><div className="text-[9px] text-hint">{k.label}</div></div>
</div>
))}
</div>
<div className="grid grid-cols-2 gap-3">
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3">GPU </div>
<div className="space-y-2">
{GPU_RESOURCES.map(g => (
<div key={g.name} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
<span className="text-[10px] text-heading font-medium w-24">{g.name}</span>
<div className="flex-1 h-2 bg-switch-background rounded-full overflow-hidden">
<div className={`h-full rounded-full ${g.usage > 80 ? 'bg-red-500' : g.usage > 60 ? 'bg-yellow-500' : 'bg-green-500'}`} style={{ width: `${g.usage}%` }} />
</div>
<span className="text-[10px] text-heading font-bold w-8">{g.usage}%</span>
<span className="text-[9px] text-hint">{g.mem}</span>
<span className="text-[9px] text-hint">{g.temp}</span>
<span className="text-[9px] text-muted-foreground">{g.job}</span>
</div>
))}
</div>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3"> </div>
<div className="space-y-2">
{[
{ name: 'DAP API Gateway', status: 'ok', rps: 221 },
{ name: 'Model Serving', status: 'ok', rps: 186 },
{ name: 'Feature Store', status: 'ok', rps: 45 },
{ name: 'Experiment Tracker', status: 'ok', rps: 32 },
{ name: 'Job Scheduler', status: 'ok', rps: 15 },
{ name: 'PostgreSQL', status: 'ok', rps: 890 },
].map(s => (
<div key={s.name} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
<div className={`w-2 h-2 rounded-full ${s.status === 'ok' ? 'bg-green-500 shadow-[0_0_4px_#22c55e]' : 'bg-yellow-500 shadow-[0_0_4px_#eab308]'}`} />
<span className="text-[10px] text-heading font-medium flex-1">{s.name}</span>
<span className="text-[10px] text-muted-foreground">{s.rps} req/s</span>
</div>
))}
</div>
</CardContent></Card>
</div>
</div>
)}
{/* ── ⑦ Repository ── */}
{tab === 'repository' && (
<div className="grid grid-cols-2 gap-3">
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3">/ Repository</div>
<div className="space-y-1.5 text-[10px]">
{[
['kcg-ai-monitoring', 'frontend + backend + prediction 모노레포'],
['kcg-ml-models', '모델 아카이브 (버전별 weight)'],
['kcg-data-pipeline', 'ETL 스크립트 + Airflow DAG'],
['kcg-feature-store', '피처 정의 + 변환 로직'],
].map(([k, v]) => (
<div key={k} className="flex justify-between px-2 py-1.5 bg-surface-overlay rounded">
<span className="text-heading font-mono">{k}</span><span className="text-hint">{v}</span>
</div>
))}
</div>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3">/ Repository</div>
<div className="space-y-1.5 text-[10px]">
{[
['AIS 원본 데이터', 'SNPDB · 5분 주기 증분 · 1.2TB'],
['학습 데이터셋', '1,456,200건 · 04-03 갱신'],
['벡터 DB (Milvus)', '1.2M 문서 · 3.6M 벡터'],
['모델 Artifact', 'S3 · 24개 버전 · 12.8GB'],
].map(([k, v]) => (
<div key={k} className="flex justify-between px-2 py-1.5 bg-surface-overlay rounded">
<span className="text-heading">{k}</span><span className="text-hint">{v}</span>
</div>
))}
</div>
</CardContent></Card>
</div>
)}
</PageContainer>
);
}

파일 보기

@ -1,4 +1,5 @@
export { AIModelManagement } from './AIModelManagement'; export { AIModelManagement } from './AIModelManagement';
export { MLOpsPage } from './MLOpsPage'; export { MLOpsPage } from './MLOpsPage';
export { LGCNSMLOpsPage } from './LGCNSMLOpsPage';
export { AIAssistant } from './AIAssistant'; export { AIAssistant } from './AIAssistant';
export { LLMOpsPage } from './LLMOpsPage'; export { LLMOpsPage } from './LLMOpsPage';

파일 보기

@ -1,6 +1,6 @@
import { useState, useEffect, useMemo, useRef, useCallback, memo } from 'react'; import { useState, useEffect, useMemo, useRef, useCallback, memo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createHeatmapLayer, useMapLayers, type MapHandle } from '@lib/map'; import { BaseMap, createStaticLayers, createMarkerLayer, createHeatmapLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { HeatPoint, MarkerData } from '@lib/map'; import type { HeatPoint, MarkerData } from '@lib/map';
import { import {
AlertTriangle, Ship, Anchor, Eye, Navigation, AlertTriangle, Ship, Anchor, Eye, Navigation,
@ -187,7 +187,7 @@ function SeaAreaMap() {
const mapRef = useRef<MapHandle>(null); const mapRef = useRef<MapHandle>(null);
const buildLayers = useCallback(() => [ const buildLayers = useCallback(() => [
...STATIC_LAYERS, ...createStaticLayers(),
createHeatmapLayer('threat-heat', THREAT_HEAT as HeatPoint[], { radiusPixels: 22 }), createHeatmapLayer('threat-heat', THREAT_HEAT as HeatPoint[], { radiusPixels: 22 }),
createMarkerLayer('threat-markers', THREAT_MARKERS), createMarkerLayer('threat-markers', THREAT_MARKERS),
], []); ], []);

파일 보기

@ -7,7 +7,7 @@ import { Select } from '@shared/components/ui/select';
import { PageContainer, PageHeader } from '@shared/components/layout'; import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { EyeOff, AlertTriangle, Loader2, Filter } from 'lucide-react'; import { EyeOff, AlertTriangle, Loader2, Filter } from 'lucide-react';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; import { BaseMap, createStaticLayers, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { MarkerData } from '@lib/map'; import type { MarkerData } from '@lib/map';
import { getDarkVessels, type VesselAnalysis } from '@/services/analysisApi'; import { getDarkVessels, type VesselAnalysis } from '@/services/analysisApi';
import { formatDateTime } from '@shared/utils/dateFormat'; import { formatDateTime } from '@shared/utils/dateFormat';
@ -170,7 +170,7 @@ export function DarkVesselDetection() {
const mapRef = useRef<MapHandle>(null); const mapRef = useRef<MapHandle>(null);
const buildLayers = useCallback(() => [ const buildLayers = useCallback(() => [
...STATIC_LAYERS, ...createStaticLayers(),
createRadiusLayer( createRadiusLayer(
'dv-radius', 'dv-radius',
DATA.filter((d) => d.darkScore >= 70).map((d) => ({ DATA.filter((d) => d.darkScore >= 70).map((d) => ({

파일 보기

@ -5,7 +5,7 @@ import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout'; import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { Anchor, AlertTriangle, Loader2 } from 'lucide-react'; import { Anchor, AlertTriangle, Loader2 } from 'lucide-react';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; import { BaseMap, createStaticLayers, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { MarkerData } from '@lib/map'; import type { MarkerData } from '@lib/map';
import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi'; import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi';
import { formatDate } from '@shared/utils/dateFormat'; import { formatDate } from '@shared/utils/dateFormat';
@ -106,7 +106,7 @@ export function GearDetection() {
const mapRef = useRef<MapHandle>(null); const mapRef = useRef<MapHandle>(null);
const buildLayers = useCallback(() => [ const buildLayers = useCallback(() => [
...STATIC_LAYERS, ...createStaticLayers(),
createRadiusLayer( createRadiusLayer(
'gear-radius', 'gear-radius',
DATA.filter(g => g.risk === '고위험').map(g => ({ DATA.filter(g => g.risk === '고위험').map(g => ({

파일 보기

@ -20,6 +20,7 @@ import { type AlertLevel as AlertLevelType, getAlertLevelLabel, getAlertLevelInt
import { getEventStatusIntent, getEventStatusLabel } from '@shared/constants/eventStatuses'; import { getEventStatusIntent, getEventStatusLabel } from '@shared/constants/eventStatuses';
import { getViolationLabel, getViolationIntent } from '@shared/constants/violationTypes'; import { getViolationLabel, getViolationIntent } from '@shared/constants/violationTypes';
import { useSettingsStore } from '@stores/settingsStore'; import { useSettingsStore } from '@stores/settingsStore';
import { useAuth } from '@/app/auth/AuthContext';
/* /*
* SFR-02 * SFR-02
@ -51,6 +52,9 @@ export function EventList() {
const { t: tc } = useTranslation('common'); const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language); const lang = useSettingsStore((s) => s.language);
const navigate = useNavigate(); const navigate = useNavigate();
const { hasPermission } = useAuth();
const canAck = hasPermission('enforcement:event-list', 'UPDATE');
const canCreateEnforcement = hasPermission('enforcement:enforcement-history', 'CREATE');
const { const {
events: storeEvents, events: storeEvents,
rawEvents, rawEvents,
@ -167,9 +171,9 @@ export function EventList() {
return ( return (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{isNew && ( {isNew && (
<button type="button" aria-label="확인" title="확인(ACK)" <button type="button" aria-label="확인" title={canAck ? '확인(ACK)' : '확인 권한이 필요합니다'}
className="p-0.5 rounded hover:bg-blue-500/20 text-blue-400 disabled:opacity-30" className="p-0.5 rounded hover:bg-blue-500/20 text-blue-400 disabled:opacity-30 disabled:cursor-not-allowed"
disabled={busy} onClick={(e) => { e.stopPropagation(); handleAck(eid); }}> disabled={busy || !canAck} onClick={(e) => { e.stopPropagation(); handleAck(eid); }}>
<CheckCircle className="w-3.5 h-3.5" /> <CheckCircle className="w-3.5 h-3.5" />
</button> </button>
)} )}
@ -180,14 +184,14 @@ export function EventList() {
</button> </button>
{isActionable && ( {isActionable && (
<> <>
<button type="button" aria-label="단속 등록" title="단속 등록" <button type="button" aria-label="단속 등록" title={canCreateEnforcement ? '단속 등록' : '단속 등록 권한이 필요합니다'}
className="p-0.5 rounded hover:bg-green-500/20 text-green-400 disabled:opacity-30" className="p-0.5 rounded hover:bg-green-500/20 text-green-400 disabled:opacity-30 disabled:cursor-not-allowed"
disabled={busy} onClick={(e) => { e.stopPropagation(); handleCreateEnforcement(row); }}> disabled={busy || !canCreateEnforcement} onClick={(e) => { e.stopPropagation(); handleCreateEnforcement(row); }}>
<Shield className="w-3.5 h-3.5" /> <Shield className="w-3.5 h-3.5" />
</button> </button>
<button type="button" aria-label="오탐 처리" title="오탐 처리" <button type="button" aria-label="오탐 처리" title={canAck ? '오탐 처리' : '상태 변경 권한이 필요합니다'}
className="p-0.5 rounded hover:bg-red-500/20 text-red-400 disabled:opacity-30" className="p-0.5 rounded hover:bg-red-500/20 text-red-400 disabled:opacity-30 disabled:cursor-not-allowed"
disabled={busy} onClick={(e) => { e.stopPropagation(); handleFalsePositive(eid); }}> disabled={busy || !canAck} onClick={(e) => { e.stopPropagation(); handleFalsePositive(eid); }}>
<Ban className="w-3.5 h-3.5" /> <Ban className="w-3.5 h-3.5" />
</button> </button>
</> </>
@ -196,7 +200,7 @@ export function EventList() {
); );
}, },
}, },
], [tc, lang, actionLoading, handleAck, handleFalsePositive, handleCreateEnforcement, navigate]); ], [tc, lang, actionLoading, handleAck, handleFalsePositive, handleCreateEnforcement, navigate, canAck, canCreateEnforcement]);
const [levelFilter, setLevelFilter] = useState<string>(''); const [levelFilter, setLevelFilter] = useState<string>('');
const [showUpload, setShowUpload] = useState(false); const [showUpload, setShowUpload] = useState(false);

파일 보기

@ -1,6 +1,6 @@
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createPolylineLayer, createZoneLayer, useMapLayers, type MapHandle } from '@lib/map'; import { BaseMap, createStaticLayers, createMarkerLayer, createPolylineLayer, createZoneLayer, useMapLayers, type MapHandle } from '@lib/map';
import { Card, CardContent } from '@shared/components/ui/card'; import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button'; import { Button } from '@shared/components/ui/button';
@ -89,7 +89,7 @@ export function FleetOptimization() {
})); }));
return [ return [
...STATIC_LAYERS, ...createStaticLayers(),
createZoneLayer('coverage', coverageZones, 30000, 0.12), createZoneLayer('coverage', coverageZones, 30000, 0.12),
createMarkerLayer('coverage-labels', coverageLabels), createMarkerLayer('coverage-labels', coverageLabels),
...routeLayers, ...routeLayers,

파일 보기

@ -1,7 +1,7 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type maplibregl from 'maplibre-gl'; import type maplibregl from 'maplibre-gl';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createPolylineLayer, useMapLayers, type MapHandle } from '@lib/map'; import { BaseMap, createStaticLayers, createMarkerLayer, createPolylineLayer, useMapLayers, type MapHandle } from '@lib/map';
import { Card, CardContent } from '@shared/components/ui/card'; import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button'; import { Button } from '@shared/components/ui/button';
@ -50,7 +50,7 @@ export function PatrolRoute() {
const wps = route?.waypoints ?? []; const wps = route?.waypoints ?? [];
const buildLayers = useCallback(() => { const buildLayers = useCallback(() => {
if (wps.length === 0) return [...STATIC_LAYERS]; if (wps.length === 0) return [...createStaticLayers()];
const routeCoords: [number, number][] = wps.map(w => [w.lat, w.lng]); const routeCoords: [number, number][] = wps.map(w => [w.lat, w.lng]);
const midMarkers = []; const midMarkers = [];
@ -70,7 +70,7 @@ export function PatrolRoute() {
}); });
return [ return [
...STATIC_LAYERS, ...createStaticLayers(),
createPolylineLayer('patrol-route', routeCoords, { color: '#06b6d4', width: 3, opacity: 0.8 }), createPolylineLayer('patrol-route', routeCoords, { color: '#06b6d4', width: 3, opacity: 0.8 }),
createMarkerLayer('route-midpoints', midMarkers, '#06b6d4', 500), createMarkerLayer('route-midpoints', midMarkers, '#06b6d4', 500),
createMarkerLayer('waypoint-markers', waypointMarkers), createMarkerLayer('waypoint-markers', waypointMarkers),

파일 보기

@ -8,7 +8,7 @@ import { DataTable, type DataColumn } from '@shared/components/common/DataTable'
import { getRiskIntent, getStatusIntent } from '@shared/constants/statusIntent'; import { getRiskIntent, getStatusIntent } from '@shared/constants/statusIntent';
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels'; import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
import { Shield, AlertTriangle, Ship, Plus, Calendar, Users, Loader2 } from 'lucide-react'; import { Shield, AlertTriangle, Ship, Plus, Calendar, Users, Loader2 } from 'lucide-react';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; import { BaseMap, createStaticLayers, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { MarkerData } from '@lib/map'; import type { MarkerData } from '@lib/map';
import { getEnforcementPlans, type EnforcementPlan as EnforcementPlanApi } from '@/services/enforcement'; import { getEnforcementPlans, type EnforcementPlan as EnforcementPlanApi } from '@/services/enforcement';
import { getEvents, type PredictionEvent } from '@/services/event'; import { getEvents, type PredictionEvent } from '@/services/event';
@ -87,7 +87,7 @@ export function EnforcementPlan() {
const mapRef = useRef<MapHandle>(null); const mapRef = useRef<MapHandle>(null);
const buildLayers = useCallback(() => [ const buildLayers = useCallback(() => [
...STATIC_LAYERS, ...createStaticLayers(),
createRadiusLayer( createRadiusLayer(
'ep-radius-confirmed', 'ep-radius-confirmed',
PLANS.filter(p => p.status === '확정' || p.status === 'CONFIRMED').map(p => ({ PLANS.filter(p => p.status === '확정' || p.status === 'CONFIRMED').map(p => ({

파일 보기

@ -1,5 +1,5 @@
import { useState, useRef, useCallback } from 'react'; import { useState, useRef, useCallback } from 'react';
import { BaseMap, STATIC_LAYERS, createHeatmapLayer, useMapLayers, type MapHandle } from '@lib/map'; import { BaseMap, createStaticLayers, createHeatmapLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { HeatPoint } from '@lib/map'; import type { HeatPoint } from '@lib/map';
import { Card, CardContent } from '@shared/components/ui/card'; import { Card, CardContent } from '@shared/components/ui/card';
import { Button } from '@shared/components/ui/button'; import { Button } from '@shared/components/ui/button';
@ -169,7 +169,7 @@ export function RiskMap() {
const buildLayers = useCallback(() => { const buildLayers = useCallback(() => {
if (tab !== 'heatmap') return []; if (tab !== 'heatmap') return [];
return [ return [
...STATIC_LAYERS, ...createStaticLayers(),
createHeatmapLayer('risk-heat', HEAT_POINTS as HeatPoint[], { radiusPixels: 25 }), createHeatmapLayer('risk-heat', HEAT_POINTS as HeatPoint[], { radiusPixels: 25 }),
]; ];
}, [tab]); }, [tab]);

파일 보기

@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import maplibregl from 'maplibre-gl'; import maplibregl from 'maplibre-gl';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; import { BaseMap, createStaticLayers, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { MarkerData } from '@lib/map'; import type { MarkerData } from '@lib/map';
import { Card, CardContent } from '@shared/components/ui/card'; import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
@ -160,7 +160,7 @@ export function LiveMapView() {
// deck.gl 레이어 // deck.gl 레이어
const buildLayers = useCallback(() => [ const buildLayers = useCallback(() => [
...STATIC_LAYERS, ...createStaticLayers(),
// 선박 분석 데이터 마커 (riskLevel 기반 색상) // 선박 분석 데이터 마커 (riskLevel 기반 색상)
createMarkerLayer( createMarkerLayer(
'ais-vessels', 'ais-vessels',

파일 보기

@ -1,5 +1,5 @@
import { useState, useRef, useCallback } from 'react'; import { useState, useRef, useCallback } from 'react';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; import { BaseMap, createStaticLayers, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import { Card, CardContent } from '@shared/components/ui/card'; import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout'; import { PageContainer, PageHeader } from '@shared/components/layout';
@ -229,7 +229,7 @@ export function MapControl() {
})); }));
return [ return [
...STATIC_LAYERS, ...createStaticLayers(),
createRadiusLayer( createRadiusLayer(
'zone-circles-active', 'zone-circles-active',
activeZones.map(pz => ({ lat: pz.lat, lng: pz.lng, radius: pz.radiusM, color: pz.color })), activeZones.map(pz => ({ lat: pz.lat, lng: pz.lng, radius: pz.radiusM, color: pz.color })),

파일 보기

@ -8,7 +8,7 @@ import {
Camera, Crosshair, Ruler, CircleDot, Clock, LayoutGrid, Brain, Camera, Crosshair, Ruler, CircleDot, Clock, LayoutGrid, Brain,
Loader2, ShieldAlert, Shield, EyeOff, FileText, Loader2, ShieldAlert, Shield, EyeOff, FileText,
} from 'lucide-react'; } from 'lucide-react';
import { BaseMap, STATIC_LAYERS, createZoneLayer, createPolylineLayer, JURISDICTION_AREAS, DEPTH_CONTOURS, useMapLayers, type MapHandle } from '@lib/map'; import { BaseMap, createStaticLayers, createZoneLayer, createPolylineLayer, JURISDICTION_AREAS, DEPTH_CONTOURS, useMapLayers, type MapHandle } from '@lib/map';
import { formatDateTime } from '@shared/utils/dateFormat'; import { formatDateTime } from '@shared/utils/dateFormat';
import { getEvents, type PredictionEvent } from '@/services/event'; import { getEvents, type PredictionEvent } from '@/services/event';
import { getAnalysisLatest, getAnalysisHistory, type VesselAnalysis } from '@/services/analysisApi'; import { getAnalysisLatest, getAnalysisHistory, type VesselAnalysis } from '@/services/analysisApi';
@ -160,7 +160,7 @@ export function VesselDetail() {
// 지도 레이어 // 지도 레이어
const buildLayers = useCallback(() => [ const buildLayers = useCallback(() => [
...STATIC_LAYERS, ...createStaticLayers(),
createZoneLayer('jurisdiction', JURISDICTION_AREAS.map((a) => ({ createZoneLayer('jurisdiction', JURISDICTION_AREAS.map((a) => ({
name: a.name, lat: a.lat, lng: a.lng, color: a.color, radiusM: 80000, name: a.name, lat: a.lat, lng: a.lng, color: a.color, radiusM: 80000,
})), 80000, 0.05), })), 80000, 0.05),

파일 보기

@ -7,6 +7,10 @@
"title": "MLOps / LLMOps", "title": "MLOps / LLMOps",
"desc": "SFR-18/19 | ML & LLM experiment, deployment, monitoring" "desc": "SFR-18/19 | ML & LLM experiment, deployment, monitoring"
}, },
"lgcnsMlops": {
"title": "LGCNS MLOps",
"desc": "LGCNS DAP-based MLOps pipeline — project, environment, model & job management"
},
"llmOps": { "llmOps": {
"title": "LLM Operations", "title": "LLM Operations",
"desc": "SFR-20 | Qwen3-8B model management, prompts, inference, RAG, evaluation, security & monitoring" "desc": "SFR-20 | Qwen3-8B model management, prompts, inference, RAG, evaluation, security & monitoring"

파일 보기

@ -21,6 +21,7 @@
"reports": "Reports", "reports": "Reports",
"aiModel": "AI Model", "aiModel": "AI Model",
"mlops": "MLOps", "mlops": "MLOps",
"lgcnsMlops": "LGCNS MLOps",
"llmOps": "LLM Ops", "llmOps": "LLM Ops",
"aiAssistant": "AI Q&A", "aiAssistant": "AI Q&A",
"dataHub": "Data Hub", "dataHub": "Data Hub",
@ -33,7 +34,9 @@
"labelSession": "Label Session", "labelSession": "Label Session",
"auditLogs": "Audit Logs", "auditLogs": "Audit Logs",
"accessLogs": "Access Logs", "accessLogs": "Access Logs",
"loginHistory": "Login History" "loginHistory": "Login History",
"aiSecurity": "AI Security",
"aiAgentSecurity": "AI Agent Security"
}, },
"status": { "status": {
"active": "Active", "active": "Active",

파일 보기

@ -7,6 +7,10 @@
"title": "MLOps/LLMOps", "title": "MLOps/LLMOps",
"desc": "SFR-18/19 | 기계학습·대규모 언어모델 실험·배포·모니터링 통합" "desc": "SFR-18/19 | 기계학습·대규모 언어모델 실험·배포·모니터링 통합"
}, },
"lgcnsMlops": {
"title": "LGCNS MLOps",
"desc": "LGCNS DAP 기반 MLOps 파이프라인 — 프로젝트·분석환경·모델·Job 관리 통합"
},
"llmOps": { "llmOps": {
"title": "LLM 운영 관리", "title": "LLM 운영 관리",
"desc": "SFR-20 | Qwen3-8B 모델 관리·프롬프트·추론·RAG·평가·보안·모니터링 통합 운영" "desc": "SFR-20 | Qwen3-8B 모델 관리·프롬프트·추론·RAG·평가·보안·모니터링 통합 운영"

파일 보기

@ -21,6 +21,7 @@
"reports": "보고서 관리", "reports": "보고서 관리",
"aiModel": "AI 모델관리", "aiModel": "AI 모델관리",
"mlops": "MLOps", "mlops": "MLOps",
"lgcnsMlops": "LGCNS MLOps",
"llmOps": "LLM 운영", "llmOps": "LLM 운영",
"aiAssistant": "AI 의사결정 지원", "aiAssistant": "AI 의사결정 지원",
"dataHub": "데이터 허브", "dataHub": "데이터 허브",
@ -33,7 +34,9 @@
"labelSession": "학습 세션", "labelSession": "학습 세션",
"auditLogs": "감사 로그", "auditLogs": "감사 로그",
"accessLogs": "접근 이력", "accessLogs": "접근 이력",
"loginHistory": "로그인 이력" "loginHistory": "로그인 이력",
"aiSecurity": "AI 보안",
"aiAgentSecurity": "AI Agent 보안"
}, },
"status": { "status": {
"active": "활성", "active": "활성",

파일 보기

@ -98,6 +98,12 @@ export const BaseMap = memo(forwardRef<MapHandle, BaseMapProps>(function BaseMap
map.on('load', () => { onMapReady?.(map); }); map.on('load', () => { onMapReady?.(map); });
return () => { return () => {
// deck.gl overlay를 먼저 정리하여 WebGL 리소스 해제
if (overlayRef.current) {
try {
overlayRef.current.finalize();
} catch { /* 이미 해제된 경우 무시 */ }
}
map.remove(); map.remove();
mapRef.current = null; mapRef.current = null;
overlayRef.current = null; overlayRef.current = null;

파일 보기

@ -35,7 +35,11 @@ export function useMapLayers(
handleRef.current?.overlay?.setProps({ layers: buildLayers() }); handleRef.current?.overlay?.setProps({ layers: buildLayers() });
}); });
return () => cancelAnimationFrame(rafRef.current); return () => {
cancelAnimationFrame(rafRef.current);
// 언마운트 시 레이어 초기화 — stale WebGL 참조 방지
try { handleRef.current?.overlay?.setProps({ layers: [] }); } catch { /* finalized */ }
};
}); });
} }
@ -60,6 +64,8 @@ export function useStoreLayerSync<T>(
return () => { return () => {
unsub(); unsub();
cancelAnimationFrame(rafRef.current); cancelAnimationFrame(rafRef.current);
// 언마운트 시 레이어 초기화 — stale WebGL 참조 방지
try { handleRef.current?.overlay?.setProps({ layers: [] }); } catch { /* finalized */ }
}; };
// buildLayers는 안정적 참조여야 함 (useCallback으로 감싸거나 모듈 스코프) // buildLayers는 안정적 참조여야 함 (useCallback으로 감싸거나 모듈 스코프)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps

파일 보기

@ -14,6 +14,6 @@ export {
createPolylineLayer, createPolylineLayer,
createHeatmapLayer, createHeatmapLayer,
createZoneLayer, createZoneLayer,
EEZ_LAYER, NLL_LAYER, STATIC_LAYERS, createStaticLayers,
} from './layers'; } from './layers';
export { useMapLayers, useStoreLayerSync } from './hooks/useMapLayers'; export { useMapLayers, useStoreLayerSync } from './hooks/useMapLayers';

파일 보기

@ -3,4 +3,4 @@ export { createMarkerLayer, createRadiusLayer, type MarkerData } from './markers
export { createPolylineLayer } from './polyline'; export { createPolylineLayer } from './polyline';
export { createHeatmapLayer } from './heatmap'; export { createHeatmapLayer } from './heatmap';
export { createZoneLayer } from './zones'; export { createZoneLayer } from './zones';
export { EEZ_LAYER, NLL_LAYER, STATIC_LAYERS } from './static'; export { createEEZStaticLayer, createNLLStaticLayer, createStaticLayers } from './static';

파일 보기

@ -1,7 +1,10 @@
/** /**
* *
* 1 *
* deck.gl은 id + data GPU * 주의: deck.gl Layer WebGL / .
* WebGL stale
* "parameter 1 is not of type 'WebGLProgram'" .
* . deck.gl이 id diff로 GPU .
*/ */
import { PathLayer } from 'deck.gl'; import { PathLayer } from 'deck.gl';
import { EEZ_BOUNDARY, NLL_LINE, EEZ_STYLE, NLL_STYLE } from '../constants'; import { EEZ_BOUNDARY, NLL_LINE, EEZ_STYLE, NLL_STYLE } from '../constants';
@ -20,29 +23,38 @@ function hexToRgba(hex: string, opacity: number): [number, number, number, numbe
const EEZ_DATA = [{ path: toPath(EEZ_BOUNDARY) }]; const EEZ_DATA = [{ path: toPath(EEZ_BOUNDARY) }];
const NLL_DATA = [{ path: toPath(NLL_LINE) }]; const NLL_DATA = [{ path: toPath(NLL_LINE) }];
/** EEZ 경계선 — 싱글턴, 리렌더/재생성 없음 */ const EEZ_COLOR = hexToRgba(EEZ_STYLE.color, EEZ_STYLE.opacity);
export const EEZ_LAYER = new PathLayer({ const NLL_COLOR = hexToRgba(NLL_STYLE.color, NLL_STYLE.opacity);
/** EEZ 경계선 레이어 생성 */
export function createEEZStaticLayer() {
return new PathLayer({
id: 'eez-boundary', id: 'eez-boundary',
data: EEZ_DATA, data: EEZ_DATA,
getPath: (d: { path: [number, number][] }) => d.path, getPath: (d: { path: [number, number][] }) => d.path,
getColor: hexToRgba(EEZ_STYLE.color, EEZ_STYLE.opacity), getColor: EEZ_COLOR,
getWidth: EEZ_STYLE.weight * 2, getWidth: EEZ_STYLE.weight * 2,
widthUnits: 'pixels' as const, widthUnits: 'pixels' as const,
getDashArray: [6, 4], getDashArray: [6, 4],
dashJustified: true, dashJustified: true,
}); });
}
/** NLL 경계선 — 싱글턴, 리렌더/재생성 없음 */ /** NLL 경계선 레이어 생성 */
export const NLL_LAYER = new PathLayer({ export function createNLLStaticLayer() {
return new PathLayer({
id: 'nll-line', id: 'nll-line',
data: NLL_DATA, data: NLL_DATA,
getPath: (d: { path: [number, number][] }) => d.path, getPath: (d: { path: [number, number][] }) => d.path,
getColor: hexToRgba(NLL_STYLE.color, NLL_STYLE.opacity), getColor: NLL_COLOR,
getWidth: NLL_STYLE.weight * 2, getWidth: NLL_STYLE.weight * 2,
widthUnits: 'pixels' as const, widthUnits: 'pixels' as const,
getDashArray: [8, 4], getDashArray: [8, 4],
dashJustified: true, dashJustified: true,
}); });
}
/** 정적 기본 레이어 배열 (EEZ + NLL) */ /** 정적 기본 레이어 배열 (EEZ + NLL) — 매 호출마다 새 인스턴스 */
export const STATIC_LAYERS = [EEZ_LAYER, NLL_LAYER] as const; export function createStaticLayers() {
return [createEEZStaticLayer(), createNLLStaticLayer()];
}

파일 보기

@ -3,8 +3,8 @@ from typing import Callable, Optional
import pandas as pd import pandas as pd
from algorithms.location import haversine_nm from algorithms.location import haversine_nm
GAP_SUSPICIOUS_SEC = 1800 # 30분 GAP_SUSPICIOUS_SEC = 6000 # 100분 (30분 → 100분 상향: 자연 gap 과탐 감소)
GAP_HIGH_SUSPICIOUS_SEC = 3600 # 1시간 GAP_HIGH_SUSPICIOUS_SEC = 10800 # 3시간
GAP_VIOLATION_SEC = 86400 # 24시간 GAP_VIOLATION_SEC = 86400 # 24시간
# 한국 AIS 수신 가능 추정 영역 (한반도 + EEZ + 접속수역 여유) # 한국 AIS 수신 가능 추정 영역 (한반도 + EEZ + 접속수역 여유)
@ -61,7 +61,7 @@ def is_dark_vessel(df_vessel: pd.DataFrame) -> tuple[bool, int]:
return False, 0 return False, 0
max_gap_min = max(g['gap_min'] for g in gaps) max_gap_min = max(g['gap_min'] for g in gaps)
is_dark = max_gap_min >= 30 # 30분 이상 소실 is_dark = max_gap_min >= (GAP_SUSPICIOUS_SEC / 60) # 상수에서 파생
return is_dark, int(max_gap_min) return is_dark, int(max_gap_min)
@ -218,6 +218,10 @@ def compute_dark_suspicion(
history: dict, history: dict,
now_kst_hour: int, now_kst_hour: int,
classify_zone_fn: Optional[Callable[[float, float], dict]] = None, classify_zone_fn: Optional[Callable[[float, float], dict]] = None,
ship_kind_code: str = '',
nav_status: str = '',
heading: Optional[float] = None,
last_cog: Optional[float] = None,
) -> tuple[int, list[str], str]: ) -> tuple[int, list[str], str]:
"""의도적 AIS OFF 의심 점수 산출. """의도적 AIS OFF 의심 점수 산출.
@ -228,6 +232,10 @@ def compute_dark_suspicion(
history: {'count_7d': int, 'count_24h': int} history: {'count_7d': int, 'count_24h': int}
now_kst_hour: 현재 KST 시각 (0~23) now_kst_hour: 현재 KST 시각 (0~23)
classify_zone_fn: (lat, lon) -> dict. gap_start 위치의 zone 판단 classify_zone_fn: (lat, lon) -> dict. gap_start 위치의 zone 판단
ship_kind_code: 선종 코드 (000020=어선, 000023=화물 )
nav_status: 항해 상태 텍스트 ("Under way using engine" )
heading: 선수 방향 (0~360, signal-batch API)
last_cog: gap 직전 침로 (0~360)
Returns: Returns:
(score, patterns, tier) (score, patterns, tier)
@ -314,9 +322,40 @@ def compute_dark_suspicion(
score += 10 score += 10
patterns.append('long_gap') patterns.append('long_gap')
# 감점: gap 시작 위치가 한국 수신 커버리지 밖 → 자연 gap 가능성 # P9: 선종별 가중치 (signal-batch API 데이터)
if ship_kind_code == '000020':
# 어선이면서 dark → 불법조업 의도 가능성
score += 10
patterns.append('fishing_vessel_dark')
elif ship_kind_code == '000023':
# 화물선은 원양 항해 중 자연 gap 빈번
score -= 10
patterns.append('cargo_natural_gap')
# P10: 항해 상태 기반 의도성
if nav_status:
status_lower = nav_status.lower()
if 'under way' in status_lower and gap_start_sog > 3.0:
# 항행 중 갑자기 OFF → 의도적
score += 20
patterns.append('underway_deliberate_off')
elif 'anchor' in status_lower or 'moored' in status_lower:
# 정박 중 gap → 자연스러움
score -= 15
patterns.append('anchored_natural_gap')
# P11: heading vs COG 불일치 (의도적 방향 전환)
if heading is not None and last_cog is not None:
diff = abs(heading - last_cog) % 360
if diff > 180:
diff = 360 - diff
if diff > 60:
score += 15
patterns.append('heading_cog_mismatch')
# 감점: gap 시작 위치가 한국 수신 커버리지 밖 → 자연 gap 가능성 높음
if not _is_in_kr_coverage(gap_start_lat, gap_start_lon): if not _is_in_kr_coverage(gap_start_lat, gap_start_lon):
score -= 30 score -= 50
patterns.append('out_of_coverage') patterns.append('out_of_coverage')
score = max(0, min(100, score)) score = max(0, min(100, score))

파일 보기

@ -1,19 +1,21 @@
"""환적(Transshipment) 의심 선박 탐지 — 서버사이드 O(n log n) 구현. """환적(Transshipment) 의심 선박 탐지 — 5단계 필터 파이프라인.
프론트엔드 useKoreaFilters.ts의 O() 근접 탐지를 대체한다. 실무 목표: 일일 10 미만 고신뢰 의심 .
scipy 미설치 환경을 고려하여 그리드 기반 공간 인덱스를 사용한다.
알고리즘 개요: 5단계 필터:
1. 후보 선박 필터: sog < 2kn, 선종 (tanker/cargo/fishing), 외국 해안선 제외 Stage 1: 이종 필수 (어선 운반선) shipKindCode 기반
2. 그리드 기반 근접 탐지: O(n log n) 분할 + 인접 9 조회 Stage 2: 감시영역(Monitoring Zone) 선박만 대상
3. pair_history dict로 쌍별 최초 탐지 시각 영속화 (호출 유지) Stage 3: 3단계 패턴 검증 (APPROACH RENDEZVOUS DEPARTURE)
4. 60 이상 지속 근접 의심 쌍으로 판정 Stage 4: 점수 산출 (0~100, 50 미만 미출력)
Stage 5: 밀집 방폭 (1 운반선 : 최대 1 어선)
""" """
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import math import math
import os
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Callable, Optional from typing import Callable, Optional
@ -24,82 +26,150 @@ from fleet_tracker import GEAR_PATTERN
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# 상수 (2026-04-09 재조정 — 베테랑 관점) # 상수
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
SOG_THRESHOLD_KN = 1.0 # 2.0 → 1.0 (완전 정박 수준) SOG_THRESHOLD_KN = 2.0 # 저속 기준 (접현 가능 속도)
PROXIMITY_DEG = 0.0007 # 0.001 → 0.0007 (~77m, GPS 노이즈 포함한 근접) PROXIMITY_DEG = 0.002 # ~220m (0.0007 → 0.002 상향: 접현 가능 범위)
SUSPECT_DURATION_MIN = 45 # 60 → 45 (gap tolerance 있음) APPROACH_DEG = 0.01 # ~1.1km (접근 판정 거리)
PAIR_EXPIRY_MIN = 180 # 120 → 180 RENDEZVOUS_MIN = 90 # 체류 최소 시간 (45 → 90분)
GAP_TOLERANCE_CYCLES = 2 # 신규: 2 사이클까지 active에서 빠져도 리셋 안 함 PAIR_EXPIRY_MIN = 240 # 쌍 만료 시간
GAP_TOLERANCE_CYCLES = 3 # 3 사이클(15분)까지 miss 허용
# 외국 해안 근접 제외 경계 (레거시 — 관할 필터로 대체됨) # 선종 분류 (shipKindCode 기반)
_CN_LON_MAX = 123.5 _FISHING_KINDS = frozenset({'000020'})
_JP_LON_MIN = 130.5 # 운반선: 화물선(000023) + 유조선(000024)만. 000027(기타)은 shipTy로 2차 판정
_TSUSHIMA_LAT_MIN = 33.8 _CARRIER_KINDS = frozenset({'000023', '000024'})
_TSUSHIMA_LON_MIN = 129.0 # 환적 불가 선종 (shipTy 텍스트 기반 2차 필터)
_EXCLUDED_SHIP_TY = frozenset({
# 한국 EEZ 관할 수역 (단속 가능 범위) 'Tug', 'Pilot Boat', 'Search And Rescue', 'Law Enforcement',
_KR_EEZ_LAT = (32.0, 39.5) 'AtoN', 'Anti Pollution', 'Passenger', 'Medical Transport',
_KR_EEZ_LON = (124.0, 132.0)
# 환적 불가능 선종 (여객/군함/유조/도선/예인/수색구조)
_TRANSSHIP_EXCLUDED: frozenset[str] = frozenset({
'passenger', 'military', 'tanker', 'pilot', 'tug', 'sar',
}) })
# 그리드 셀 크기 # ──────────────────────────────────────────────────────────────
_GRID_CELL_DEG = PROXIMITY_DEG # 감시영역 로드
# ──────────────────────────────────────────────────────────────
_ZONES_FILE = os.path.join(os.path.dirname(__file__), '..', 'data', 'monitoring_zones.json')
_TRANSSHIP_ZONES: list[dict] = []
def _load_monitoring_zones() -> None:
global _TRANSSHIP_ZONES
try:
with open(_ZONES_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
_TRANSSHIP_ZONES = [
z for z in data.get('zones', [])
if z.get('type') == 'TRANSSHIP' and z.get('enabled')
]
logger.info('loaded %d transship monitoring zones', len(_TRANSSHIP_ZONES))
except Exception as e:
logger.warning('failed to load monitoring zones: %s', e)
_TRANSSHIP_ZONES = []
_load_monitoring_zones()
def _point_in_polygon(lat: float, lon: float, polygon: list[list[float]]) -> bool:
"""Ray-casting point-in-polygon. polygon: [[lon, lat], ...]"""
n = len(polygon)
inside = False
j = n - 1
for i in range(n):
xi, yi = polygon[i][0], polygon[i][1] # lon, lat
xj, yj = polygon[j][0], polygon[j][1]
if ((yi > lat) != (yj > lat)) and (lon < (xj - xi) * (lat - yi) / (yj - yi) + xi):
inside = not inside
j = i
return inside
def _is_in_transship_zone(lat: float, lon: float) -> Optional[str]:
"""감시영역 내 여부. 해당 zone ID 반환, 미해당 시 None."""
for zone in _TRANSSHIP_ZONES:
if _point_in_polygon(lat, lon, zone['polygon']):
return zone['id']
return None
# ──────────────────────────────────────────────────────────────
# Stage 1: 이종 쌍 필터
# ──────────────────────────────────────────────────────────────
def _classify_vessel_role(
ship_kind_code: str,
ship_ty: str,
) -> str:
"""선박 역할 분류: 'FISHING', 'CARRIER', 'EXCLUDED', 'UNKNOWN'"""
if ship_kind_code in _FISHING_KINDS:
return 'FISHING'
if ship_kind_code in _CARRIER_KINDS:
# 화물선/유조선 — shipTy가 예인선/관공선이면 제외
if ship_ty in _EXCLUDED_SHIP_TY:
return 'EXCLUDED'
return 'CARRIER'
# 000027(기타) / 000028(미분류): shipTy 텍스트로 엄격 판정
if ship_kind_code in ('000027', '000028'):
if ship_ty == 'Cargo':
return 'CARRIER'
if ship_ty == 'Tanker':
return 'CARRIER'
if ship_ty in _EXCLUDED_SHIP_TY:
return 'EXCLUDED'
# N/A, Vessel, 기타 → UNKNOWN (환적 후보에서 제외)
return 'UNKNOWN'
# 000021(함정), 000022(여객), 000025(관공) → 제외
if ship_kind_code in ('000021', '000022', '000025'):
return 'EXCLUDED'
return 'UNKNOWN'
def _is_transship_pair(role_a: str, role_b: str) -> bool:
"""어선 + 운반선 조합만 True."""
return (role_a == 'FISHING' and role_b == 'CARRIER') or \
(role_b == 'FISHING' and role_a == 'CARRIER')
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# 내부 헬퍼 # 내부 헬퍼
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
def _is_near_foreign_coast(lat: float, lon: float) -> bool: def _haversine_nm(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""외국 해안 근처 여부 — 중국/일본/대마도 경계 확인.""" R = 3440.065
if lon < _CN_LON_MAX: dlat = math.radians(lat2 - lat1)
return True dlon = math.radians(lon2 - lon1)
if lon > _JP_LON_MIN: a = math.sin(dlat/2)**2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon/2)**2
return True return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
if lat > _TSUSHIMA_LAT_MIN and lon > _TSUSHIMA_LON_MIN:
return True
def _within_proximity(a: dict, b: dict) -> bool:
"""PROXIMITY_DEG 이내인지 (위경도 근사)."""
dlat = abs(a['lat'] - b['lat'])
if dlat >= PROXIMITY_DEG:
return False return False
cos_lat = math.cos(math.radians((a['lat'] + b['lat']) / 2.0))
dlon_scaled = abs(a['lon'] - b['lon']) * cos_lat
return dlon_scaled < PROXIMITY_DEG
def _is_in_kr_jurisdiction(lat: float, lon: float) -> bool: def _within_approach(a: dict, b: dict) -> bool:
"""한국 EEZ 관할 수역 여부 (단속 가능 범위).""" """APPROACH_DEG 이내인지 (~1km)."""
return (_KR_EEZ_LAT[0] <= lat <= _KR_EEZ_LAT[1] dlat = abs(a['lat'] - b['lat'])
and _KR_EEZ_LON[0] <= lon <= _KR_EEZ_LON[1]) if dlat >= APPROACH_DEG:
def _is_candidate_ship_type(vessel_type_a: Optional[str], vessel_type_b: Optional[str]) -> bool:
"""환적 후보 선종인지 (명시적 제외만 차단, 미상은 허용)."""
a = (vessel_type_a or '').strip().lower()
b = (vessel_type_b or '').strip().lower()
if a in _TRANSSHIP_EXCLUDED or b in _TRANSSHIP_EXCLUDED:
return False return False
return True cos_lat = math.cos(math.radians((a['lat'] + b['lat']) / 2.0))
dlon_scaled = abs(a['lon'] - b['lon']) * cos_lat
return dlon_scaled < APPROACH_DEG
def _is_gear_name(name: Optional[str]) -> bool:
"""어구 이름 패턴 매칭 — fleet_tracker.GEAR_PATTERN SSOT."""
if not name:
return False
return bool(GEAR_PATTERN.match(name))
def _cell_key(lat: float, lon: float) -> tuple[int, int]: def _cell_key(lat: float, lon: float) -> tuple[int, int]:
"""위도/경도를 그리드 셀 인덱스로 변환.""" return (int(math.floor(lat / APPROACH_DEG)),
return (int(math.floor(lat / _GRID_CELL_DEG)), int(math.floor(lon / APPROACH_DEG)))
int(math.floor(lon / _GRID_CELL_DEG)))
def _build_grid(records: list[dict]) -> dict[tuple[int, int], list[int]]: def _build_grid(records: list[dict]) -> dict[tuple[int, int], list[int]]:
"""선박 리스트를 그리드 셀로 분류.
Returns: {(row, col): [record index, ...]}
"""
grid: dict[tuple[int, int], list[int]] = {} grid: dict[tuple[int, int], list[int]] = {}
for idx, rec in enumerate(records): for idx, rec in enumerate(records):
key = _cell_key(rec['lat'], rec['lon']) key = _cell_key(rec['lat'], rec['lon'])
@ -109,147 +179,130 @@ def _build_grid(records: list[dict]) -> dict[tuple[int, int], list[int]]:
return grid return grid
def _within_proximity(a: dict, b: dict) -> bool:
"""두 선박이 PROXIMITY_DEG 이내인지 확인 (위경도 직교 근사)."""
dlat = abs(a['lat'] - b['lat'])
if dlat >= PROXIMITY_DEG:
return False
cos_lat = math.cos(math.radians((a['lat'] + b['lat']) / 2.0))
dlon_scaled = abs(a['lon'] - b['lon']) * cos_lat
return dlon_scaled < PROXIMITY_DEG
def _normalize_type(raw: Optional[str]) -> str:
"""선종 문자열 소문자 정규화."""
if not raw:
return ''
return raw.strip().lower()
def _pair_key(mmsi_a: str, mmsi_b: str) -> tuple[str, str]: def _pair_key(mmsi_a: str, mmsi_b: str) -> tuple[str, str]:
"""MMSI 순서를 정규화하여 중복 쌍 방지."""
return (mmsi_a, mmsi_b) if mmsi_a < mmsi_b else (mmsi_b, mmsi_a) return (mmsi_a, mmsi_b) if mmsi_a < mmsi_b else (mmsi_b, mmsi_a)
def _evict_expired_pairs( def _is_gear_name(name: Optional[str]) -> bool:
pair_history: dict, if not name:
now: datetime, return False
) -> None: return bool(GEAR_PATTERN.match(name))
"""PAIR_EXPIRY_MIN 이상 갱신 없는 pair_history 항목 제거.
구조: {(a,b): {'first_seen': dt, 'last_seen': dt, 'miss_count': int}}
""" # ──────────────────────────────────────────────────────────────
expired = [] # Stage 4: 점수 산출
for key, meta in pair_history.items(): # ──────────────────────────────────────────────────────────────
if not isinstance(meta, dict):
# 레거시 구조 (datetime 직접 저장)는 즉시 제거 → 다음 사이클에서 재구성 def _score_pair(
expired.append(key) meta: dict,
continue now: datetime,
last_seen = meta.get('last_seen') or meta.get('first_seen') is_permitted_fn: Optional[Callable[[str], bool]],
if last_seen is None: now_kst_hour: int,
expired.append(key) zone_id: Optional[str],
continue vessel_info_a: dict,
if (now - last_seen).total_seconds() / 60 > PAIR_EXPIRY_MIN: vessel_info_b: dict,
expired.append(key) ) -> Optional[dict]:
for key in expired: """환적 의심 점수 산출. 50점 미만이면 None."""
del pair_history[key] phase = meta.get('phase', 'APPROACH')
pair = meta.get('pair')
if not pair:
return None
# 필수: RENDEZVOUS 90분 이상
rendezvous_start = meta.get('rendezvous_start')
if rendezvous_start is None:
return None
rendezvous_min = (now - rendezvous_start).total_seconds() / 60
if rendezvous_min < RENDEZVOUS_MIN:
return None
score = 0
# 3단계 패턴 완성도
has_approach = meta.get('approach_detected', False)
has_departure = meta.get('departure_detected', False)
if has_approach and has_departure:
score += 35
elif has_departure or has_approach:
score += 25
else:
score += 15
# 어선 식별 — pair에서 누가 어선이고 누가 운반선인지
role_a = meta.get('role_a', 'UNKNOWN')
role_b = meta.get('role_b', 'UNKNOWN')
fishing_mmsi = pair[0] if role_a == 'FISHING' else pair[1]
carrier_mmsi = pair[1] if role_a == 'FISHING' else pair[0]
# 운반선 무허가
if is_permitted_fn is not None:
try:
if not is_permitted_fn(carrier_mmsi):
score += 15
except Exception:
pass
# 야간 (20:00~04:00 KST)
if now_kst_hour >= 20 or now_kst_hour < 4:
score += 10
# 수역 가점 (감시영역 종류에 따라)
if zone_id:
if 'EEZ' in zone_id.upper() or '001' in zone_id:
score += 15
else:
score += 10
# 체류 시간 bonus
if rendezvous_min >= 180:
score += 10
elif rendezvous_min >= 120:
score += 5
# heading 병행 판정 (향후 detail API 데이터)
heading_a = vessel_info_a.get('heading')
heading_b = vessel_info_b.get('heading')
if heading_a is not None and heading_b is not None:
diff = abs(heading_a - heading_b)
if diff > 180:
diff = 360 - diff
if diff < 20: # 평행 = 접현 가능
score += 10
score = max(0, min(100, score))
if score < 50:
return None
if score >= 70:
severity = 'CRITICAL'
else:
severity = 'HIGH'
lat = meta.get('last_lat', 0)
lon = meta.get('last_lon', 0)
return {
'pair_a': pair[0],
'pair_b': pair[1],
'fishing_mmsi': fishing_mmsi,
'carrier_mmsi': carrier_mmsi,
'duration_min': int(rendezvous_min),
'severity': severity,
'score': score,
'lat': lat,
'lon': lon,
'zone_id': zone_id,
'phase': phase,
'has_approach': has_approach,
'has_departure': has_departure,
}
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# 공개 API # 공개 API
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
def _score_pair(
pair: tuple[str, str],
meta: dict,
lat: float,
lon: float,
cog_a: Optional[float],
cog_b: Optional[float],
vessel_info_a: dict,
vessel_info_b: dict,
is_permitted_fn: Optional[Callable[[str], bool]],
now_kst_hour: int,
zone_code: Optional[str],
now: datetime,
) -> Optional[dict]:
"""환적 의심 pair에 대해 점수 산출 + severity 반환.
필수 조건 실패 None. WATCH 이상이면 dict 반환.
"""
# 필수 1: 한국 관할 수역
if not _is_in_kr_jurisdiction(lat, lon):
return None
# 필수 2: 선종 필터
if not _is_candidate_ship_type(
vessel_info_a.get('vessel_type'),
vessel_info_b.get('vessel_type'),
):
return None
# 필수 3: 어구 제외
if _is_gear_name(vessel_info_a.get('name')) or _is_gear_name(vessel_info_b.get('name')):
return None
# 필수 4: 지속 시간
first_seen = meta.get('first_seen')
if first_seen is None:
return None
duration_min = int((now - first_seen).total_seconds() / 60)
if duration_min < SUSPECT_DURATION_MIN:
return None
score = 40 # base
# 야간 가점 (KST 20:00~04:00)
if now_kst_hour >= 20 or now_kst_hour < 4:
score += 15
# 무허가 가점
if is_permitted_fn is not None:
try:
if not is_permitted_fn(pair[0]) or not is_permitted_fn(pair[1]):
score += 20
except Exception:
pass
# COG 편차 (같은 방향 아니면 가점 — 나란히 가는 선단 배제)
if cog_a is not None and cog_b is not None:
try:
diff = abs(float(cog_a) - float(cog_b))
if diff > 180:
diff = 360 - diff
if diff > 45:
score += 20
except Exception:
pass
# 지속 길이 추가 가점
if duration_min >= 90:
score += 20
# 영해/접속수역 추가 가점
if zone_code in ('TERRITORIAL_SEA', 'CONTIGUOUS_ZONE'):
score += 15
if score >= 90:
severity = 'CRITICAL'
elif score >= 70:
severity = 'HIGH'
elif score >= 50:
severity = 'WATCH'
else:
return None
return {
'pair_a': pair[0],
'pair_b': pair[1],
'duration_min': duration_min,
'severity': severity,
'score': score,
'lat': lat,
'lon': lon,
}
def detect_transshipment( def detect_transshipment(
df: pd.DataFrame, df: pd.DataFrame,
pair_history: dict, pair_history: dict,
@ -258,207 +311,226 @@ def detect_transshipment(
classify_zone_fn: Optional[Callable[[float, float], dict]] = None, classify_zone_fn: Optional[Callable[[float, float], dict]] = None,
now_kst_hour: int = 0, now_kst_hour: int = 0,
) -> list[dict]: ) -> list[dict]:
"""환적 의심 쌍 탐지 (점수 기반, 베테랑 관점 필터). """환적 의심 쌍 탐지 — 5단계 필터 파이프라인.
Args: Args:
df: 선박 위치 DataFrame. df: 선박 위치 DataFrame (mmsi, lat, lon, sog 필수)
필수 컬럼: mmsi, lat, lon, sog pair_history: 호출 유지되는 상태 dict
선택 컬럼: cog get_vessel_info: mmsi {ship_kind_code, ship_ty, name, heading, ...}
pair_history: {(a,b): {'first_seen', 'last_seen', 'miss_count'}} is_permitted: mmsi bool (허가 어선 여부)
get_vessel_info: callable(mmsi) -> {'name', 'vessel_type', ...} classify_zone_fn: (lat, lon) {zone, ...}
is_permitted: callable(mmsi) -> bool
classify_zone_fn: callable(lat, lon) -> dict (zone 판정)
now_kst_hour: 현재 KST 시각 (0~23) now_kst_hour: 현재 KST 시각 (0~23)
Returns: Returns:
list[dict] severity 'CRITICAL'/'HIGH'/'WATCH' 포함 의심 list[dict] 의심 (score >= 50)
""" """
if df.empty: if df.empty:
return [] return []
required_cols = {'mmsi', 'lat', 'lon', 'sog'} required_cols = {'mmsi', 'lat', 'lon', 'sog'}
missing = required_cols - set(df.columns) if required_cols - set(df.columns):
if missing:
logger.error('detect_transshipment: missing required columns: %s', missing)
return [] return []
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
# ── 1. 후보 선박 필터 (SOG < 1.0) ───────────────────────── # ── Stage 1+2: 후보 필터 ──────────────────────────────
candidate_mask = df['sog'] < SOG_THRESHOLD_KN # SOG < 2kn 선박만
candidates = df[candidate_mask].copy() candidates = df[df['sog'] < SOG_THRESHOLD_KN].copy()
if candidates.empty:
_evict_expired_pairs(pair_history, now)
return []
# 외국 해안 근처 제외 (1차 필터)
coast_mask = candidates.apply(
lambda row: not _is_near_foreign_coast(row['lat'], row['lon']),
axis=1,
)
candidates = candidates[coast_mask]
if len(candidates) < 2: if len(candidates) < 2:
_evict_expired_pairs(pair_history, now) _evict_expired(pair_history, now)
return [] return []
has_cog = 'cog' in candidates.columns # 선종 + 감시영역 필터 → 유효 레코드만
cols = ['mmsi', 'lat', 'lon'] records: list[dict] = []
if has_cog: for _, row in candidates.iterrows():
cols.append('cog') mmsi = str(row['mmsi'])
records = candidates[cols].to_dict('records') lat, lon = float(row['lat']), float(row['lon'])
for rec in records:
rec['mmsi'] = str(rec['mmsi'])
# ── 2. 그리드 기반 근접 쌍 탐지 (77m) ─────────────────── # Stage 2: 감시영역 내 여부
grid = _build_grid(records) zone_id = _is_in_transship_zone(lat, lon)
if zone_id is None:
continue
# Stage 1: 선종 분류
info = get_vessel_info(mmsi) if get_vessel_info else {}
kind = info.get('ship_kind_code', '')
ship_ty = info.get('ship_ty', info.get('vessel_type', ''))
role = _classify_vessel_role(kind, ship_ty)
if role in ('EXCLUDED', 'UNKNOWN'):
continue
# 어구 신호명 제외
if _is_gear_name(info.get('name')):
continue
rec = {
'mmsi': mmsi, 'lat': lat, 'lon': lon,
'role': role, 'zone_id': zone_id,
'cog': float(row.get('cog', 0)),
}
records.append(rec)
if len(records) < 2:
_evict_expired(pair_history, now)
return []
# 역할별 분리
fishing_recs = [r for r in records if r['role'] == 'FISHING']
carrier_recs = [r for r in records if r['role'] == 'CARRIER']
if not fishing_recs or not carrier_recs:
_evict_expired(pair_history, now)
return []
# ── 그리드 기반 근접 쌍 탐지 (어선 × 운반선만) ──────
carrier_grid = _build_grid(carrier_recs)
active_pairs: dict[tuple[str, str], dict] = {} active_pairs: dict[tuple[str, str], dict] = {}
def _try_add_pair(a_rec, b_rec): for f_rec in fishing_recs:
if not _within_proximity(a_rec, b_rec): f_cell = _cell_key(f_rec['lat'], f_rec['lon'])
return # 인접 9셀 탐색
key = _pair_key(a_rec['mmsi'], b_rec['mmsi']) for dr in (-1, 0, 1):
# 중점 좌표 (점수 산출용) for dc in (-1, 0, 1):
mid_lat = (a_rec['lat'] + b_rec['lat']) / 2.0 neighbor = (f_cell[0] + dr, f_cell[1] + dc)
mid_lon = (a_rec['lon'] + b_rec['lon']) / 2.0 if neighbor not in carrier_grid:
active_pairs[key] = {
'lat': mid_lat, 'lon': mid_lon,
'cog_a': a_rec.get('cog'), 'cog_b': b_rec.get('cog'),
# mmsi_a < mmsi_b 순서로 정렬되었으므로 cog도 맞춰 정렬 필요
'mmsi_a': a_rec['mmsi'], 'mmsi_b': b_rec['mmsi'],
}
for (row, col), indices in grid.items():
for i in range(len(indices)):
for j in range(i + 1, len(indices)):
_try_add_pair(records[indices[i]], records[indices[j]])
for dr, dc in ((0, 1), (1, -1), (1, 0), (1, 1)):
neighbor_key = (row + dr, col + dc)
if neighbor_key not in grid:
continue continue
for ai in indices: for ci in carrier_grid[neighbor]:
for bi in grid[neighbor_key]: c_rec = carrier_recs[ci]
_try_add_pair(records[ai], records[bi]) is_close = _within_proximity(f_rec, c_rec)
is_approaching = _within_approach(f_rec, c_rec) if not is_close else False
# ── 3. pair_history 갱신 (gap tolerance) ───────────────── if not is_close and not is_approaching:
active_keys = set(active_pairs.keys()) continue
# 활성 쌍 → 등록/갱신 key = _pair_key(f_rec['mmsi'], c_rec['mmsi'])
for pair in active_keys: mid_lat = (f_rec['lat'] + c_rec['lat']) / 2
if pair not in pair_history or not isinstance(pair_history[pair], dict): mid_lon = (f_rec['lon'] + c_rec['lon']) / 2
pair_history[pair] = {
'first_seen': now, active_pairs[key] = {
'last_seen': now, 'is_close': is_close,
'miss_count': 0, 'is_approaching': is_approaching,
'lat': mid_lat, 'lon': mid_lon,
'zone_id': f_rec['zone_id'],
'role_a': 'FISHING' if key[0] == f_rec['mmsi'] else 'CARRIER',
'role_b': 'CARRIER' if key[0] == f_rec['mmsi'] else 'FISHING',
} }
else:
pair_history[pair]['last_seen'] = now
pair_history[pair]['miss_count'] = 0
# 비활성 쌍 → miss_count++ , GAP_TOLERANCE 초과 시 삭제 # ── Stage 3: pair_history 상태머신 갱신 ─────────────
for key, loc in active_pairs.items():
meta = pair_history.get(key)
if meta is None or not isinstance(meta, dict):
# 신규 쌍
meta = {
'pair': key,
'phase': 'APPROACH' if loc['is_approaching'] else 'RENDEZVOUS',
'approach_detected': loc['is_approaching'],
'rendezvous_start': now if loc['is_close'] else None,
'departure_detected': False,
'miss_count': 0,
'last_lat': loc['lat'],
'last_lon': loc['lon'],
'zone_id': loc['zone_id'],
'role_a': loc['role_a'],
'role_b': loc['role_b'],
}
pair_history[key] = meta
continue
meta['miss_count'] = 0
meta['last_lat'] = loc['lat']
meta['last_lon'] = loc['lon']
if loc['is_close']:
if meta['phase'] == 'APPROACH':
# 접근 → 체류 전환
meta['phase'] = 'RENDEZVOUS'
meta['approach_detected'] = True
meta['rendezvous_start'] = meta.get('rendezvous_start') or now
elif meta['phase'] == 'DEPARTURE':
# 분리 후 재접근 → 체류 재개
meta['phase'] = 'RENDEZVOUS'
# RENDEZVOUS 상태 유지
if meta.get('rendezvous_start') is None:
meta['rendezvous_start'] = now
elif loc['is_approaching']:
if meta['phase'] == 'RENDEZVOUS':
# 체류 중 이격 시작 → 분리 단계
meta['phase'] = 'DEPARTURE'
meta['departure_detected'] = True
elif meta['phase'] == 'APPROACH':
meta['approach_detected'] = True
# 비활성 쌍 miss_count++
for key in list(pair_history.keys()): for key in list(pair_history.keys()):
if key in active_keys: if key in active_pairs:
continue continue
meta = pair_history[key] meta = pair_history[key]
if not isinstance(meta, dict): if not isinstance(meta, dict):
del pair_history[key] del pair_history[key]
continue continue
meta['miss_count'] = meta.get('miss_count', 0) + 1 meta['miss_count'] = meta.get('miss_count', 0) + 1
# 체류 중이던 쌍이 miss → 분리 가능
if meta.get('phase') == 'RENDEZVOUS' and meta['miss_count'] >= 2:
meta['phase'] = 'DEPARTURE'
meta['departure_detected'] = True
if meta['miss_count'] > GAP_TOLERANCE_CYCLES: if meta['miss_count'] > GAP_TOLERANCE_CYCLES:
del pair_history[key] del pair_history[key]
# 만료 정리 _evict_expired(pair_history, now)
_evict_expired_pairs(pair_history, now)
# ── 4. 점수 기반 의심 쌍 판정 ───────────────────────────── # ── Stage 4+5: 점수 산출 + 밀집 방폭 ────────────────
suspects: list[dict] = [] raw_suspects: list[dict] = []
rejected_jurisdiction = 0 for key, meta in pair_history.items():
rejected_ship_type = 0
rejected_gear = 0
rejected_duration = 0
for pair, meta in pair_history.items():
if not isinstance(meta, dict): if not isinstance(meta, dict):
continue continue
first_seen = meta.get('first_seen')
if first_seen is None:
continue
# active_pairs에 있으면 해당 사이클 좌표·cog 사용, 없으면 이전 값 재사용 (miss 중) info_a = get_vessel_info(key[0]) if get_vessel_info else {}
loc_meta = active_pairs.get(pair) info_b = get_vessel_info(key[1]) if get_vessel_info else {}
if loc_meta is not None:
lat = loc_meta['lat']
lon = loc_meta['lon']
# mmsi_a, mmsi_b 순서를 pair 순서에 맞춤
if loc_meta['mmsi_a'] == pair[0]:
cog_a, cog_b = loc_meta.get('cog_a'), loc_meta.get('cog_b')
else:
cog_a, cog_b = loc_meta.get('cog_b'), loc_meta.get('cog_a')
meta['last_lat'] = lat
meta['last_lon'] = lon
meta['last_cog_a'] = cog_a
meta['last_cog_b'] = cog_b
else:
lat = meta.get('last_lat')
lon = meta.get('last_lon')
cog_a = meta.get('last_cog_a')
cog_b = meta.get('last_cog_b')
if lat is None or lon is None:
continue
# 선박 정보 조회
info_a = get_vessel_info(pair[0]) if get_vessel_info else {}
info_b = get_vessel_info(pair[1]) if get_vessel_info else {}
# 짧게 pre-check (로깅용)
if not _is_in_kr_jurisdiction(lat, lon):
rejected_jurisdiction += 1
continue
if not _is_candidate_ship_type(info_a.get('vessel_type'), info_b.get('vessel_type')):
rejected_ship_type += 1
continue
if _is_gear_name(info_a.get('name')) or _is_gear_name(info_b.get('name')):
rejected_gear += 1
continue
duration_min = int((now - first_seen).total_seconds() / 60)
if duration_min < SUSPECT_DURATION_MIN:
rejected_duration += 1
continue
zone_code = None
if classify_zone_fn is not None:
try:
zone_code = classify_zone_fn(lat, lon).get('zone')
except Exception:
pass
scored = _score_pair( scored = _score_pair(
pair, meta, lat, lon, cog_a, cog_b, meta, now, is_permitted, now_kst_hour,
info_a, info_b, is_permitted, meta.get('zone_id'), info_a, info_b,
now_kst_hour, zone_code, now,
) )
if scored is not None: if scored is not None:
suspects.append(scored) raw_suspects.append(scored)
tier_counts = {'CRITICAL': 0, 'HIGH': 0, 'WATCH': 0} # Stage 5: 밀집 방폭 — 1 운반선 : 최대 1 어선 (최고 점수)
carrier_best: dict[str, dict] = {}
for s in raw_suspects:
carrier = s['carrier_mmsi']
if carrier not in carrier_best or s['score'] > carrier_best[carrier]['score']:
carrier_best[carrier] = s
suspects = list(carrier_best.values())
# 로그
tier_counts = {'CRITICAL': 0, 'HIGH': 0}
for s in suspects: for s in suspects:
tier_counts[s['severity']] = tier_counts.get(s['severity'], 0) + 1 tier_counts[s['severity']] = tier_counts.get(s['severity'], 0) + 1
logger.info( logger.info(
'transshipment detection: pairs=%d (critical=%d, high=%d, watch=%d, ' 'transshipment: pairs=%d (critical=%d, high=%d), '
'rejected_jurisdiction=%d, rejected_ship_type=%d, rejected_gear=%d, ' 'candidates: fishing=%d carrier=%d, active_pairs=%d, history=%d',
'rejected_duration=%d, candidates=%d)', len(suspects), tier_counts.get('CRITICAL', 0), tier_counts.get('HIGH', 0),
len(suspects), len(fishing_recs), len(carrier_recs), len(active_pairs), len(pair_history),
tier_counts.get('CRITICAL', 0),
tier_counts.get('HIGH', 0),
tier_counts.get('WATCH', 0),
rejected_jurisdiction,
rejected_ship_type,
rejected_gear,
rejected_duration,
len(candidates),
) )
return suspects return suspects
def _evict_expired(pair_history: dict, now: datetime) -> None:
"""PAIR_EXPIRY_MIN 이상 갱신 없는 쌍 제거."""
expired = []
for key, meta in pair_history.items():
if not isinstance(meta, dict):
expired.append(key)
continue
miss = meta.get('miss_count', 0)
if miss > GAP_TOLERANCE_CYCLES:
expired.append(key)
for key in expired:
del pair_history[key]

파일 보기

@ -254,7 +254,12 @@ class VesselStore:
mmsi_list = list(self._tracks.keys()) mmsi_list = list(self._tracks.keys())
try: try:
info = snpdb.fetch_static_info(mmsi_list) info = snpdb.fetch_static_info(mmsi_list)
self._static_info.update(info) # SNPDB 필드(name, vessel_type, length, width)만 갱신하되
# signal-batch에서 추가한 필드(ship_kind_code, status, heading 등)는 보존
for mmsi, snpdb_info in info.items():
existing = self._static_info.get(mmsi, {})
existing.update(snpdb_info) # SNPDB 필드 덮어쓰기 (signal-batch 필드는 유지)
self._static_info[mmsi] = existing
self._static_refreshed_at = now self._static_refreshed_at = now
logger.info('static info refreshed: %d vessels', len(info)) logger.info('static info refreshed: %d vessels', len(info))
except Exception as e: except Exception as e:
@ -285,6 +290,45 @@ class VesselStore:
except Exception as e: except Exception as e:
logger.error('fetch_permit_mmsis failed: %s', e) logger.error('fetch_permit_mmsis failed: %s', e)
# ------------------------------------------------------------------
# Signal-batch API 정적정보 보강
# ------------------------------------------------------------------
def enrich_from_signal_api(self, minutes: int = 10) -> None:
"""signal-batch recent-positions-detail API에서 정적정보 보강.
shipKindCode, status, heading, draught SNPDB에 없는 필드를 수집하여
_static_info에 merge. 기존 SNPDB 정적정보(name, vessel_type ) 유지.
Args:
minutes: API 조회 시간 범위 (기본 10, 초기 로드 120)
"""
from db.signal_api import signal_client, _KR_WATERS_POLYGON
try:
raw = signal_client.fetch_recent_detail(
minutes=minutes,
polygon=_KR_WATERS_POLYGON,
)
if not raw:
return
parsed = signal_client.parse_static_info(raw)
enriched = 0
for mmsi, new_info in parsed.items():
existing = self._static_info.get(mmsi, {})
# 신규 필드만 merge (기존 값 덮어쓰기)
existing.update(new_info)
self._static_info[mmsi] = existing
enriched += 1
logger.info(
'signal-batch enrich: %d vessels enriched (minutes=%d, api_total=%d)',
enriched, minutes, len(raw),
)
except Exception as e:
logger.warning('enrich_from_signal_api failed: %s', e)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Analysis target selection # Analysis target selection
# ------------------------------------------------------------------ # ------------------------------------------------------------------

파일 보기

@ -36,6 +36,10 @@ class Settings(BaseSettings):
MMSI_PREFIX: str = '412' MMSI_PREFIX: str = '412'
MIN_TRAJ_POINTS: int = 100 MIN_TRAJ_POINTS: int = 100
# signal-batch API (gc-signal-batch — 정적정보 보강용)
SIGNAL_BATCH_URL: str = 'http://192.168.1.18:18090/signal-batch'
SIGNAL_BATCH_TIMEOUT: int = 60
# Ollama (LLM) # Ollama (LLM)
OLLAMA_BASE_URL: str = 'http://localhost:11434' OLLAMA_BASE_URL: str = 'http://localhost:11434'
OLLAMA_MODEL: str = 'qwen3:14b' # CPU-only: 14b 권장, GPU 있으면 32b OLLAMA_MODEL: str = 'qwen3:14b' # CPU-only: 14b 권장, GPU 있으면 32b

파일 보기

@ -0,0 +1,45 @@
{
"description": "해양 감시영역 정의 — 환적/불법조업 탐지 범위. 향후 DB 테이블로 이관 + 프론트엔드 운영자 편집 가능.",
"zones": [
{
"id": "TS-001",
"name": "서해 EEZ 경계 (환적 고위험)",
"type": "TRANSSHIP",
"description": "서해 EEZ 외측 ~ 중국 근해 접경. 중국 어선-운반선 환적 주요 발생 해역.",
"polygon": [[124.0, 33.0], [125.8, 33.0], [125.8, 37.5], [124.0, 37.5], [124.0, 33.0]],
"enabled": true
},
{
"id": "TS-002",
"name": "남해 외해 (환적 관심)",
"type": "TRANSSHIP",
"description": "제주 남방 ~ 동중국해 접경. 원양 운반선 환적 경유 해역.",
"polygon": [[125.0, 31.5], [130.0, 31.5], [130.0, 33.5], [125.0, 33.5], [125.0, 31.5]],
"enabled": true
},
{
"id": "TS-003",
"name": "동해 북방 (환적 관심)",
"type": "TRANSSHIP",
"description": "동해 중부 ~ 일본해 경계. 오징어잡이 환적 발생 해역.",
"polygon": [[129.0, 36.0], [131.5, 36.0], [131.5, 38.5], [129.0, 38.5], [129.0, 36.0]],
"enabled": true
},
{
"id": "FZ-001",
"name": "서해 특정어업수역",
"type": "FISHING",
"description": "한중어업협정 특정어업수역. 불법조업 탐지 대상.",
"polygon": [[124.0, 34.5], [126.0, 34.5], [126.0, 37.0], [124.5, 37.0], [124.0, 36.0], [124.0, 34.5]],
"enabled": true
},
{
"id": "FZ-002",
"name": "제주 남방 수역",
"type": "FISHING",
"description": "제주 남방 EEZ 내 불법조업 빈발 해역.",
"polygon": [[125.5, 32.0], [127.5, 32.0], [127.5, 33.2], [125.5, 33.2], [125.5, 32.0]],
"enabled": true
}
]
}

174
prediction/db/signal_api.py Normal file
파일 보기

@ -0,0 +1,174 @@
"""signal-batch API 클라이언트 — gc-signal-batch(192.168.1.18:18090) HTTP 연동.
데이터 소스: S&P Global AIS API gc-signal-batch L0 캐시(Caffeine, 120 TTL)
용도: 정적정보 보강 (shipKindCode, status, heading, draught )
궤적 소스는 SNPDB 유지 모듈은 정적정보 전용.
호출 전략:
- 초기 로드: minutes=120 (L0 캐시 전체, 412* ~3,150)
- 5 주기 보강: minutes=10 (직전 2사이클분, 412* ~728)
- gc-signal-batch 수집 주기: :45 호출 시점 :50 이후 권장
"""
import logging
from typing import Optional
import httpx
from config import settings
logger = logging.getLogger(__name__)
_KR_WATERS_POLYGON = [[122, 31], [132, 31], [132, 39], [122, 39], [122, 31]]
class SignalBatchClient:
def __init__(
self,
base_url: str = '',
timeout_sec: int = 60,
) -> None:
self._base_url = (base_url or settings.SIGNAL_BATCH_URL).rstrip('/')
self._timeout = timeout_sec
def fetch_recent_detail(
self,
minutes: int = 10,
polygon: Optional[list[list[float]]] = None,
) -> list[dict]:
"""POST /api/v1/vessels/recent-positions-detail
L0 캐시(AisTargetCacheManager)에서 직접 조회.
공간 필터(폴리곤) 적용 가능.
Returns:
list[dict] RecentPositionDetailResponse 배열
dict 필드:
mmsi, imo, lon, lat, sog, cog, heading,
shipNm, shipTy(텍스트 선종), shipKindCode(6자리 코드),
nationalCode(MID 숫자), status(항해상태 텍스트),
destination, eta, draught, length, width, lastUpdate
"""
url = f'{self._base_url}/api/v1/vessels/recent-positions-detail'
body: dict = {'minutes': minutes}
if polygon:
body['coordinates'] = polygon
try:
with httpx.Client(timeout=self._timeout) as client:
resp = client.post(url, json=body)
resp.raise_for_status()
data = resp.json()
logger.info(
'fetch_recent_detail: %d vessels (minutes=%d)',
len(data), minutes,
)
return data
except httpx.TimeoutException:
logger.warning('fetch_recent_detail timeout (%ds)', self._timeout)
return []
except httpx.HTTPStatusError as e:
logger.warning('fetch_recent_detail HTTP %d: %s', e.response.status_code, e)
return []
except Exception as e:
logger.error('fetch_recent_detail failed: %s', e)
return []
def fetch_area_tracks(
self,
start_time: str,
end_time: str,
polygon: Optional[list[list[float]]] = None,
) -> dict:
"""POST /api/v2/tracks/area-search
D-1~D-7 인메모리 캐시 기반 항적 조회.
503 캐시 미준비 상태.
Returns:
dict AreaSearchResponse {tracks, hitDetails, summary}
"""
url = f'{self._base_url}/api/v2/tracks/area-search'
body = {
'startTime': start_time,
'endTime': end_time,
'polygons': [{
'id': 'kr-waters',
'name': '한국 해역',
'coordinates': polygon or _KR_WATERS_POLYGON,
}],
}
try:
with httpx.Client(timeout=self._timeout) as client:
resp = client.post(url, json=body)
resp.raise_for_status()
data = resp.json()
tracks = data.get('tracks', [])
logger.info(
'fetch_area_tracks: %d tracks (%s ~ %s)',
len(tracks), start_time, end_time,
)
return data
except httpx.HTTPStatusError as e:
if e.response.status_code == 503:
logger.warning('area-search: cache not ready (503)')
else:
logger.warning('area-search HTTP %d', e.response.status_code)
return {}
except Exception as e:
logger.error('fetch_area_tracks failed: %s', e)
return {}
@staticmethod
def parse_static_info(detail_response: list[dict]) -> dict[str, dict]:
"""recent-positions-detail 응답에서 정적정보 dict 추출.
Returns:
{mmsi: {ship_kind_code, ship_ty, national_code, status,
heading, draught, destination, name, length, width}}
"""
result: dict[str, dict] = {}
for item in detail_response:
mmsi = str(item.get('mmsi', ''))
if not mmsi:
continue
info: dict = {}
# 선종 (6자리 코드: 000020=어선, 000023=화물, 000028=미분류)
if item.get('shipKindCode'):
info['ship_kind_code'] = item['shipKindCode']
# 텍스트 선종 ("Cargo", "Tanker", "Vessel", "N/A")
if item.get('shipTy'):
info['ship_ty'] = item['shipTy']
# 국적 MID ("412"=중국, "440"=한국)
if item.get('nationalCode'):
info['national_code'] = item['nationalCode']
# 항해 상태 ("Under way using engine", "Anchored", "N/A")
if item.get('status') and item['status'] != 'N/A':
info['status'] = item['status']
# 선수 방향
if item.get('heading') is not None:
info['heading'] = float(item['heading'])
# 흘수
if item.get('draught') is not None and item['draught'] > 0:
info['draught'] = float(item['draught'])
# 목적지
if item.get('destination'):
info['destination'] = item['destination']
# 기본 정보 (기존 SNPDB 정적정보와 동일 필드)
if item.get('shipNm'):
info['name'] = item['shipNm']
if item.get('length') and item['length'] > 0:
info['length'] = int(item['length'])
if item.get('width') and item['width'] > 0:
info['width'] = int(item['width'])
if info:
result[mmsi] = info
return result
# 모듈 레벨 싱글턴
signal_client = SignalBatchClient()

파일 보기

@ -31,6 +31,10 @@ async def lifespan(application: FastAPI):
vessel_store.load_initial(settings.INITIAL_LOAD_HOURS) vessel_store.load_initial(settings.INITIAL_LOAD_HOURS)
logger.info('initial load complete: %s', vessel_store.stats()) logger.info('initial load complete: %s', vessel_store.stats())
# signal-batch API에서 정적정보 초기 보강 (120분 범위, 최대 커버리지)
vessel_store.enrich_from_signal_api(minutes=120)
logger.info('signal-batch enrich complete')
start_scheduler() start_scheduler()
yield yield
stop_scheduler() stop_scheduler()

파일 보기

@ -52,11 +52,12 @@ def _fetch_dark_history(kcg_conn, mmsi_list: list[str]) -> dict[str, dict]:
cur.execute( cur.execute(
""" """
SELECT mmsi, SELECT mmsi,
count(*) AS n7, count(DISTINCT analyzed_at::date) AS n7,
count(*) FILTER (WHERE analyzed_at > now() - interval '24 hours') AS n24, count(DISTINCT analyzed_at::date) FILTER (WHERE analyzed_at > now() - interval '24 hours') AS n24,
max(analyzed_at) AS last_at max(analyzed_at) AS last_at
FROM kcg.vessel_analysis_results FROM kcg.vessel_analysis_results
WHERE is_dark = true WHERE is_dark = true
AND gap_duration_min >= 100
AND analyzed_at > now() - interval '7 days' AND analyzed_at > now() - interval '7 days'
AND mmsi = ANY(%s) AND mmsi = ANY(%s)
GROUP BY mmsi GROUP BY mmsi
@ -107,6 +108,8 @@ def run_analysis_cycle():
# 정적정보 / 허가어선 주기적 갱신 # 정적정보 / 허가어선 주기적 갱신
vessel_store.refresh_static_info() vessel_store.refresh_static_info()
vessel_store.refresh_permit_registry() vessel_store.refresh_permit_registry()
# signal-batch API 정적정보 보강 (shipKindCode, status, heading, draught 등)
vessel_store.enrich_from_signal_api(minutes=10)
# 2. 분석 대상 선별 (SOG/COG 계산 포함) # 2. 분석 대상 선별 (SOG/COG 계산 포함)
df_targets = vessel_store.select_analysis_targets() df_targets = vessel_store.select_analysis_targets()
@ -247,9 +250,15 @@ def run_analysis_cycle():
dark = bool(gap_info.get('is_dark')) dark = bool(gap_info.get('is_dark'))
gap_min = int(gap_info.get('gap_min') or 0) gap_min = int(gap_info.get('gap_min') or 0)
history = dark_history_map.get(mmsi, {'count_7d': 0, 'count_24h': 0}) history = dark_history_map.get(mmsi, {'count_7d': 0, 'count_24h': 0})
v_info = vessel_store.get_vessel_info(mmsi)
last_cog_val = float(df_v.iloc[-1].get('cog', 0)) if len(df_v) > 0 else None
score, patterns, tier = compute_dark_suspicion( score, patterns, tier = compute_dark_suspicion(
gap_info, mmsi, is_permitted, history, gap_info, mmsi, is_permitted, history,
now_kst_hour, classify_zone, now_kst_hour, classify_zone,
ship_kind_code=v_info.get('ship_kind_code', ''),
nav_status=v_info.get('status', ''),
heading=v_info.get('heading'),
last_cog=last_cog_val,
) )
pipeline_dark_tiers[tier] = pipeline_dark_tiers.get(tier, 0) + 1 pipeline_dark_tiers[tier] = pipeline_dark_tiers.get(tier, 0) + 1
dark_features = { dark_features = {
@ -385,9 +394,15 @@ def run_analysis_cycle():
gap_min = int(gap_info.get('gap_min') or 0) gap_min = int(gap_info.get('gap_min') or 0)
history = dark_history_map.get(mmsi, {'count_7d': 0, 'count_24h': 0}) history = dark_history_map.get(mmsi, {'count_7d': 0, 'count_24h': 0})
lw_info = vessel_store.get_vessel_info(mmsi)
lw_last_cog = float(df_v.iloc[-1].get('cog', 0)) if df_v is not None and len(df_v) > 0 else None
score, patterns, tier = compute_dark_suspicion( score, patterns, tier = compute_dark_suspicion(
gap_info, mmsi, is_permitted, history, gap_info, mmsi, is_permitted, history,
now_kst_hour, classify_zone, now_kst_hour, classify_zone,
ship_kind_code=lw_info.get('ship_kind_code', ''),
nav_status=lw_info.get('status', ''),
heading=lw_info.get('heading'),
last_cog=lw_last_cog,
) )
lw_dark_tiers[tier] = lw_dark_tiers.get(tier, 0) + 1 lw_dark_tiers[tier] = lw_dark_tiers.get(tier, 0) + 1

파일 보기

@ -0,0 +1,326 @@
#!/bin/bash
# prediction 알고리즘 진단 스냅샷 수집기 (5분 주기, 수동 종료까지 연속 실행)
#
# 용도: 알고리즘 재설계 후 동작 검증용. 단순 집계가 아닌 개별 판정 과정 추적.
# 실행: nohup bash /home/apps/kcg-ai-prediction/scripts/diagnostic-snapshot.sh &
# 종료: kill $(cat /home/apps/kcg-ai-prediction/data/diag/diag.pid)
# 출력: /home/apps/kcg-ai-prediction/data/diag/YYYYMMDD-HHMM.txt
set -u
OUTDIR=/home/apps/kcg-ai-prediction/data/diag
mkdir -p "$OUTDIR"
echo $$ > "$OUTDIR/diag.pid"
export PGPASSWORD=Kcg2026ai
PSQL="psql -U kcg-app -d kcgaidb -h 211.208.115.83 -P pager=off -x"
PSQL_TABLE="psql -U kcg-app -d kcgaidb -h 211.208.115.83 -P pager=off"
INTERVAL_SEC=300 # 5분
while true; do
STAMP=$(date '+%Y%m%d-%H%M')
OUT="$OUTDIR/$STAMP.txt"
{
echo "###################################################################"
echo "# PREDICTION DIAGNOSTIC SNAPSHOT"
echo "# generated: $(date '+%Y-%m-%d %H:%M:%S %Z')"
echo "# host: $(hostname)"
echo "# interval: ${INTERVAL_SEC}s"
echo "###################################################################"
#===================================================================
# PART 1: 종합 지표
#===================================================================
echo ""
echo "================================================================="
echo "PART 1: 종합 지표 (last 5min)"
echo "================================================================="
$PSQL_TABLE << 'SQL'
SELECT count(*) total,
count(*) FILTER (WHERE vessel_type != 'UNKNOWN') pipeline,
count(*) FILTER (WHERE vessel_type = 'UNKNOWN') lightweight,
count(*) FILTER (WHERE is_dark) dark,
count(*) FILTER (WHERE transship_suspect) transship,
count(*) FILTER (WHERE risk_level='CRITICAL') crit,
count(*) FILTER (WHERE risk_level='HIGH') high,
round(avg(risk_score)::numeric, 1) avg_risk,
max(risk_score) max_risk,
round(count(*) FILTER (WHERE is_dark)::numeric / NULLIF(count(*), 0) * 100, 1) AS dark_pct
FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '5 minutes';
SQL
#===================================================================
# PART 2: 다크베셀 심층 진단
#===================================================================
echo ""
echo "================================================================="
echo "PART 2: DARK VESSEL 심층 진단"
echo "================================================================="
echo ""
echo "--- 2-1. dark_suspicion_score 히스토그램 ---"
$PSQL_TABLE << 'SQL'
SELECT CASE
WHEN (features->>'dark_suspicion_score')::int >= 90 THEN 'a_90-100 (CRITICAL_HIGH)'
WHEN (features->>'dark_suspicion_score')::int >= 70 THEN 'b_70-89 (CRITICAL)'
WHEN (features->>'dark_suspicion_score')::int >= 50 THEN 'c_50-69 (HIGH)'
WHEN (features->>'dark_suspicion_score')::int >= 30 THEN 'd_30-49 (WATCH)'
WHEN (features->>'dark_suspicion_score')::int >= 1 THEN 'e_1-29 (NONE_SCORED)'
ELSE 'f_0 (NOT_DARK)'
END bucket,
count(*) cnt,
round(avg(gap_duration_min)::numeric, 0) avg_gap_min,
round(avg(risk_score)::numeric, 1) avg_risk
FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '5 minutes'
AND is_dark = true
GROUP BY bucket ORDER BY bucket;
SQL
echo ""
echo "--- 2-2. dark_patterns 발동 빈도 (어떤 규칙이 얼마나 적용되는지) ---"
$PSQL_TABLE << 'SQL'
SELECT pattern,
count(*) cnt,
round(count(*)::numeric / NULLIF((SELECT count(*) FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '5 minutes' AND is_dark), 0) * 100, 1) AS pct
FROM kcg.vessel_analysis_results,
LATERAL jsonb_array_elements_text(features->'dark_patterns') AS pattern
WHERE analyzed_at > now() - interval '5 minutes'
AND is_dark = true
GROUP BY pattern ORDER BY cnt DESC;
SQL
echo ""
echo "--- 2-3. P9 선종별 dark 분포 (신규 패턴 검증) ---"
$PSQL_TABLE << 'SQL'
SELECT
CASE WHEN features->>'dark_patterns' LIKE '%fishing_vessel_dark%' THEN 'FISHING(+10)'
WHEN features->>'dark_patterns' LIKE '%cargo_natural_gap%' THEN 'CARGO(-10)'
ELSE 'NO_KIND_EFFECT' END AS p9_effect,
count(*) cnt,
round(avg((features->>'dark_suspicion_score')::int)::numeric, 1) avg_score,
round(avg(gap_duration_min)::numeric, 0) avg_gap
FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '5 minutes'
AND is_dark = true
GROUP BY p9_effect ORDER BY cnt DESC;
SQL
echo ""
echo "--- 2-4. P10 항해상태 dark 분포 (신규 패턴 검증) ---"
$PSQL_TABLE << 'SQL'
SELECT
CASE WHEN features->>'dark_patterns' LIKE '%underway_deliberate_off%' THEN 'UNDERWAY_OFF(+20)'
WHEN features->>'dark_patterns' LIKE '%anchored_natural_gap%' THEN 'ANCHORED(-15)'
ELSE 'NO_STATUS_EFFECT' END AS p10_effect,
count(*) cnt,
round(avg((features->>'dark_suspicion_score')::int)::numeric, 1) avg_score
FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '5 minutes'
AND is_dark = true
GROUP BY p10_effect ORDER BY cnt DESC;
SQL
echo ""
echo "--- 2-5. P11 heading/COG 불일치 (신규 패턴 검증) ---"
$PSQL_TABLE << 'SQL'
SELECT
CASE WHEN features->>'dark_patterns' LIKE '%heading_cog_mismatch%' THEN 'MISMATCH(+15)'
ELSE 'NO_MISMATCH' END AS p11_effect,
count(*) cnt,
round(avg((features->>'dark_suspicion_score')::int)::numeric, 1) avg_score
FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '5 minutes'
AND is_dark = true
GROUP BY p11_effect ORDER BY cnt DESC;
SQL
echo ""
echo "--- 2-6. GAP 구간별 dark_tier 교차표 (임계값 100분 검증) ---"
$PSQL_TABLE << 'SQL'
SELECT CASE
WHEN gap_duration_min < 100 THEN 'a_lt100 (NOT_DARK 예상)'
WHEN gap_duration_min < 180 THEN 'b_100-179'
WHEN gap_duration_min < 360 THEN 'c_180-359'
WHEN gap_duration_min < 720 THEN 'd_360-719'
ELSE 'e_gte720' END gap_bucket,
count(*) total,
count(*) FILTER (WHERE is_dark) dark,
count(*) FILTER (WHERE features->>'dark_tier' = 'CRITICAL') crit,
count(*) FILTER (WHERE features->>'dark_tier' = 'HIGH') high,
count(*) FILTER (WHERE features->>'dark_tier' = 'WATCH') watch,
count(*) FILTER (WHERE features->>'dark_tier' = 'NONE') tier_none
FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '5 minutes'
GROUP BY gap_bucket ORDER BY gap_bucket;
SQL
echo ""
echo "--- 2-7. CRITICAL dark 상위 10건 (개별 판정 상세) ---"
$PSQL_TABLE << 'SQL'
SELECT mmsi, gap_duration_min, zone_code, activity_state,
(features->>'dark_suspicion_score')::int AS score,
features->>'dark_tier' AS tier,
features->>'dark_patterns' AS patterns,
risk_score, risk_level
FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '5 minutes'
AND features->>'dark_tier' = 'CRITICAL'
ORDER BY (features->>'dark_suspicion_score')::int DESC
LIMIT 10;
SQL
#===================================================================
# PART 3: 환적 탐지 심층 진단
#===================================================================
echo ""
echo "================================================================="
echo "PART 3: TRANSSHIPMENT 심층 진단"
echo "================================================================="
echo ""
echo "--- 3-1. 환적 의심 건수 + 점수 분포 ---"
$PSQL_TABLE << 'SQL'
SELECT count(*) total_suspects,
count(*) FILTER (WHERE (features->>'transship_score')::numeric >= 70) critical,
count(*) FILTER (WHERE (features->>'transship_score')::numeric >= 50
AND (features->>'transship_score')::numeric < 70) high,
round(avg((features->>'transship_score')::numeric)::numeric, 1) avg_score,
round(avg(transship_duration_min)::numeric, 0) avg_duration_min
FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '5 minutes'
AND transship_suspect = true;
SQL
echo ""
echo "--- 3-2. 환적 의심 개별 건 상세 (전체) ---"
$PSQL_TABLE << 'SQL'
SELECT mmsi, transship_pair_mmsi AS pair_mmsi,
transship_duration_min AS dur_min,
(features->>'transship_score')::numeric AS score,
features->>'transship_tier' AS tier,
zone_code,
activity_state,
risk_score
FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '5 minutes'
AND transship_suspect = true
ORDER BY (features->>'transship_score')::numeric DESC;
SQL
echo ""
echo "--- 3-3. 환적 후보 선종 분포 (Stage 1 이종 쌍 검증) ---"
echo " (이 쿼리는 journalctl 로그에서 추출)"
journalctl -u kcg-ai-prediction --since '6 minutes ago' --no-pager 2>/dev/null | \
grep -o 'transshipment:.*' | tail -1
#===================================================================
# PART 4: 이벤트 + KPI
#===================================================================
echo ""
echo "================================================================="
echo "PART 4: 이벤트 + KPI (시스템 출력 검증)"
echo "================================================================="
echo ""
echo "--- 4-1. prediction_events (last 5min) ---"
$PSQL_TABLE << 'SQL'
SELECT category, level, count(*) cnt
FROM kcg.prediction_events
WHERE created_at > now() - interval '5 minutes'
GROUP BY category, level ORDER BY cnt DESC;
SQL
echo ""
echo "--- 4-2. KPI 실시간 ---"
$PSQL_TABLE << 'SQL'
SELECT kpi_key, value, trend, delta_pct, updated_at
FROM kcg.prediction_kpi_realtime ORDER BY kpi_key;
SQL
#===================================================================
# PART 5: signal-batch 정적정보 보강 검증
#===================================================================
echo ""
echo "================================================================="
echo "PART 5: signal-batch 정적정보 보강 검증"
echo "================================================================="
echo ""
echo "--- 5-1. 직전 사이클 enrich 로그 ---"
journalctl -u kcg-ai-prediction --since '6 minutes ago' --no-pager 2>/dev/null | \
grep -E 'signal-batch enrich|fetch_recent_detail' | tail -2
echo ""
echo "--- 5-2. features 내 신규 패턴(P9/P10/P11) 적용 비율 ---"
$PSQL_TABLE << 'SQL'
WITH dark_vessels AS (
SELECT features FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '5 minutes' AND is_dark = true
)
SELECT
count(*) AS total_dark,
count(*) FILTER (WHERE features->>'dark_patterns' LIKE '%fishing_vessel_dark%'
OR features->>'dark_patterns' LIKE '%cargo_natural_gap%') AS p9_applied,
count(*) FILTER (WHERE features->>'dark_patterns' LIKE '%underway_deliberate_off%'
OR features->>'dark_patterns' LIKE '%anchored_natural_gap%') AS p10_applied,
count(*) FILTER (WHERE features->>'dark_patterns' LIKE '%heading_cog_mismatch%') AS p11_applied,
round(count(*) FILTER (WHERE features->>'dark_patterns' LIKE '%fishing_vessel_dark%'
OR features->>'dark_patterns' LIKE '%cargo_natural_gap%')::numeric
/ NULLIF(count(*), 0) * 100, 1) AS p9_pct,
round(count(*) FILTER (WHERE features->>'dark_patterns' LIKE '%underway_deliberate_off%'
OR features->>'dark_patterns' LIKE '%anchored_natural_gap%')::numeric
/ NULLIF(count(*), 0) * 100, 1) AS p10_pct,
round(count(*) FILTER (WHERE features->>'dark_patterns' LIKE '%heading_cog_mismatch%')::numeric
/ NULLIF(count(*), 0) * 100, 1) AS p11_pct
FROM dark_vessels;
SQL
#===================================================================
# PART 6: 사이클 로그 (직전 6분)
#===================================================================
echo ""
echo "================================================================="
echo "PART 6: 사이클 로그 (최근 6분)"
echo "================================================================="
journalctl -u kcg-ai-prediction --since '6 minutes ago' --no-pager 2>/dev/null | \
grep -E 'analysis cycle:|lightweight analysis:|pipeline dark:|event_generator:|kpi_writer:|stats_aggregator|enrich|transship|ERROR|Traceback' | \
tail -20
#===================================================================
# PART 7: 해역별 + 위험도 교차 (운영 지표)
#===================================================================
echo ""
echo "================================================================="
echo "PART 7: 해역별 × 위험도 교차표"
echo "================================================================="
$PSQL_TABLE << 'SQL'
SELECT zone_code,
count(*) total,
count(*) FILTER (WHERE is_dark) dark,
count(*) FILTER (WHERE risk_level='CRITICAL') crit,
count(*) FILTER (WHERE risk_level='HIGH') high,
round(avg(risk_score)::numeric, 1) avg_risk,
count(*) FILTER (WHERE transship_suspect) transship
FROM kcg.vessel_analysis_results
WHERE analyzed_at > now() - interval '5 minutes'
GROUP BY zone_code ORDER BY total DESC;
SQL
echo ""
echo "================================================================="
echo "END OF SNAPSHOT $STAMP"
echo "================================================================="
} > "$OUT" 2>&1
echo "[diag] $(date '+%H:%M:%S') saved: $OUT ($(wc -l < "$OUT") lines)"
sleep $INTERVAL_SEC
done