Merge remote-tracking branch 'origin/feature/performance-monitoring-menu' into merge-check/performance-monitoring-menu-into-develop
This commit is contained in:
커밋
d12c81f233
@ -0,0 +1,28 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- V026: 데이터 보관기간 및 파기 정책 (DAR-10) 메뉴 추가
|
||||||
|
-- 시스템관리 > 감사·보안 서브그룹
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- 1. 권한 트리 노드 등록
|
||||||
|
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
|
||||||
|
VALUES ('admin:data-retention', 'admin', '데이터 보관·파기', 1, 57)
|
||||||
|
ON CONFLICT (rsrc_cd) DO NOTHING;
|
||||||
|
|
||||||
|
-- 2. 메뉴 메타데이터 갱신
|
||||||
|
UPDATE kcg.auth_perm_tree
|
||||||
|
SET url_path = '/admin/data-retention',
|
||||||
|
label_key = 'nav.dataRetentionPolicy',
|
||||||
|
component_key = 'features/admin/DataRetentionPolicy',
|
||||||
|
nav_group = 'admin',
|
||||||
|
nav_sub_group = '감사·보안',
|
||||||
|
nav_sort = 2000,
|
||||||
|
labels = '{"ko":"데이터 보관·파기","en":"Data Retention"}'
|
||||||
|
WHERE rsrc_cd = 'admin:data-retention';
|
||||||
|
|
||||||
|
-- 3. ADMIN 역할에 전체 권한 부여
|
||||||
|
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||||
|
SELECT r.role_sn, 'admin:data-retention', 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;
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- V027: 데이터 모델 검증 (DAR-11) 메뉴 추가
|
||||||
|
-- 시스템관리 > 감사·보안 서브그룹
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- 1. 권한 트리 노드 등록
|
||||||
|
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
|
||||||
|
VALUES ('admin:data-model-verification', 'admin', '데이터 모델 검증', 1, 58)
|
||||||
|
ON CONFLICT (rsrc_cd) DO NOTHING;
|
||||||
|
|
||||||
|
-- 2. 메뉴 메타데이터 갱신
|
||||||
|
UPDATE kcg.auth_perm_tree
|
||||||
|
SET url_path = '/admin/data-model-verification',
|
||||||
|
label_key = 'nav.dataModelVerification',
|
||||||
|
component_key = 'features/admin/DataModelVerification',
|
||||||
|
nav_group = 'admin',
|
||||||
|
nav_sub_group = '감사·보안',
|
||||||
|
nav_sort = 2100,
|
||||||
|
labels = '{"ko":"데이터 모델 검증","en":"Model Verification"}'
|
||||||
|
WHERE rsrc_cd = 'admin:data-model-verification';
|
||||||
|
|
||||||
|
-- 3. ADMIN 역할에 전체 권한 부여
|
||||||
|
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||||
|
SELECT r.role_sn, 'admin:data-model-verification', 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;
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- V028: 성능 모니터링 (PER-01~06) 메뉴 추가
|
||||||
|
-- 시스템관리 > 감사·보안 서브그룹
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- 1. 권한 트리 노드 등록
|
||||||
|
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
|
||||||
|
VALUES ('admin:performance-monitoring', 'admin', '성능 모니터링', 1, 59)
|
||||||
|
ON CONFLICT (rsrc_cd) DO NOTHING;
|
||||||
|
|
||||||
|
-- 2. 메뉴 메타데이터 갱신
|
||||||
|
UPDATE kcg.auth_perm_tree
|
||||||
|
SET url_path = '/admin/performance-monitoring',
|
||||||
|
label_key = 'nav.performanceMonitoring',
|
||||||
|
component_key = 'features/admin/PerformanceMonitoring',
|
||||||
|
nav_group = 'admin',
|
||||||
|
nav_sub_group = '감사·보안',
|
||||||
|
nav_sort = 2110,
|
||||||
|
labels = '{"ko":"성능 모니터링","en":"Performance Monitoring"}'
|
||||||
|
WHERE rsrc_cd = 'admin:performance-monitoring';
|
||||||
|
|
||||||
|
-- 3. ADMIN 역할에 전체 권한 부여
|
||||||
|
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||||
|
SELECT r.role_sn, 'admin:performance-monitoring', op.oper_cd, 'Y'
|
||||||
|
FROM kcg.auth_role r
|
||||||
|
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
|
||||||
|
WHERE r.role_cd = 'ADMIN'
|
||||||
|
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
|
||||||
BIN
frontend/public/dar03/bottom-trawl.png
Normal file
BIN
frontend/public/dar03/bottom-trawl.png
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 72 KiB |
BIN
frontend/public/dar03/gillnet.png
Normal file
BIN
frontend/public/dar03/gillnet.png
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 51 KiB |
BIN
frontend/public/dar03/pair-trawl.png
Normal file
BIN
frontend/public/dar03/pair-trawl.png
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 69 KiB |
BIN
frontend/public/dar03/pot-trap.png
Normal file
BIN
frontend/public/dar03/pot-trap.png
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 67 KiB |
BIN
frontend/public/dar03/stow-net.png
Normal file
BIN
frontend/public/dar03/stow-net.png
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 54 KiB |
@ -122,6 +122,15 @@ export const COMPONENT_REGISTRY: Record<string, LazyComponent> = {
|
|||||||
'features/admin/AIAgentSecurityPage': lazy(() =>
|
'features/admin/AIAgentSecurityPage': lazy(() =>
|
||||||
import('@features/admin').then((m) => ({ default: m.AIAgentSecurityPage })),
|
import('@features/admin').then((m) => ({ default: m.AIAgentSecurityPage })),
|
||||||
),
|
),
|
||||||
|
'features/admin/DataRetentionPolicy': lazy(() =>
|
||||||
|
import('@features/admin').then((m) => ({ default: m.DataRetentionPolicy })),
|
||||||
|
),
|
||||||
|
'features/admin/DataModelVerification': lazy(() =>
|
||||||
|
import('@features/admin').then((m) => ({ default: m.DataModelVerification })),
|
||||||
|
),
|
||||||
|
'features/admin/PerformanceMonitoring': lazy(() =>
|
||||||
|
import('@features/admin').then((m) => ({ default: m.PerformanceMonitoring })),
|
||||||
|
),
|
||||||
// ── 모선 워크플로우 ──
|
// ── 모선 워크플로우 ──
|
||||||
'features/parent-inference/ParentReview': lazy(() =>
|
'features/parent-inference/ParentReview': lazy(() =>
|
||||||
import('@features/parent-inference/ParentReview').then((m) => ({
|
import('@features/parent-inference/ParentReview').then((m) => ({
|
||||||
|
|||||||
378
frontend/src/features/admin/DataModelVerification.tsx
Normal file
378
frontend/src/features/admin/DataModelVerification.tsx
Normal file
@ -0,0 +1,378 @@
|
|||||||
|
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 {
|
||||||
|
Database, CheckCircle, AlertTriangle, FileText, Users,
|
||||||
|
Layers, Table2, Search, ChevronRight, GitBranch,
|
||||||
|
Shield, Eye, ListChecks, ClipboardCheck,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* DAR-11: 데이터 모델 검증
|
||||||
|
*
|
||||||
|
* 설계된 데이터 모델을 설계자·개발자·해양경찰청·전문가 등이 참여하여
|
||||||
|
* 각 단계별 데이터 모델 검증 시 기준을 정의·실시한다.
|
||||||
|
*
|
||||||
|
* ① 검증 현황 ② 논리 모델 검증 ③ 물리 모델 검증 ④ 중복·정합성 점검 ⑤ 검증 결과 이력
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Tab = 'overview' | 'logical' | 'physical' | 'duplication' | 'history';
|
||||||
|
|
||||||
|
// ─── 검증 현황 KPI ──────────────────
|
||||||
|
const VERIFICATION_KPI = [
|
||||||
|
{ label: '전체 테이블', value: '48개', icon: Table2, color: '#3b82f6' },
|
||||||
|
{ label: '논리 모델 검증', value: '완료', icon: GitBranch, color: '#10b981' },
|
||||||
|
{ label: '물리 모델 검증', value: '완료', icon: Database, color: '#8b5cf6' },
|
||||||
|
{ label: '중복 테이블', value: '0건', icon: Search, color: '#06b6d4' },
|
||||||
|
{ label: '미해결 이슈', value: '1건', icon: AlertTriangle, color: '#f59e0b' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 검증 참여자 ──────────────────
|
||||||
|
const PARTICIPANTS = [
|
||||||
|
{ role: '데이터 설계자', name: '정해진 (주무관)', responsibility: '논리·물리 모델 설계, ERD 작성', badge: 'critical' as const },
|
||||||
|
{ role: '백엔드 개발자', name: '이상호 (경위)', responsibility: '마이그레이션 구현, 인덱스·성능 최적화', badge: 'info' as const },
|
||||||
|
{ role: '해양경찰청 담당관', name: '김영수 (사무관)', responsibility: '요구사항 대비 완전성 검토·승인', badge: 'warning' as const },
|
||||||
|
{ role: 'DB 전문가', name: '외부 자문위원', responsibility: '정규화·반정규화 타당성, 성능 검증', badge: 'success' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 검증 절차 (4단계) ──────────────────
|
||||||
|
const VERIFICATION_PHASES = [
|
||||||
|
{ phase: '① 검증 계획 수립', actions: ['검증 범위·기준·일정 확정', '참여자 역할 분담', '체크리스트 작성'], responsible: '데이터 설계자', icon: FileText },
|
||||||
|
{ phase: '② 논리 모델 검증', actions: ['요구사항 대비 완전성 확인', '엔티티·속성·관계 정합성', '정규화 수준 적정성 검토'], responsible: '설계자 + 담당관', icon: GitBranch },
|
||||||
|
{ phase: '③ 물리 모델 검증', actions: ['테이블·컬럼·인덱스 구조 확인', '데이터 타입·제약조건 적정성', '성능 관점 반정규화 타당성'], responsible: '개발자 + DB전문가', icon: Database },
|
||||||
|
{ phase: '④ 결과 보고·조치', actions: ['검증 결과서 작성', '미해결 이슈 추적·조치', '최종 승인 및 이력 등록'], responsible: '전체 참여자', icon: ClipboardCheck },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 논리 모델 검증 항목 ──────────────────
|
||||||
|
const LOGICAL_CHECKS = [
|
||||||
|
{ category: '완전성', item: '요구사항 커버리지', desc: '48개 테이블이 SFR/DAR 전체 요구사항을 충족하는지 매핑 확인', result: '100% (48/48)', status: '통과' },
|
||||||
|
{ category: '완전성', item: '엔티티 누락 여부', desc: '식별된 비즈니스 영역(인증·탐지·단속·통계·관리) 대비 누락 엔티티 점검', result: '누락 0건', status: '통과' },
|
||||||
|
{ category: '정합성', item: '엔티티 관계 정의', desc: '외래키 관계, 참조 무결성, 카디널리티(1:N, M:N) 적정성', result: '78개 관계 확인', status: '통과' },
|
||||||
|
{ category: '정합성', item: '속성 정의 명확성', desc: '속성명 명명규칙, 도메인 정의, NULL 허용 정책 준수', result: '전체 준수', status: '통과' },
|
||||||
|
{ category: '정규화', item: '제3정규형 준수', desc: '함수 종속성 분석, 이행 종속 제거, 제3정규형(3NF) 이상 달성', result: '48/48 테이블', status: '통과' },
|
||||||
|
{ category: '정규화', item: '반정규화 타당성', desc: '성능 목적 반정규화 항목의 타당성 및 정합성 관리 방안', result: '3건 (타당)', status: '통과' },
|
||||||
|
{ category: '표준', item: '명명규칙 준수', desc: '테이블·컬럼·인덱스 명명규칙 (snake_case, kcg 스키마 접두어)', result: '전체 준수', status: '통과' },
|
||||||
|
{ category: '표준', item: '코드성 데이터 표준화', desc: '상태코드·유형코드 등 코드 마스터 테이블 일원화', result: 'code_master 통합', status: '통과' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 물리 모델 검증 항목 ──────────────────
|
||||||
|
const PHYSICAL_CHECKS = [
|
||||||
|
{ category: '구조', item: '테이블 스페이스 배치', desc: 'PostgreSQL kcg 스키마 내 48개 테이블 배치 적정성', result: '적정', status: '통과' },
|
||||||
|
{ category: '구조', item: '파티셔닝 전략', desc: 'AIS 위치 로그 등 대용량 테이블 파티셔닝 적용 여부', result: 'TimescaleDB hypertable', status: '통과' },
|
||||||
|
{ category: '데이터 타입', item: '컬럼 타입 적정성', desc: 'VARCHAR 길이, NUMERIC 정밀도, TIMESTAMP 타임존 설정', result: '전체 적정', status: '통과' },
|
||||||
|
{ category: '데이터 타입', item: 'JSON vs 정규 컬럼', desc: 'JSONB 사용 항목의 타당성 (스키마 유연성 vs 쿼리 성능)', result: '3개 테이블 JSONB (타당)', status: '통과' },
|
||||||
|
{ category: '인덱스', item: '기본키·외래키 인덱스', desc: 'PK/FK 인덱스 자동 생성 확인, 복합키 순서 적정성', result: '전체 생성 확인', status: '통과' },
|
||||||
|
{ category: '인덱스', item: '조회 성능 인덱스', desc: '고빈도 조회 패턴 분석 기반 추가 인덱스 설계', result: '12개 추가 인덱스', status: '통과' },
|
||||||
|
{ category: '제약조건', item: 'NOT NULL·CHECK·UNIQUE', desc: '필수값 제약, 범위 체크, 유일성 제약 적용 현황', result: '전체 적용', status: '통과' },
|
||||||
|
{ category: '제약조건', item: '참조 무결성 (FK)', desc: '외래키 ON DELETE/UPDATE 정책 (CASCADE/RESTRICT)', result: '정책 수립 완료', status: '통과' },
|
||||||
|
{ category: '성능', item: '쿼리 실행 계획 검증', desc: '주요 조회 쿼리 EXPLAIN ANALYZE 검증', result: 'P95 < 100ms', status: '통과' },
|
||||||
|
{ category: '성능', item: '대량 INSERT 성능', desc: 'AIS 5분 주기 배치 INSERT 성능 검증 (bulk insert)', result: '10K rows/sec', status: '주의' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 중복·정합성 점검 ──────────────────
|
||||||
|
const DUPLICATION_CHECKS = [
|
||||||
|
{ target: '테이블 중복', desc: '동일 목적의 테이블이 중복 존재하는지 점검', scope: '48개 테이블', result: '중복 0건', status: '통과' },
|
||||||
|
{ target: '컬럼 중복', desc: '동일 비즈니스 의미의 컬럼이 다른 이름으로 존재하는지 점검', scope: '전체 컬럼', result: '중복 0건', status: '통과' },
|
||||||
|
{ target: '반정규화 정합성', desc: '반정규화로 인한 중복 데이터의 동기화 방안 확인', scope: '3개 반정규화 항목', result: '트리거 기반 동기화', status: '통과' },
|
||||||
|
{ target: '코드값 일관성', desc: '상태코드·유형코드가 code_master 통해 일원 관리되는지 확인', scope: '19개 코드 카탈로그', result: '전체 일원화', status: '통과' },
|
||||||
|
{ target: '외래키 정합성', desc: '참조 대상 레코드 삭제 시 고아 레코드 발생 여부 점검', scope: '78개 FK 관계', result: '고아 0건', status: '통과' },
|
||||||
|
{ target: '스키마 간 정합성', desc: 'prediction 분석 결과 → kcgaidb 연계 시 데이터 타입 일치', scope: 'SNPDB ↔ kcgaidb', result: '타입 일치 확인', status: '통과' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 데이터 주제영역 ──────────────────
|
||||||
|
const SUBJECT_AREAS = [
|
||||||
|
{ area: '인증·권한', tables: 'auth_user, auth_role, auth_perm_tree, auth_perm, auth_user_role', count: 5, desc: '사용자·역할·권한 트리 기반 RBAC' },
|
||||||
|
{ area: 'AIS·선박', tables: 'vessel_master, ais_position, vessel_permit', count: 3, desc: 'AIS 수신 데이터, 선박 기본정보, 허가 현황' },
|
||||||
|
{ area: '탐지·분석', tables: 'vessel_analysis, dark_vessel, gear_detection, transship_detection, ...', count: 12, desc: '14개 알고리즘 분석 결과 저장' },
|
||||||
|
{ area: '단속·이벤트', tables: 'prediction_event, enforcement_plan, enforcement_record, ...', count: 8, desc: '이벤트 발생·단속 계획·단속 이력' },
|
||||||
|
{ area: '모선 워크플로우', tables: 'parent_review, parent_exclusion, label_session, ...', count: 5, desc: '모선 확정·제외·학습 워크플로우' },
|
||||||
|
{ area: '순찰·함정', tables: 'patrol_ship, patrol_route, fleet_optimization, ...', count: 6, desc: '함정 관리·순찰 경로·최적화' },
|
||||||
|
{ area: '통계·감사', tables: 'statistics_daily, audit_log, access_log, login_history, ...', count: 5, desc: '통계 집계·감사 로그·접근 이력' },
|
||||||
|
{ area: '시스템·관리', tables: 'code_master, zone_polygon, gear_type_master, notice, ...', count: 4, desc: '코드 마스터·구역 정보·공지사항' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 검증 결과 이력 ──────────────────
|
||||||
|
const VERIFICATION_HISTORY = [
|
||||||
|
{ id: 'VER-2026-005', date: '2026-04-10', phase: '물리 모델 검증', reviewer: '이상호, 외부 자문', target: 'V015 NUMERIC 정밀도 조정', issues: 0, result: '통과' },
|
||||||
|
{ id: 'VER-2026-004', date: '2026-04-07', phase: '중복·정합성 점검', reviewer: '정해진', target: '반정규화 3건 정합성', issues: 0, result: '통과' },
|
||||||
|
{ id: 'VER-2026-003', date: '2026-04-03', phase: '논리 모델 검증', reviewer: '김영수, 정해진', target: 'V014 함정·예측 테이블 추가', issues: 1, result: '조건부 통과' },
|
||||||
|
{ id: 'VER-2026-002', date: '2026-03-28', phase: '물리 모델 검증', reviewer: '이상호', target: 'V012~V013 이벤트·단속 테이블', issues: 0, result: '통과' },
|
||||||
|
{ id: 'VER-2026-001', date: '2026-03-20', phase: '논리 모델 검증', reviewer: '전체 참여', target: '초기 V001~V011 전체 구조', issues: 3, result: '조건부 통과' },
|
||||||
|
{ id: 'VER-2025-012', date: '2025-12-15', phase: '검증 계획 수립', reviewer: '김영수', target: '검증 계획서 v1.0 확정', issues: 0, result: '승인' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DataModelVerification() {
|
||||||
|
const [tab, setTab] = useState<Tab>('overview');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
icon={ListChecks}
|
||||||
|
iconColor="text-green-400"
|
||||||
|
title="데이터 모델 검증"
|
||||||
|
description="DAR-11 | 논리·물리 데이터 모델 검증 기준 정의·실시 및 결과 관리"
|
||||||
|
demo
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 탭 */}
|
||||||
|
<div className="flex gap-0 border-b border-border">
|
||||||
|
{([
|
||||||
|
{ key: 'overview' as Tab, icon: Eye, label: '검증 현황' },
|
||||||
|
{ key: 'logical' as Tab, icon: GitBranch, label: '논리 모델 검증' },
|
||||||
|
{ key: 'physical' as Tab, icon: Database, label: '물리 모델 검증' },
|
||||||
|
{ key: 'duplication' as Tab, icon: Search, label: '중복·정합성 점검' },
|
||||||
|
{ key: 'history' as Tab, icon: FileText, label: '검증 결과 이력' },
|
||||||
|
]).map(t => (
|
||||||
|
<button type="button" key={t.key} onClick={() => setTab(t.key)}
|
||||||
|
className={`flex items-center gap-1.5 px-4 py-2.5 text-[11px] font-medium border-b-2 transition-colors ${tab === t.key ? 'text-green-400 border-green-400' : 'text-hint border-transparent hover:text-label'}`}>
|
||||||
|
<t.icon className="w-3.5 h-3.5" />{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── ① 검증 현황 ── */}
|
||||||
|
{tab === 'overview' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{VERIFICATION_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="text-lg font-bold" style={{ color: k.color }}>{k.value}</div>
|
||||||
|
<div className="text-[9px] text-hint">{k.label}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검증 절차 */}
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<ClipboardCheck className="w-4 h-4 text-green-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">검증 절차 (4단계)</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{VERIFICATION_PHASES.map((s, i) => (
|
||||||
|
<div key={s.phase} className="relative">
|
||||||
|
{i < VERIFICATION_PHASES.length - 1 && (
|
||||||
|
<ChevronRight className="absolute -right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-border z-10" />
|
||||||
|
)}
|
||||||
|
<div className="bg-surface-overlay rounded-lg p-3 h-full">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<s.icon className="w-4 h-4 text-green-400" />
|
||||||
|
<span className="text-[11px] font-bold text-green-400">{s.phase}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-cyan-400 mb-2">{s.responsible}</div>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{s.actions.map(a => (
|
||||||
|
<li key={a} className="flex items-start gap-1.5 text-[9px] text-muted-foreground">
|
||||||
|
<CheckCircle className="w-3 h-3 text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<span>{a}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
{/* 참여자 + 주제영역 */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Users className="w-4 h-4 text-blue-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">검증 참여자</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{PARTICIPANTS.map(p => (
|
||||||
|
<div key={p.role} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||||
|
<Badge intent={p.badge} size="sm">{p.role}</Badge>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-[11px] text-heading font-medium">{p.name}</div>
|
||||||
|
<div className="text-[9px] text-hint">{p.responsibility}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Layers className="w-4 h-4 text-purple-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">데이터 주제영역 ({SUBJECT_AREAS.reduce((s, a) => s + a.count, 0)}개 테이블)</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{SUBJECT_AREAS.map(a => (
|
||||||
|
<div key={a.area} className="flex items-center gap-2 px-2 py-1.5 bg-surface-overlay rounded text-[10px]">
|
||||||
|
<Badge intent="muted" size="xs">{a.count}개</Badge>
|
||||||
|
<span className="text-heading font-medium w-24 shrink-0">{a.area}</span>
|
||||||
|
<span className="text-hint text-[9px] truncate flex-1">{a.desc}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ② 논리 모델 검증 ── */}
|
||||||
|
{tab === 'logical' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<GitBranch className="w-4 h-4 text-green-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">논리 데이터 모델 검증 기준 및 결과</span>
|
||||||
|
<Badge intent="success" size="xs">{LOGICAL_CHECKS.filter(c => c.status === '통과').length}/{LOGICAL_CHECKS.length} 통과</Badge>
|
||||||
|
</div>
|
||||||
|
<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-left py-2">상세 기준</th>
|
||||||
|
<th className="text-center py-2">검증 결과</th>
|
||||||
|
<th className="text-center py-2">판정</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{LOGICAL_CHECKS.map(c => (
|
||||||
|
<tr key={c.item} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||||
|
<td className="py-2.5 px-2"><Badge intent="muted" size="xs">{c.category}</Badge></td>
|
||||||
|
<td className="py-2.5 text-heading font-medium">{c.item}</td>
|
||||||
|
<td className="py-2.5 text-muted-foreground text-[9px]">{c.desc}</td>
|
||||||
|
<td className="py-2.5 text-center text-cyan-400 font-medium text-[9px]">{c.result}</td>
|
||||||
|
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(c.status)} size="sm">{c.status}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">데이터 주제영역별 엔티티 구성</div>
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{SUBJECT_AREAS.map(a => (
|
||||||
|
<div key={a.area} className="bg-surface-overlay rounded-lg p-3">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-[11px] font-bold text-heading">{a.area}</span>
|
||||||
|
<Badge intent="info" size="xs">{a.count}개</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-[8px] text-hint mb-1.5">{a.desc}</div>
|
||||||
|
<div className="text-[8px] text-muted-foreground font-mono leading-relaxed">{a.tables}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ③ 물리 모델 검증 ── */}
|
||||||
|
{tab === 'physical' && (
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Database className="w-4 h-4 text-purple-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">물리 데이터 모델 검증 기준 및 결과</span>
|
||||||
|
<Badge intent="success" size="xs">{PHYSICAL_CHECKS.filter(c => c.status === '통과').length}/{PHYSICAL_CHECKS.length} 통과</Badge>
|
||||||
|
{PHYSICAL_CHECKS.some(c => c.status === '주의') && (
|
||||||
|
<Badge intent="warning" size="xs">{PHYSICAL_CHECKS.filter(c => c.status === '주의').length}건 주의</Badge>
|
||||||
|
)}
|
||||||
|
</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-left py-2">상세 기준</th>
|
||||||
|
<th className="text-center py-2">검증 결과</th>
|
||||||
|
<th className="text-center py-2">판정</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{PHYSICAL_CHECKS.map(c => (
|
||||||
|
<tr key={c.item} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||||
|
<td className="py-2.5 px-2"><Badge intent="muted" size="xs">{c.category}</Badge></td>
|
||||||
|
<td className="py-2.5 text-heading font-medium">{c.item}</td>
|
||||||
|
<td className="py-2.5 text-muted-foreground text-[9px]">{c.desc}</td>
|
||||||
|
<td className="py-2.5 text-center text-cyan-400 font-medium text-[9px]">{c.result}</td>
|
||||||
|
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(c.status)} size="sm">{c.status}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ④ 중복·정합성 점검 ── */}
|
||||||
|
{tab === 'duplication' && (
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Shield className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">중복 테이블·컬럼 및 데이터 정합성 점검</span>
|
||||||
|
<Badge intent="success" size="xs">{DUPLICATION_CHECKS.filter(c => c.status === '통과').length}/{DUPLICATION_CHECKS.length} 통과</Badge>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{DUPLICATION_CHECKS.map(c => (
|
||||||
|
<tr key={c.target} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||||
|
<td className="py-2.5 px-2 text-heading font-medium">{c.target}</td>
|
||||||
|
<td className="py-2.5 text-muted-foreground text-[9px]">{c.desc}</td>
|
||||||
|
<td className="py-2.5 text-center"><Badge intent="muted" size="xs">{c.scope}</Badge></td>
|
||||||
|
<td className="py-2.5 text-center text-cyan-400 font-medium text-[9px]">{c.result}</td>
|
||||||
|
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(c.status)} size="sm">{c.status}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ⑤ 검증 결과 이력 ── */}
|
||||||
|
{tab === 'history' && (
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<FileText className="w-4 h-4 text-blue-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">검증 결과 이력</span>
|
||||||
|
<Badge intent="info" size="xs">{VERIFICATION_HISTORY.length}건</Badge>
|
||||||
|
</div>
|
||||||
|
<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-center py-2">일시</th>
|
||||||
|
<th className="text-center py-2">단계</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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{VERIFICATION_HISTORY.map(h => (
|
||||||
|
<tr key={h.id} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||||
|
<td className="py-2.5 px-2 text-hint font-mono text-[9px]">{h.id}</td>
|
||||||
|
<td className="py-2.5 text-center text-muted-foreground">{h.date}</td>
|
||||||
|
<td className="py-2.5 text-center"><Badge intent="muted" size="xs">{h.phase}</Badge></td>
|
||||||
|
<td className="py-2.5 text-muted-foreground">{h.reviewer}</td>
|
||||||
|
<td className="py-2.5 text-heading font-medium text-[9px]">{h.target}</td>
|
||||||
|
<td className="py-2.5 text-center">{h.issues > 0 ? <span className="text-yellow-400 font-bold">{h.issues}건</span> : <span className="text-green-400">0건</span>}</td>
|
||||||
|
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(h.result)} size="sm">{h.result}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
432
frontend/src/features/admin/DataRetentionPolicy.tsx
Normal file
432
frontend/src/features/admin/DataRetentionPolicy.tsx
Normal file
@ -0,0 +1,432 @@
|
|||||||
|
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 {
|
||||||
|
Database, Clock, Trash2, ShieldCheck, FileText, AlertTriangle,
|
||||||
|
CheckCircle, Archive, CalendarClock, UserCheck, Search,
|
||||||
|
ChevronRight, Lock, Eye, Settings,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* DAR-10: 데이터 보관기간 및 파기 정책
|
||||||
|
*
|
||||||
|
* 법령 내 규정 및 유관기관 협의에 따라 데이터 유형별 보관기간 및
|
||||||
|
* 파기 절차를 수립하고 체계적으로 관리하는 정책 페이지.
|
||||||
|
*
|
||||||
|
* ① 보관 현황 ② 유형별 보관기간 ③ 파기 절차 ④ 예외·연장 ⑤ 파기 감사 대장
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Tab = 'overview' | 'retention' | 'disposal' | 'exception' | 'audit';
|
||||||
|
|
||||||
|
// ─── 보관 현황 KPI ──────────────────
|
||||||
|
const RETENTION_KPI = [
|
||||||
|
{ label: '관리 데이터 유형', value: '6종', icon: Database, color: '#3b82f6' },
|
||||||
|
{ label: '보관기간 초과', value: '0건', icon: Clock, color: '#10b981' },
|
||||||
|
{ label: '파기 대기', value: '3건', icon: Trash2, color: '#f59e0b' },
|
||||||
|
{ label: '보존 연장 중', value: '1건', icon: ShieldCheck, color: '#8b5cf6' },
|
||||||
|
{ label: '금월 파기 완료', value: '12건', icon: CheckCircle, color: '#06b6d4' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 유형별 보관기간 기준표 ──────────────────
|
||||||
|
const RETENTION_TABLE = [
|
||||||
|
{ type: '선박 위치 로그 (AIS)', category: '운항 데이터', basis: '해사안전법 시행규칙 제42조', period: '5년', format: 'PostgreSQL + TimescaleDB', volume: '약 2.1TB/년', status: '정상' },
|
||||||
|
{ type: '단속 자료', category: '법집행 기록', basis: '해양경비법 제18조, 공공기록물법', period: '10년 (영구 가능)', format: 'PostgreSQL + 파일 스토리지', volume: '약 150GB/년', status: '정상' },
|
||||||
|
{ type: '수사 관련 자료', category: '수사 기록', basis: '형사소송법 제198조, 수사기록 보존규칙', period: '영구 (종결 후 30년)', format: '암호화 스토리지', volume: '약 50GB/년', status: '정상' },
|
||||||
|
{ type: 'AI 학습용 임시 데이터', category: 'AI 학습 데이터', basis: '개인정보보호법 제21조', period: '학습 완료 후 90일', format: 'S3 + DVC', volume: '약 500GB/주기', status: '정상' },
|
||||||
|
{ type: 'CCTV·영상 증거', category: '영상 데이터', basis: '개인정보보호법 제25조', period: '30일 (증거 채택 시 영구)', format: 'NAS + HLS', volume: '약 3TB/월', status: '주의' },
|
||||||
|
{ type: '시스템 접근 로그', category: '감사 로그', basis: '정보통신망법 제45조, 전자금융감독규정', period: '5년', format: 'Elasticsearch', volume: '약 80GB/년', status: '정상' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 파기 방식 정의 ──────────────────
|
||||||
|
const DISPOSAL_METHODS = [
|
||||||
|
{ method: '완전 삭제 (Secure Erase)', desc: 'DoD 5220.22-M 기준 3회 덮어쓰기 후 삭제', target: 'DB 레코드, 파일', encryption: '해당 없음', recovery: '복구 불가능', status: '적용' },
|
||||||
|
{ method: '암호화 키 폐기', desc: '암호화된 데이터의 복호화 키를 영구 폐기하여 접근 차단', target: '암호화 스토리지', encryption: 'AES-256 키 폐기', recovery: '복구 불가능', status: '적용' },
|
||||||
|
{ method: '논리적 삭제 + 만료', desc: 'soft-delete 마킹 후 보관기간 만료 시 물리 삭제 전환', target: '운영 DB', encryption: '-', recovery: '만료 전 복구 가능', status: '적용' },
|
||||||
|
{ method: '물리적 파기', desc: '디가우저(Degausser) 또는 물리적 파쇄로 매체 파기', target: '이동식 매체, 하드디스크', encryption: '해당 없음', recovery: '복구 불가능', status: '적용' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 파기 승인 절차 ──────────────────
|
||||||
|
const DISPOSAL_WORKFLOW = [
|
||||||
|
{ phase: '① 파기 대상 선별', actions: ['보관기간 만료 데이터 자동 탐색', '유형별 파기 대상 목록 생성', '백업 데이터 포함 여부 확인'], responsible: '시스템 자동', icon: Search },
|
||||||
|
{ phase: '② 파기 신청', actions: ['파기 대상 목록 검토 및 승인 요청', '수사·소송 보존 연장 대상 제외 확인', '파기 방식 지정 (완전삭제/키폐기/물리파기)'], responsible: '데이터 관리자', icon: FileText },
|
||||||
|
{ phase: '③ 승인 및 집행', actions: ['보안담당관 파기 승인', '파기 실행 (이중 확인 절차)', '백업 데이터 동시 파기'], responsible: '보안담당관', icon: UserCheck },
|
||||||
|
{ phase: '④ 결과 기록', actions: ['파기 결과 로그 자동 기록', '파기 대장 등록 (대상·일시·담당자·방식)', '감사 보고서 생성 및 보관'], responsible: '시스템 자동', icon: Archive },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 보존 연장 예외 현황 ──────────────────
|
||||||
|
const EXCEPTIONS = [
|
||||||
|
{ id: 'EXC-2026-001', dataType: '단속 자료 #2024-1892', reason: '수사 진행 중 (인천해경)', originalExpiry: '2026-03-15', extendedTo: '수사 종결 시까지', approver: '수사과장', status: '연장 중' },
|
||||||
|
{ id: 'EXC-2026-002', dataType: 'AIS 로그 (2021-Q2)', reason: '재판 증거 제출 (서울중앙지법)', originalExpiry: '2026-06-30', extendedTo: '판결 확정 시까지', approver: '법무담당관', status: '연장 중' },
|
||||||
|
{ id: 'EXC-2025-015', dataType: 'CCTV 영상 #V-2025-0342', reason: '감사원 감사 대상', originalExpiry: '2025-12-01', extendedTo: '2026-06-30', approver: '감사담당관', status: '해제 예정' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EXCEPTION_RULES = [
|
||||||
|
{ rule: '수사·소송 보존', desc: '수사 개시 또는 소송 진행 중인 데이터는 종결 시까지 파기 유예', authority: '수사과장 / 법무담당관' },
|
||||||
|
{ rule: '감사 보존', desc: '내부·외부 감사 대상 데이터는 감사 완료 후 6개월까지 보존 연장', authority: '감사담당관' },
|
||||||
|
{ rule: '재난·사고 보존', desc: '해양 사고 관련 데이터는 사고 조사 종결 시까지 보존', authority: '안전관리관' },
|
||||||
|
{ rule: '정보공개 청구', desc: '정보공개 청구 접수된 데이터는 처리 완료 시까지 보존', authority: '정보공개담당관' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 파기 감사 대장 ──────────────────
|
||||||
|
const DISPOSAL_AUDIT_LOG = [
|
||||||
|
{ id: 'DSP-2026-012', date: '2026-04-10', target: 'AI 학습 임시 데이터 (배치 #B-0392)', type: 'AI 학습 데이터', method: '완전 삭제', volume: '48.2GB', operator: '정해진', approver: '김영수', result: '완료' },
|
||||||
|
{ id: 'DSP-2026-011', date: '2026-04-08', target: 'CCTV 영상 2026-03월분 (미채택)', type: '영상 데이터', method: '완전 삭제', volume: '2.8TB', operator: '시스템', approver: '김영수', result: '완료' },
|
||||||
|
{ id: 'DSP-2026-010', date: '2026-04-05', target: '시스템 접근 로그 (2021-Q1)', type: '감사 로그', method: '완전 삭제', volume: '12.5GB', operator: '시스템', approver: '김영수', result: '완료' },
|
||||||
|
{ id: 'DSP-2026-009', date: '2026-04-03', target: 'AI 학습 임시 데이터 (배치 #B-0391)', type: 'AI 학습 데이터', method: '암호화 키 폐기', volume: '51.7GB', operator: '정해진', approver: '김영수', result: '완료' },
|
||||||
|
{ id: 'DSP-2026-008', date: '2026-04-01', target: 'AIS 위치 로그 (2021-03)', type: '운항 데이터', method: '완전 삭제', volume: '180GB', operator: '시스템', approver: '이상호', result: '완료' },
|
||||||
|
{ id: 'DSP-2026-007', date: '2026-03-28', target: '이동식 매체 (USB-0021~0025)', type: '물리 매체', method: '물리적 파기', volume: '5개', operator: '박민수', approver: '김영수', result: '완료' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 보관 구조 요약 ──────────────────
|
||||||
|
const STORAGE_ARCHITECTURE = [
|
||||||
|
{ tier: '운영 스토리지', desc: '실시간 조회·분석 대상 (최근 1년)', tech: 'PostgreSQL + TimescaleDB', encryption: 'TDE (AES-256)', backup: '일일 증분 + 주간 전체', icon: Database },
|
||||||
|
{ tier: '아카이브 스토리지', desc: '장기 보관 대상 (1~10년)', tech: 'S3 Compatible (Glacier 등급)', encryption: 'SSE-KMS', backup: '월간 무결성 검증', icon: Archive },
|
||||||
|
{ tier: '백업 스토리지', desc: '재해 복구용 (이중화)', tech: '원격지 NAS + 테이프', encryption: 'AES-256', backup: '분기별 복구 테스트', icon: Lock },
|
||||||
|
{ tier: '파기 대기 영역', desc: 'soft-delete 후 파기 승인 대기', tech: '격리 스토리지 (접근 제한)', encryption: 'AES-256', backup: '미백업 (파기 예정)', icon: Trash2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DataRetentionPolicy() {
|
||||||
|
const [tab, setTab] = useState<Tab>('overview');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
icon={Database}
|
||||||
|
iconColor="text-blue-400"
|
||||||
|
title="데이터 보관기간 및 파기 정책"
|
||||||
|
description="DAR-10 | 데이터 유형별 보관기간 기준표, 파기 절차, 보존 연장 예외 관리"
|
||||||
|
demo
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 탭 */}
|
||||||
|
<div className="flex gap-0 border-b border-border">
|
||||||
|
{([
|
||||||
|
{ key: 'overview' as Tab, icon: Eye, label: '보관 현황' },
|
||||||
|
{ key: 'retention' as Tab, icon: CalendarClock, label: '유형별 보관기간' },
|
||||||
|
{ key: 'disposal' as Tab, icon: Trash2, label: '파기 절차' },
|
||||||
|
{ key: 'exception' as Tab, icon: ShieldCheck, label: '예외·연장' },
|
||||||
|
{ key: 'audit' as Tab, icon: FileText, label: '파기 감사 대장' },
|
||||||
|
]).map(t => (
|
||||||
|
<button type="button" key={t.key} onClick={() => setTab(t.key)}
|
||||||
|
className={`flex items-center gap-1.5 px-4 py-2.5 text-[11px] font-medium border-b-2 transition-colors ${tab === t.key ? 'text-blue-400 border-blue-400' : 'text-hint border-transparent hover:text-label'}`}>
|
||||||
|
<t.icon className="w-3.5 h-3.5" />{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── ① 보관 현황 ── */}
|
||||||
|
{tab === 'overview' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{RETENTION_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="text-lg font-bold" style={{ color: k.color }}>{k.value}</div>
|
||||||
|
<div className="text-[9px] text-hint">{k.label}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 보관 구조 */}
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Settings className="w-4 h-4 text-blue-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">전체 보관 구조 (4-Tier)</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{STORAGE_ARCHITECTURE.map(s => (
|
||||||
|
<div key={s.tier} className="bg-surface-overlay rounded-lg p-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<s.icon className="w-4 h-4 text-blue-400" />
|
||||||
|
<span className="text-[11px] font-bold text-heading">{s.tier}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[9px] text-hint mb-2">{s.desc}</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{[
|
||||||
|
['기술', s.tech],
|
||||||
|
['암호화', s.encryption],
|
||||||
|
['백업', s.backup],
|
||||||
|
].map(([k, v]) => (
|
||||||
|
<div key={k} className="flex justify-between text-[9px] px-2 py-1 bg-surface-raised rounded">
|
||||||
|
<span className="text-muted-foreground">{k}</span>
|
||||||
|
<span className="text-label">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
{/* 정책 준수 현황 */}
|
||||||
|
<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 방식 적용', '완료'],
|
||||||
|
['파기 승인 절차', '4단계 절차 운영 중', '완료'],
|
||||||
|
['보존 연장 예외 관리', '3건 관리 중 (1건 해제 예정)', '정상'],
|
||||||
|
['백업 데이터 동시 파기', '파기 시 백업 포함 확인', '완료'],
|
||||||
|
['파기 감사 대장', '12건 기록 (금월)', '완료'],
|
||||||
|
['CCTV 30일 보관 준수', '미채택 영상 30일 초과 1건', '주의'],
|
||||||
|
].map(([k, v, s]) => (
|
||||||
|
<div key={k} className="flex items-center gap-2 px-2 py-1.5 bg-surface-overlay rounded">
|
||||||
|
{s === '완료' || s === '정상' ? <CheckCircle className="w-3 h-3 text-green-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]">
|
||||||
|
{DISPOSAL_AUDIT_LOG.slice(0, 6).map(d => (
|
||||||
|
<div key={d.id} className="flex items-center gap-2 px-2 py-1.5 bg-surface-overlay rounded">
|
||||||
|
<span className="text-muted-foreground w-20">{d.date}</span>
|
||||||
|
<span className="text-heading flex-1 truncate">{d.target}</span>
|
||||||
|
<Badge intent={getStatusIntent(d.result)} size="sm">{d.method}</Badge>
|
||||||
|
<span className="text-hint text-[9px]">{d.volume}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ② 유형별 보관기간 ── */}
|
||||||
|
{tab === 'retention' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<CalendarClock className="w-4 h-4 text-blue-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">데이터 유형별 보관기간 기준표</span>
|
||||||
|
<Badge intent="info" size="xs">{RETENTION_TABLE.length}종 관리</Badge>
|
||||||
|
</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-left py-2">법적 근거</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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{RETENTION_TABLE.map(r => (
|
||||||
|
<tr key={r.type} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||||
|
<td className="py-2.5 px-2 text-heading font-medium">{r.type}</td>
|
||||||
|
<td className="py-2.5"><Badge intent="muted" size="xs">{r.category}</Badge></td>
|
||||||
|
<td className="py-2.5 text-muted-foreground text-[9px]">{r.basis}</td>
|
||||||
|
<td className="py-2.5 text-center text-cyan-400 font-bold">{r.period}</td>
|
||||||
|
<td className="py-2.5 text-muted-foreground text-[9px]">{r.format}</td>
|
||||||
|
<td className="py-2.5 text-center text-muted-foreground">{r.volume}</td>
|
||||||
|
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(r.status)} size="sm">{r.status}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">관련 법령 요약</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{[
|
||||||
|
{ law: '해사안전법 시행규칙', article: '제42조', content: 'AIS 장치 기록 보존 의무 (5년)' },
|
||||||
|
{ law: '해양경비법', article: '제18조', content: '단속 기록 작성·보존 의무' },
|
||||||
|
{ law: '공공기록물 관리에 관한 법률', article: '제19조', content: '기록물 보존기간 준수 의무' },
|
||||||
|
{ law: '개인정보보호법', article: '제21조', content: '목적 달성 후 지체 없이 파기' },
|
||||||
|
{ law: '정보통신망법', article: '제45조', content: '접속기록 5년 보관 의무' },
|
||||||
|
{ law: '형사소송법', article: '제198조', content: '수사기록 보존 의무' },
|
||||||
|
].map(l => (
|
||||||
|
<div key={l.law} className="bg-surface-overlay rounded-lg p-3">
|
||||||
|
<div className="text-[11px] font-bold text-heading mb-1">{l.law}</div>
|
||||||
|
<Badge intent="muted" size="xs">{l.article}</Badge>
|
||||||
|
<div className="text-[9px] text-hint mt-1.5">{l.content}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ③ 파기 절차 ── */}
|
||||||
|
{tab === 'disposal' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 파기 승인 워크플로우 */}
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Trash2 className="w-4 h-4 text-red-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">파기 승인 절차 (4단계)</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{DISPOSAL_WORKFLOW.map((s, i) => (
|
||||||
|
<div key={s.phase} className="relative">
|
||||||
|
{i < DISPOSAL_WORKFLOW.length - 1 && (
|
||||||
|
<ChevronRight className="absolute -right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-border z-10" />
|
||||||
|
)}
|
||||||
|
<div className="bg-surface-overlay rounded-lg p-3 h-full">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<s.icon className="w-4 h-4 text-blue-400" />
|
||||||
|
<span className="text-[11px] font-bold text-blue-400">{s.phase}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-cyan-400 mb-2">{s.responsible}</div>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{s.actions.map(a => (
|
||||||
|
<li key={a} className="flex items-start gap-1.5 text-[9px] text-muted-foreground">
|
||||||
|
<CheckCircle className="w-3 h-3 text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<span>{a}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
{/* 파기 방식 */}
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Lock className="w-4 h-4 text-red-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">파기 방식 정의</span>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-[10px]">
|
||||||
|
<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-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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{DISPOSAL_METHODS.map(m => (
|
||||||
|
<tr key={m.method} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||||
|
<td className="py-2.5 px-2 text-heading font-medium">{m.method}</td>
|
||||||
|
<td className="py-2.5 text-muted-foreground text-[9px]">{m.desc}</td>
|
||||||
|
<td className="py-2.5 text-muted-foreground">{m.target}</td>
|
||||||
|
<td className="py-2.5 text-center"><Badge intent={m.encryption.includes('AES') ? 'success' : 'muted'} size="xs">{m.encryption}</Badge></td>
|
||||||
|
<td className="py-2.5 text-center text-red-400 text-[9px] font-medium">{m.recovery}</td>
|
||||||
|
<td className="py-2.5 text-center"><Badge intent="success" size="sm">{m.status}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ④ 예외·연장 ── */}
|
||||||
|
{tab === 'exception' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<ShieldCheck className="w-4 h-4 text-purple-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">보존 연장 예외 현황</span>
|
||||||
|
<Badge intent="warning" size="xs">{EXCEPTIONS.filter(e => e.status === '연장 중').length}건 연장 중</Badge>
|
||||||
|
</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-center py-2">상태</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{EXCEPTIONS.map(e => (
|
||||||
|
<tr key={e.id} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||||
|
<td className="py-2.5 px-2 text-hint font-mono text-[9px]">{e.id}</td>
|
||||||
|
<td className="py-2.5 text-heading font-medium">{e.dataType}</td>
|
||||||
|
<td className="py-2.5 text-muted-foreground text-[9px]">{e.reason}</td>
|
||||||
|
<td className="py-2.5 text-center text-muted-foreground">{e.originalExpiry}</td>
|
||||||
|
<td className="py-2.5 text-center text-cyan-400 font-medium text-[9px]">{e.extendedTo}</td>
|
||||||
|
<td className="py-2.5 text-center text-muted-foreground">{e.approver}</td>
|
||||||
|
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(e.status)} size="sm">{e.status}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-yellow-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">보존 연장 사유 유형</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{EXCEPTION_RULES.map(r => (
|
||||||
|
<div key={r.rule} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||||
|
<ShieldCheck className="w-4 h-4 text-purple-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>
|
||||||
|
<Badge intent="muted" size="sm">{r.authority}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ⑤ 파기 감사 대장 ── */}
|
||||||
|
{tab === 'audit' && (
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<FileText className="w-4 h-4 text-green-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">파기 감사 대장</span>
|
||||||
|
<Badge intent="info" size="xs">{DISPOSAL_AUDIT_LOG.length}건</Badge>
|
||||||
|
</div>
|
||||||
|
<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-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-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>
|
||||||
|
{DISPOSAL_AUDIT_LOG.map(d => (
|
||||||
|
<tr key={d.id} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||||
|
<td className="py-2.5 px-2 text-hint font-mono text-[9px]">{d.id}</td>
|
||||||
|
<td className="py-2.5 text-center text-muted-foreground">{d.date}</td>
|
||||||
|
<td className="py-2.5 text-heading font-medium text-[9px]">{d.target}</td>
|
||||||
|
<td className="py-2.5 text-center"><Badge intent="muted" size="xs">{d.type}</Badge></td>
|
||||||
|
<td className="py-2.5 text-center text-muted-foreground">{d.method}</td>
|
||||||
|
<td className="py-2.5 text-center text-cyan-400 font-mono">{d.volume}</td>
|
||||||
|
<td className="py-2.5 text-center text-muted-foreground">{d.operator}</td>
|
||||||
|
<td className="py-2.5 text-center text-muted-foreground">{d.approver}</td>
|
||||||
|
<td className="py-2.5 text-center"><Badge intent="success" size="sm">{d.result}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
637
frontend/src/features/admin/PerformanceMonitoring.tsx
Normal file
637
frontend/src/features/admin/PerformanceMonitoring.tsx
Normal file
@ -0,0 +1,637 @@
|
|||||||
|
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 {
|
||||||
|
Activity, Gauge, Users, Database, Brain, Server,
|
||||||
|
CheckCircle, AlertTriangle, TrendingUp, Clock,
|
||||||
|
Zap, Shield, BarChart3, Cpu, HardDrive, Wifi,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 성능 모니터링 (PER-01 ~ PER-06)
|
||||||
|
*
|
||||||
|
* 총 사용자 3,000명 (본청 200명·상황실 100명 24/7 + 지방청·관할서·함정)
|
||||||
|
* 통합게이트웨이(V-PASS·VTS·E-nav) + S&P Global AIS A/B 클래스 전제
|
||||||
|
*
|
||||||
|
* ① 성능 현황 ② 응답성(PER-01) ③ 처리용량(PER-02·03)
|
||||||
|
* ④ AI 모델 성능(PER-04) ⑤ 가용성·확장성(PER-05·06)
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Tab = 'overview' | 'response' | 'capacity' | 'aiModel' | 'availability';
|
||||||
|
|
||||||
|
// ─── 성능 KPI ──────────────────
|
||||||
|
const PERF_KPI = [
|
||||||
|
{ label: '현재 동시접속', value: '342', unit: '명', icon: Users, color: '#3b82f6', status: 'normal' },
|
||||||
|
{ label: '대시보드 p95', value: '1.8', unit: '초', icon: Gauge, color: '#10b981', status: 'good' },
|
||||||
|
{ label: '시스템 가동률', value: '99.87', unit: '%', icon: Shield, color: '#06b6d4', status: 'good' },
|
||||||
|
{ label: 'AI 추론 p95', value: '1.4', unit: '초', icon: Brain, color: '#8b5cf6', status: 'good' },
|
||||||
|
{ label: '배치 SLA 준수', value: '100', unit: '%', icon: CheckCircle, color: '#10b981', status: 'good' },
|
||||||
|
{ label: '이벤트 경보', value: '0', unit: '건', icon: AlertTriangle, color: '#f59e0b', status: 'normal' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── SLO 적용 그룹 ──────────────────
|
||||||
|
const USER_GROUPS = [
|
||||||
|
{ group: '본청 상황실', users: 100, concurrent: '100 (100%)', sla: '≤ 2초 대시보드 / ≤ 1초 지도', priority: 'critical' as const, note: '24/7 상시 접속 · 최상위 SLO' },
|
||||||
|
{ group: '본청 기타', users: 100, concurrent: '50 (50%)', sla: '≤ 3초 대시보드', priority: 'high' as const, note: '주간 업무 시간' },
|
||||||
|
{ group: '지방청(5개)', users: 400, concurrent: '120 (30%)', sla: '≤ 3초 대시보드', priority: 'high' as const, note: '관할해역 상황실' },
|
||||||
|
{ group: '관할서', users: 1500, concurrent: '300 (20%)', sla: '≤ 1.5초 조회', priority: 'info' as const, note: '주간 피크' },
|
||||||
|
{ group: '함정·파출소', users: 800, concurrent: '120 (15%)', sla: '≤ 1초 API', priority: 'info' as const, note: '모바일 Agent · 저대역폭' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── PER-01 응답성 SLO vs 실측 ──────────────────
|
||||||
|
const RESPONSE_SLO = [
|
||||||
|
{ target: '메인 대시보드 초기 로드', slo: '2.0초', p50: '0.9초', p95: '1.8초', p99: '2.4초', status: 'good' as const },
|
||||||
|
{ target: '위험도 지도 격자 조회', slo: '2.0초', p50: '0.7초', p95: '1.6초', p99: '2.1초', status: 'good' as const },
|
||||||
|
{ target: '의심 선박·어구 단순 조회', slo: '1.5초', p50: '0.4초', p95: '1.1초', p99: '1.7초', status: 'good' as const },
|
||||||
|
{ target: '복합 분석·시각화', slo: '5.0초', p50: '2.1초', p95: '4.2초', p99: '5.8초', status: 'warn' as const },
|
||||||
|
{ target: 'AI 추론 API (단건)', slo: '2.0초', p50: '0.6초', p95: '1.4초', p99: '1.9초', status: 'good' as const },
|
||||||
|
{ target: '연계 API (read)', slo: '500ms', p50: '120ms', p95: '380ms', p99: '510ms', status: 'warn' as const },
|
||||||
|
{ target: '함정 모바일 Agent API', slo: '1.0초', p50: '0.3초', p95: '0.8초', p99: '1.2초', status: 'good' as const },
|
||||||
|
{ target: 'AI 탐지 알림 End-to-End', slo: '3.0초', p50: '1.1초', p95: '2.3초', p99: '2.8초', status: 'good' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── PER-02 동시접속·TPS ──────────────────
|
||||||
|
const CAPACITY_METRICS = [
|
||||||
|
{ metric: '현재 동시접속', current: 342, target: '600 (정상 피크)', max: '900 (작전 피크)', utilization: 57, intent: 'info' as const },
|
||||||
|
{ metric: '현재 TPS', current: 185, target: '400 TPS', max: '600 TPS', utilization: 46, intent: 'info' as const },
|
||||||
|
{ metric: '활성 세션', current: 287, target: '500', max: '750', utilization: 57, intent: 'info' as const },
|
||||||
|
{ metric: 'WebSocket 연결', current: 142, target: '300', max: '500', utilization: 47, intent: 'info' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── PER-03 배치 처리 현황 ──────────────────
|
||||||
|
const BATCH_JOBS = [
|
||||||
|
{ name: 'AIS 국내 정제·적재', schedule: '매 5분', volume: '~5 GB/일', sla: '5분', avg: '2분 18초', lastRun: '성공', status: 'success' as const },
|
||||||
|
{ name: 'S&P 글로벌 AIS 집계·격자', schedule: '00:00 야간', volume: '~500 GB/일 (압축 후)', sla: '3시간', avg: '2시간 12분', lastRun: '성공', status: 'success' as const },
|
||||||
|
{ name: '위성영상 타일링·인덱싱', schedule: '수신 직후', volume: '건당 2~10 GB', sla: '2시간/건', avg: '1시간 24분', lastRun: '성공', status: 'success' as const },
|
||||||
|
{ name: '피처 스토어 갱신', schedule: '매시 정각', volume: '~200 MB', sla: '1시간', avg: '8분 42초', lastRun: '성공', status: 'success' as const },
|
||||||
|
{ name: 'AI 모델 재학습 (주간)', schedule: '일요일 02:00', volume: '학습셋 전체', sla: '8시간', avg: '6시간 18분', lastRun: '성공', status: 'success' as const },
|
||||||
|
{ name: '통계·리포트 집계', schedule: '매시 정각', volume: '~50 MB', sla: '30분', avg: '6분 12초', lastRun: '성공', status: 'success' as const },
|
||||||
|
{ name: '해양기상·환경 수집', schedule: '매시 정각', volume: '~500 MB', sla: '10분', avg: '3분 48초', lastRun: '지연', status: 'warn' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── PER-04 AI 모델 성능 ──────────────────
|
||||||
|
const AI_MODELS = [
|
||||||
|
{ model: '불법조업 위험도 예측', accuracy: 92.4, precision: 89.1, recall: 87.6, f1: 88.3, rocAuc: 0.948, target: '≥ 85% 정확도', status: 'good' as const },
|
||||||
|
{ model: '순찰 경로 추천 (단일)', accuracy: 94.1, precision: 91.2, recall: 90.5, f1: 90.8, rocAuc: 0.961, target: '≥ 90% 정확도', status: 'good' as const },
|
||||||
|
{ model: '다함정 협력 경로 최적화', accuracy: 91.8, precision: 88.4, recall: 87.9, f1: 88.1, rocAuc: 0.936, target: '≥ 85% 정확도', status: 'good' as const },
|
||||||
|
{ model: '불법 어선 (Dark Vessel) 탐지', accuracy: 96.2, precision: 94.8, recall: 92.3, f1: 93.5, rocAuc: 0.978, target: '≥ 92% 정확도', status: 'good' as const },
|
||||||
|
{ model: '불법 어망·어구 탐지', accuracy: 88.7, precision: 85.2, recall: 83.6, f1: 84.4, rocAuc: 0.912, target: '≥ 85% 정확도', status: 'warn' as const },
|
||||||
|
{ model: 'AIS 조작 패턴 감지', accuracy: 93.5, precision: 91.7, recall: 89.4, f1: 90.5, rocAuc: 0.954, target: '≥ 90% 정확도', status: 'good' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── PER-05 가용성·장애복구 ──────────────────
|
||||||
|
const AVAILABILITY_METRICS = [
|
||||||
|
{ component: '애플리케이션 서버 (K8s)', uptime: '99.98%', rto: '≤ 30초', rpo: '0 (stateless)', lastIncident: '없음', status: 'good' as const },
|
||||||
|
{ component: 'DB (PostgreSQL HA)', uptime: '99.95%', rto: '≤ 60초', rpo: '≤ 5초', lastIncident: '2026-03-28', status: 'good' as const },
|
||||||
|
{ component: 'TimescaleDB (Hot)', uptime: '99.92%', rto: '≤ 120초', rpo: '≤ 15초', lastIncident: '2026-04-02', status: 'good' as const },
|
||||||
|
{ component: '벡터 DB (RAG)', uptime: '99.87%', rto: '≤ 180초', rpo: '≤ 30초', lastIncident: '2026-04-08', status: 'warn' as const },
|
||||||
|
{ component: 'NAS 스토리지', uptime: '99.99%', rto: '≤ 60초', rpo: '0 (이중화)', lastIncident: '없음', status: 'good' as const },
|
||||||
|
{ component: '통합게이트웨이', uptime: '99.89%', rto: '≤ 60초', rpo: '≤ 10초', lastIncident: '2026-04-05', status: 'good' as const },
|
||||||
|
{ component: 'S&P Global AIS API', uptime: '99.41%', rto: 'Fallback 즉시', rpo: '국내 신호 대체', lastIncident: '2026-04-12', status: 'warn' as const },
|
||||||
|
{ component: 'LLM Q&A 서버 (H200)', uptime: '99.76%', rto: '≤ 120초', rpo: '0 (stateless)', lastIncident: '2026-04-09', status: 'good' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── PER-06 확장성·자원 사용률 ──────────────────
|
||||||
|
const RESOURCE_USAGE = [
|
||||||
|
{ resource: '워커 노드 CPU', current: 48, threshold: 70, max: 80, scalePolicy: 'HPA 자동 확장', unit: '%' },
|
||||||
|
{ resource: '워커 노드 메모리', current: 52, threshold: 75, max: 85, scalePolicy: 'HPA 자동 확장', unit: '%' },
|
||||||
|
{ resource: 'AI 서버 GPU (RTX pro 6000)', current: 61, threshold: 80, max: 90, scalePolicy: '추론 큐잉', unit: '%' },
|
||||||
|
{ resource: 'LLM 서버 GPU (H200)', current: 44, threshold: 75, max: 85, scalePolicy: '요청 병합·배치', unit: '%' },
|
||||||
|
{ resource: 'DB 연결 풀', current: 128, threshold: 300, max: 400, scalePolicy: 'PgBouncer 풀 확대', unit: '개' },
|
||||||
|
{ resource: 'NAS 사용량', current: 28, threshold: 75, max: 90, scalePolicy: '콜드 티어 이관', unit: '% (100TB)' },
|
||||||
|
{ resource: 'Kafka 컨슈머 Lag', current: 142, threshold: 5000, max: 10000, scalePolicy: '파티션 증설', unit: 'msg' },
|
||||||
|
{ resource: 'Redis 캐시 메모리', current: 38, threshold: 70, max: 85, scalePolicy: 'Eviction + 클러스터 확장', unit: '%' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 성능 영향 최소화 전략 (S&P 글로벌 대응) ──────────────────
|
||||||
|
const IMPACT_REDUCTION = [
|
||||||
|
{ strategy: '이중 수집 파이프라인 물리 분리', target: '국내 vs 글로벌 격리', effect: '글로벌 장애 → 국내 무영향', per: 'PER-01·05' },
|
||||||
|
{ strategy: '경계 조기 필터링', target: '지리·선박 클래스 필터', effect: '원본 50~80% 감축', per: 'PER-03' },
|
||||||
|
{ strategy: '스트림·백프레셔 (Kafka)', target: 'Lag 임계 초과 시 다운샘플링', effect: '온라인 무영향', per: 'PER-01·03' },
|
||||||
|
{ strategy: '티어드 스토리지 (Hot/Warm/Cold)', target: '1~7일 / 30일 / 이후', effect: '쿼리 비용 최소화', per: 'PER-03·06' },
|
||||||
|
{ strategy: '공간 사전 집계 (H3 격자)', target: 'Materialized View', effect: '대시보드 Redis만 조회', per: 'PER-01' },
|
||||||
|
{ strategy: 'Circuit Breaker (S&P)', target: '실패율 50% 차단', effect: '국내 신호 Fallback', per: 'PER-05' },
|
||||||
|
{ strategy: 'K8s PriorityClass 격리', target: '온라인 vs 배치', effect: '상황실 SLO 절대 보장', per: 'PER-01·03' },
|
||||||
|
{ strategy: 'HPA 자동 확장', target: 'CPU/메모리 70% 임계', effect: '피크 자동 대응', per: 'PER-02·06' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const statusIntent = (s: 'good' | 'warn' | 'critical' | 'success'): 'success' | 'warning' | 'critical' => {
|
||||||
|
if (s === 'good' || s === 'success') return 'success';
|
||||||
|
if (s === 'warn') return 'warning';
|
||||||
|
return 'critical';
|
||||||
|
};
|
||||||
|
|
||||||
|
const barColor = (ratio: number): string => {
|
||||||
|
if (ratio < 0.6) return '#10b981';
|
||||||
|
if (ratio < 0.8) return '#f59e0b';
|
||||||
|
return '#ef4444';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PerformanceMonitoring() {
|
||||||
|
const [tab, setTab] = useState<Tab>('overview');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
icon={Activity}
|
||||||
|
iconColor="text-cyan-400"
|
||||||
|
title="성능 모니터링"
|
||||||
|
description="PER-01~06 | 응답성·처리용량·AI 모델·가용성·확장성 실시간 현황"
|
||||||
|
demo
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 탭 */}
|
||||||
|
<div className="flex gap-0 border-b border-border">
|
||||||
|
{([
|
||||||
|
{ key: 'overview' as Tab, icon: BarChart3, label: '성능 현황' },
|
||||||
|
{ key: 'response' as Tab, icon: Gauge, label: '응답성 (PER-01)' },
|
||||||
|
{ key: 'capacity' as Tab, icon: Users, label: '처리용량 (PER-02·03)' },
|
||||||
|
{ key: 'aiModel' as Tab, icon: Brain, label: 'AI 모델 (PER-04)' },
|
||||||
|
{ key: 'availability' as Tab, icon: Shield, label: '가용성·확장성 (PER-05·06)' },
|
||||||
|
]).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 === 'overview' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* KPI */}
|
||||||
|
<div className="grid grid-cols-6 gap-2">
|
||||||
|
{PERF_KPI.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-lg font-bold" style={{ color: k.color }}>
|
||||||
|
{k.value}<span className="text-[10px] ml-1 text-hint">{k.unit}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-hint">{k.label}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 사용자 그룹별 SLO */}
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Users className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">사용자 그룹별 SLO (총 2,900명 + 추정)</span>
|
||||||
|
<Badge intent="info" size="xs">본청 200 · 상황실 100 확정</Badge>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<thead className="text-hint text-[10px] border-b border-border">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left py-2 px-2 font-medium">그룹</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">총 인원</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">동시접속 추정</th>
|
||||||
|
<th className="text-left py-2 px-2 font-medium">SLA</th>
|
||||||
|
<th className="text-center py-2 px-2 font-medium">우선순위</th>
|
||||||
|
<th className="text-left py-2 px-2 font-medium">특성</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{USER_GROUPS.map(g => (
|
||||||
|
<tr key={g.group} className="border-b border-border/40 hover:bg-surface-overlay/40">
|
||||||
|
<td className="py-2 px-2 text-label font-medium">{g.group}</td>
|
||||||
|
<td className="py-2 px-2 text-right text-label">{g.users.toLocaleString()}명</td>
|
||||||
|
<td className="py-2 px-2 text-right text-heading font-medium">{g.concurrent}</td>
|
||||||
|
<td className="py-2 px-2 text-label">{g.sla}</td>
|
||||||
|
<td className="py-2 px-2 text-center"><Badge intent={g.priority} size="xs">{g.priority === 'critical' ? '최상' : g.priority === 'high' ? '높음' : '일반'}</Badge></td>
|
||||||
|
<td className="py-2 px-2 text-hint">{g.note}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
{/* 성능 영향 최소화 전략 */}
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Zap className="w-4 h-4 text-amber-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">성능 영향 최소화 전략 (글로벌 AIS 대응)</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{IMPACT_REDUCTION.map((s, i) => (
|
||||||
|
<div key={s.strategy} className="flex items-start gap-2 px-3 py-2 bg-surface-overlay rounded-lg">
|
||||||
|
<div className="w-5 h-5 rounded-full bg-amber-500/20 flex items-center justify-center shrink-0 text-[9px] font-bold text-amber-400">{i + 1}</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
|
<span className="text-[11px] text-heading font-medium">{s.strategy}</span>
|
||||||
|
<Badge intent="info" size="xs">{s.per}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-hint mb-0.5">대상: {s.target}</div>
|
||||||
|
<div className="text-[9px] text-green-400">효과: {s.effect}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ② 응답성 (PER-01) ── */}
|
||||||
|
{tab === 'response' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Gauge className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">PER-01 서비스 응답성 — SLO vs 실측 (p50/p95/p99)</span>
|
||||||
|
</div>
|
||||||
|
<Badge intent="success" size="sm">TER-03 검증 통과</Badge>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<thead className="text-hint text-[10px] border-b border-border">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left py-2 px-2 font-medium">대상</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">SLO 목표</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">p50</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">p95</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">p99</th>
|
||||||
|
<th className="text-center py-2 px-2 font-medium">상태</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{RESPONSE_SLO.map(r => (
|
||||||
|
<tr key={r.target} className="border-b border-border/40 hover:bg-surface-overlay/40">
|
||||||
|
<td className="py-2 px-2 text-label font-medium">{r.target}</td>
|
||||||
|
<td className="py-2 px-2 text-right text-cyan-400 font-medium">{r.slo}</td>
|
||||||
|
<td className="py-2 px-2 text-right text-hint">{r.p50}</td>
|
||||||
|
<td className="py-2 px-2 text-right text-heading font-medium">{r.p95}</td>
|
||||||
|
<td className="py-2 px-2 text-right text-label">{r.p99}</td>
|
||||||
|
<td className="py-2 px-2 text-center"><Badge intent={statusIntent(r.status)} size="xs">{r.status === 'good' ? '정상' : '주의'}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Shield className="w-4 h-4 text-purple-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">상황실 전용 SLO (24/7 100명)</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[
|
||||||
|
{ item: '메인 대시보드 초기 로드', target: '≤ 2초', current: '1.8초', met: true },
|
||||||
|
{ item: '위험도 지도 실시간 갱신', target: '≤ 1초', current: '0.7초', met: true },
|
||||||
|
{ item: 'AI 탐지 알림 수신 → 표출', target: '≤ 3초 E2E', current: '2.3초', met: true },
|
||||||
|
{ item: '단속 계획·경로 조회', target: '≤ 1.5초', current: '1.1초', met: true },
|
||||||
|
{ item: '장애 시 세션 유지', target: '< 30초 복구', current: '18초', met: true },
|
||||||
|
].map(s => (
|
||||||
|
<div key={s.item} className="flex items-center justify-between px-3 py-2 bg-surface-overlay rounded-lg">
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] text-heading font-medium">{s.item}</div>
|
||||||
|
<div className="text-[9px] text-hint">목표: {s.target}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] text-green-400 font-bold">{s.current}</span>
|
||||||
|
{s.met ? <CheckCircle className="w-4 h-4 text-green-500" /> : <AlertTriangle className="w-4 h-4 text-amber-500" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Clock className="w-4 h-4 text-blue-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">측정 방법론</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-2 text-[11px]">
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<div><strong className="text-heading">샘플링:</strong> <span className="text-label">1초 간격 p50/p95/p99 집계</span></div>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<div><strong className="text-heading">도구:</strong> <span className="text-label">OpenTelemetry + Prometheus + Grafana</span></div>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<div><strong className="text-heading">APM:</strong> <span className="text-label">분산 추적 + Trace ID 요청 단위 관통</span></div>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<div><strong className="text-heading">API 재시도:</strong> <span className="text-label">3회 · Exponential Backoff · 타임아웃 3초</span></div>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<div><strong className="text-heading">경보:</strong> <span className="text-label">SLO 위반 지속 5분 → PagerDuty</span></div>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<div><strong className="text-heading">원인 분석:</strong> <span className="text-label">RED/USE 방법론 + 로그·메트릭·추적 상관 분석</span></div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ③ 처리용량 (PER-02·03) ── */}
|
||||||
|
{tab === 'capacity' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 동시접속·TPS */}
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Users className="w-4 h-4 text-blue-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">PER-02 동시접속·처리용량 (정상 피크 600 / 작전 피크 900)</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{CAPACITY_METRICS.map(c => (
|
||||||
|
<div key={c.metric} className="px-3 py-3 bg-surface-overlay rounded-lg">
|
||||||
|
<div className="text-[10px] text-hint mb-1">{c.metric}</div>
|
||||||
|
<div className="text-xl font-bold text-heading mb-1">{c.current.toLocaleString()}</div>
|
||||||
|
<div className="text-[9px] text-label mb-2">목표 {c.target} / 최대 {c.max}</div>
|
||||||
|
<div className="h-1.5 bg-surface-raised rounded-full overflow-hidden">
|
||||||
|
<div className="h-full rounded-full transition-all" style={{ width: `${c.utilization}%`, backgroundColor: barColor(c.utilization / 100) }} />
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-hint mt-1">{c.utilization}% 사용 중</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
{/* 배치 작업 현황 */}
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Database className="w-4 h-4 text-purple-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">PER-03 배치 · 대용량 처리 현황</span>
|
||||||
|
<Badge intent="success" size="xs">SLA 준수 6/7</Badge>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<thead className="text-hint text-[10px] border-b border-border">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left py-2 px-2 font-medium">배치 작업</th>
|
||||||
|
<th className="text-left py-2 px-2 font-medium">스케줄</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">처리 볼륨</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">SLA</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">평균 소요</th>
|
||||||
|
<th className="text-center py-2 px-2 font-medium">최근 실행</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{BATCH_JOBS.map(j => (
|
||||||
|
<tr key={j.name} className="border-b border-border/40 hover:bg-surface-overlay/40">
|
||||||
|
<td className="py-2 px-2 text-label font-medium">{j.name}</td>
|
||||||
|
<td className="py-2 px-2 text-hint">{j.schedule}</td>
|
||||||
|
<td className="py-2 px-2 text-right text-label">{j.volume}</td>
|
||||||
|
<td className="py-2 px-2 text-right text-cyan-400">{j.sla}</td>
|
||||||
|
<td className="py-2 px-2 text-right text-heading font-medium">{j.avg}</td>
|
||||||
|
<td className="py-2 px-2 text-center"><Badge intent={statusIntent(j.status)} size="xs">{j.lastRun}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
{/* 처리 볼륨 산정 */}
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<HardDrive className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">데이터 처리 볼륨 (국내 + S&P 글로벌)</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3 text-[11px]">
|
||||||
|
<div className="px-3 py-3 bg-surface-overlay rounded-lg">
|
||||||
|
<div className="text-[10px] text-hint mb-1">일 수집 (원본)</div>
|
||||||
|
<div className="text-xl font-bold text-heading">1.6 ~ 3.2 TB</div>
|
||||||
|
<div className="text-[9px] text-label mt-1">AIS(국내) + S&P A/B + 위성 + 환경</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-3 bg-surface-overlay rounded-lg">
|
||||||
|
<div className="text-[10px] text-hint mb-1">일 적재 (필터·압축 후)</div>
|
||||||
|
<div className="text-xl font-bold text-heading">330 ~ 900 GB</div>
|
||||||
|
<div className="text-[9px] text-green-400 mt-1">경계 필터링 50~80% 감축</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-3 bg-surface-overlay rounded-lg">
|
||||||
|
<div className="text-[10px] text-hint mb-1">3년 누적 (티어드)</div>
|
||||||
|
<div className="text-xl font-bold text-heading">~360 TB ~ 1 PB</div>
|
||||||
|
<div className="text-[9px] text-amber-400 mt-1">NAS 100TB → 객체스토리지 이관</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ④ AI 모델 성능 (PER-04) ── */}
|
||||||
|
{tab === 'aiModel' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Brain className="w-4 h-4 text-purple-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">PER-04 AI 모델 성능 지표</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge intent="success" size="xs">6개 모델 운영 중</Badge>
|
||||||
|
<Badge intent="warning" size="xs">어망·어구 모델 개선 필요</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<thead className="text-hint text-[10px] border-b border-border">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left py-2 px-2 font-medium">모델</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">정확도</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">정밀도</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">재현율</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">F1</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">ROC-AUC</th>
|
||||||
|
<th className="text-left py-2 px-2 font-medium">목표</th>
|
||||||
|
<th className="text-center py-2 px-2 font-medium">상태</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{AI_MODELS.map(m => (
|
||||||
|
<tr key={m.model} className="border-b border-border/40 hover:bg-surface-overlay/40">
|
||||||
|
<td className="py-2 px-2 text-label font-medium">{m.model}</td>
|
||||||
|
<td className="py-2 px-2 text-right text-heading font-medium">{m.accuracy}%</td>
|
||||||
|
<td className="py-2 px-2 text-right text-label">{m.precision}%</td>
|
||||||
|
<td className="py-2 px-2 text-right text-label">{m.recall}%</td>
|
||||||
|
<td className="py-2 px-2 text-right text-label">{m.f1}</td>
|
||||||
|
<td className="py-2 px-2 text-right text-cyan-400">{m.rocAuc}</td>
|
||||||
|
<td className="py-2 px-2 text-hint text-[10px]">{m.target}</td>
|
||||||
|
<td className="py-2 px-2 text-center"><Badge intent={statusIntent(m.status)} size="xs">{m.status === 'good' ? '통과' : '주의'}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<TrendingUp className="w-4 h-4 text-green-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">모델 성능 저하 대응</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-2 text-[11px]">
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<div><strong className="text-heading">학습/검증/테스트 분할:</strong> <span className="text-label">70/15/15 비율, K-Fold 5</span></div>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<div><strong className="text-heading">드리프트 탐지:</strong> <span className="text-label">입력 분포 KL divergence 주간 모니터링</span></div>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<div><strong className="text-heading">성능 저하 임계:</strong> <span className="text-label">F1 3%p 하락 시 자동 재학습 트리거</span></div>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<div><strong className="text-heading">설명가능성:</strong> <span className="text-label">Feature Importance + SHAP 값 제공</span></div>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<div><strong className="text-heading">A/B 테스트:</strong> <span className="text-label">Shadow → Canary 5% → 50% → 100% 단계 배포</span></div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Cpu className="w-4 h-4 text-amber-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">추론 성능 (GPU 활용)</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-[11px] text-label">AI 서버 (RTX pro 6000 Blackwell ×2)</span>
|
||||||
|
<span className="text-[11px] text-heading font-bold">61%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-surface-raised rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-amber-500 rounded-full" style={{ width: '61%' }} />
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-hint mt-1">추론 단건 평균 1.4초 · 큐 대기 <200ms</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-[11px] text-label">LLM 서버 (H200 ×2)</span>
|
||||||
|
<span className="text-[11px] text-heading font-bold">44%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-surface-raised rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-green-500 rounded-full" style={{ width: '44%' }} />
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-hint mt-1">Q&A 스트리밍 첫 토큰 평균 380ms</div>
|
||||||
|
</div>
|
||||||
|
<div className="pt-2 border-t border-border/40">
|
||||||
|
<div className="text-[10px] text-hint mb-1">동시 추론 요청 처리</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge intent="info" size="xs">최대 100 요청/초</Badge>
|
||||||
|
<Badge intent="success" size="xs">큐잉 지연 <1%</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ⑤ 가용성·확장성 (PER-05·06) ── */}
|
||||||
|
{tab === 'availability' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 가용성 */}
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Shield className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">PER-05 가용성 및 장애복구 (목표 ≥ 99.9%)</span>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<thead className="text-hint text-[10px] border-b border-border">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left py-2 px-2 font-medium">구성요소</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">가동률</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">RTO</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium">RPO</th>
|
||||||
|
<th className="text-left py-2 px-2 font-medium">최근 장애</th>
|
||||||
|
<th className="text-center py-2 px-2 font-medium">상태</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{AVAILABILITY_METRICS.map(a => (
|
||||||
|
<tr key={a.component} className="border-b border-border/40 hover:bg-surface-overlay/40">
|
||||||
|
<td className="py-2 px-2 text-label font-medium">{a.component}</td>
|
||||||
|
<td className="py-2 px-2 text-right text-heading font-medium">{a.uptime}</td>
|
||||||
|
<td className="py-2 px-2 text-right text-cyan-400">{a.rto}</td>
|
||||||
|
<td className="py-2 px-2 text-right text-label">{a.rpo}</td>
|
||||||
|
<td className="py-2 px-2 text-hint">{a.lastIncident}</td>
|
||||||
|
<td className="py-2 px-2 text-center"><Badge intent={statusIntent(a.status)} size="xs">{a.status === 'good' ? '정상' : '주의'}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
{/* 확장성 */}
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Server className="w-4 h-4 text-purple-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">PER-06 확장성 및 자원 사용률</span>
|
||||||
|
<Badge intent="info" size="xs">2배(6,000명) 확장 목표</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{RESOURCE_USAGE.map(r => {
|
||||||
|
const ratio = r.current / r.max;
|
||||||
|
return (
|
||||||
|
<div key={r.resource} className="px-3 py-3 bg-surface-overlay rounded-lg">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-[11px] text-label font-medium">{r.resource}</span>
|
||||||
|
<span className="text-[11px] text-heading font-bold">{r.current}{r.unit === '%' || r.unit.startsWith('%') ? '%' : ' ' + r.unit}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-surface-raised rounded-full overflow-hidden mb-2">
|
||||||
|
<div className="h-full rounded-full transition-all" style={{ width: `${(ratio) * 100}%`, backgroundColor: barColor(ratio) }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-[9px]">
|
||||||
|
<span className="text-hint">경보 {r.threshold}{r.unit === '%' || r.unit.startsWith('%') ? '%' : ''} · 한계 {r.max}{r.unit === '%' || r.unit.startsWith('%') ? '%' : ''}</span>
|
||||||
|
<Badge intent="muted" size="xs">{r.scalePolicy}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
{/* 요약 지표 */}
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Wifi className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span className="text-[11px] font-bold text-heading">연간 가동률 목표</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-cyan-400">99.9%</div>
|
||||||
|
<div className="text-[9px] text-hint mt-1">월간 다운타임 ≤ 43분</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Clock className="w-4 h-4 text-purple-400" />
|
||||||
|
<span className="text-[11px] font-bold text-heading">RTO 평균</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-purple-400">≤ 60초</div>
|
||||||
|
<div className="text-[9px] text-hint mt-1">자동 페일오버 · Self-healing</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Database className="w-4 h-4 text-green-400" />
|
||||||
|
<span className="text-[11px] font-bold text-heading">RPO 평균</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-green-400">≤ 10초</div>
|
||||||
|
<div className="text-[9px] text-hint mt-1">실시간 복제 + 백업 이중화</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<TrendingUp className="w-4 h-4 text-amber-400" />
|
||||||
|
<span className="text-[11px] font-bold text-heading">Scale-out 여유</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-amber-400">×2</div>
|
||||||
|
<div className="text-[9px] text-hint mt-1">6,000명까지 선형 확장</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -5,3 +5,6 @@ export { AdminPanel } from './AdminPanel';
|
|||||||
export { DataHub } from './DataHub';
|
export { DataHub } from './DataHub';
|
||||||
export { AISecurityPage } from './AISecurityPage';
|
export { AISecurityPage } from './AISecurityPage';
|
||||||
export { AIAgentSecurityPage } from './AIAgentSecurityPage';
|
export { AIAgentSecurityPage } from './AIAgentSecurityPage';
|
||||||
|
export { DataRetentionPolicy } from './DataRetentionPolicy';
|
||||||
|
export { DataModelVerification } from './DataModelVerification';
|
||||||
|
export { PerformanceMonitoring } from './PerformanceMonitoring';
|
||||||
|
|||||||
@ -197,6 +197,149 @@ const GEAR_PROFILES = [
|
|||||||
features: [{ k: 'High Speed', v: '>7 kt' }, { k: 'Circularity', v: 'High' }, { k: 'Speed Trans.', v: 'High' }, { k: 'Fleet', v: 'High' }] },
|
features: [{ k: 'High Speed', v: '>7 kt' }, { k: 'Circularity', v: 'High' }, { k: 'Speed Trans.', v: 'High' }, { k: 'Fleet', v: 'High' }] },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ─── DAR-03 5종 어구 구조 비교 (FAO ISSCFG) ──────────
|
||||||
|
|
||||||
|
interface DAR03GearSummary {
|
||||||
|
no: string;
|
||||||
|
name: string;
|
||||||
|
faoCode: string;
|
||||||
|
mesh: string;
|
||||||
|
iuuRisk: '매우 높음' | '높음' | '중간' | '낮음~중간';
|
||||||
|
aisType: string;
|
||||||
|
gCodes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAR03_GEAR_SUMMARY: DAR03GearSummary[] = [
|
||||||
|
{ no: '①', name: '저층 트롤', faoCode: 'OTB/TBB', mesh: '≥60mm', iuuRisk: '높음', aisType: '어선 AIS', gCodes: 'G-01, G-03' },
|
||||||
|
{ no: '②', name: '쌍끌이 트롤', faoCode: 'PTM', mesh: '≥56mm', iuuRisk: '매우 높음', aisType: '어선 AIS 2척', gCodes: 'G-02, G-06' },
|
||||||
|
{ no: '③', name: '스토우넷', faoCode: 'FYK', mesh: '≥55mm', iuuRisk: '중간', aisType: '어구 AIS 부표', gCodes: 'G-01, G-04, G-05' },
|
||||||
|
{ no: '④', name: '자망', faoCode: 'GNS/GND', mesh: '55~144mm', iuuRisk: '낮음~중간', aisType: '어구 AIS 부표', gCodes: 'G-03, G-05' },
|
||||||
|
{ no: '⑤', name: '통발·함정', faoCode: 'FPO', mesh: '탈출구 Ø≥8cm', iuuRisk: '중간', aisType: '어구 AIS 부표', gCodes: 'G-01, G-04' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DAR03_IUU_INTENT: Record<DAR03GearSummary['iuuRisk'], BadgeIntent> = {
|
||||||
|
'매우 높음': 'critical',
|
||||||
|
'높음': 'high',
|
||||||
|
'중간': 'warning',
|
||||||
|
'낮음~중간': 'info',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DAR03GearDetail {
|
||||||
|
no: string;
|
||||||
|
name: string;
|
||||||
|
nameEn: string;
|
||||||
|
image: string;
|
||||||
|
specs: { k: string; v: string }[];
|
||||||
|
gCodes: { code: string; desc: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAR03_GEAR_DETAILS: DAR03GearDetail[] = [
|
||||||
|
{
|
||||||
|
no: '①', name: '저층 트롤', nameEn: 'Bottom Trawl (OTB/TBB)', image: '/dar03/bottom-trawl.png',
|
||||||
|
specs: [
|
||||||
|
{ k: 'FAO 코드', v: 'OTB / TBB' },
|
||||||
|
{ k: '최소 망목', v: '≥ 60mm (마름모형)' },
|
||||||
|
{ k: '주요 어종', v: '참조기 · 갈치' },
|
||||||
|
{ k: '조업 속력', v: '2.5~4.5 knot' },
|
||||||
|
{ k: '항적 패턴', v: 'U형 회전 · 직선 왕복' },
|
||||||
|
{ k: 'AIS', v: '어선 AIS (어구 AIS 없음)' },
|
||||||
|
],
|
||||||
|
gCodes: [
|
||||||
|
{ code: 'G-01', desc: '허가 해역 외 트롤 → GIS 교차' },
|
||||||
|
{ code: 'G-03', desc: '미등록 어구 → label=1' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: '②', name: '쌍끌이 중층 트롤', nameEn: 'Pair Midwater Trawl (PTM)', image: '/dar03/pair-trawl.png',
|
||||||
|
specs: [
|
||||||
|
{ k: 'FAO 코드', v: 'PTM' },
|
||||||
|
{ k: '최소 망목', v: '≥ 56mm' },
|
||||||
|
{ k: '주요 어종', v: '전갱이 · 고등어 · 참조기' },
|
||||||
|
{ k: '선박 간격', v: '300~500m 유지' },
|
||||||
|
{ k: '조업 속력', v: '2~4 knot (2척 동기화)' },
|
||||||
|
{ k: 'AIS', v: '2척 어선 AIS 동기화' },
|
||||||
|
],
|
||||||
|
gCodes: [
|
||||||
|
{ code: 'G-02', desc: '금어기 내 공조 조업 탐지' },
|
||||||
|
{ code: 'G-06', desc: '2척 동기화 2시간+ → 공조' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: '③', name: '스토우넷 (안강망)', nameEn: 'Stow Net (FYK)', image: '/dar03/stow-net.png',
|
||||||
|
specs: [
|
||||||
|
{ k: 'FAO 코드', v: 'FYK' },
|
||||||
|
{ k: '최소 망목', v: '≥ 55mm (캔버스형)' },
|
||||||
|
{ k: '주요 어종', v: '참조기 · 갈치 · 실치' },
|
||||||
|
{ k: '설치 방식', v: '말뚝·닻으로 고정' },
|
||||||
|
{ k: 'AIS', v: '어구 AIS 부표 부착 의무' },
|
||||||
|
{ k: '탐지 지표', v: '위치 이탈·출현·소실 주기' },
|
||||||
|
],
|
||||||
|
gCodes: [
|
||||||
|
{ code: 'G-01', desc: '위치 편차 200m+ → 구역 외' },
|
||||||
|
{ code: 'G-04', desc: '신호 30분 내 반복 → MMSI 조작' },
|
||||||
|
{ code: 'G-05', desc: '이동 500m+ → 인위적 이동' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: '④', name: '자망', nameEn: 'Gillnet (GNS/GND)', image: '/dar03/gillnet.png',
|
||||||
|
specs: [
|
||||||
|
{ k: 'FAO 코드', v: 'GNS / GND' },
|
||||||
|
{ k: '최소 망목', v: '55~144mm (어종별 상이)' },
|
||||||
|
{ k: '참조기 기준', v: '55mm (황해)' },
|
||||||
|
{ k: '은돔 기준', v: '100mm' },
|
||||||
|
{ k: 'AIS', v: '어구 AIS 부표 부착' },
|
||||||
|
{ k: '탐지 지표', v: '미등록 여부·기간 이탈' },
|
||||||
|
],
|
||||||
|
gCodes: [
|
||||||
|
{ code: 'G-02', desc: '금어기 내 신호 출현 → label=1' },
|
||||||
|
{ code: 'G-03', desc: '등록DB 미매칭 → 불법 자망' },
|
||||||
|
{ code: 'G-05', desc: '조류 보정 후 500m+ → 이동' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: '⑤', name: '통발 · 함정', nameEn: 'Pot / Trap (FPO)', image: '/dar03/pot-trap.png',
|
||||||
|
specs: [
|
||||||
|
{ k: 'FAO 코드', v: 'FPO' },
|
||||||
|
{ k: '탈출구 (꽃게)', v: 'Ø ≥ 8cm 또는 높이 33mm' },
|
||||||
|
{ k: '탈출구 (참게)', v: '측면 30mm + 말단 7cm' },
|
||||||
|
{ k: '주요 어종', v: '꽃게 · 참게 · 장어' },
|
||||||
|
{ k: '미성어 방류율', v: '95% 이상 (탈출구 적용 시)' },
|
||||||
|
{ k: 'AIS', v: '어구 AIS 부표 부착' },
|
||||||
|
],
|
||||||
|
gCodes: [
|
||||||
|
{ code: 'G-01', desc: '허가 구역 외 설치 → GIS 교차' },
|
||||||
|
{ code: 'G-04', desc: '어선-어구 출현·소실 60분+ 불일치' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface DAR03AisSignal {
|
||||||
|
no: string;
|
||||||
|
name: string;
|
||||||
|
aisType: string;
|
||||||
|
normal: string[];
|
||||||
|
threshold: string[];
|
||||||
|
gCodes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAR03_AIS_SIGNALS: DAR03AisSignal[] = [
|
||||||
|
{ no: '①', name: '저층 트롤', aisType: '어선 AIS (Class-A)',
|
||||||
|
normal: ['2.5~4.5 knot', 'U형 항적 반복'],
|
||||||
|
threshold: ['5 knot 이상 급가속', '금지 해역 진입'], gCodes: 'G-01, G-03' },
|
||||||
|
{ no: '②', name: '쌍끌이 트롤', aisType: '어선 AIS 2척',
|
||||||
|
normal: ['2~4 knot 동기화', '500m 간격 유지'],
|
||||||
|
threshold: ['동기화 2시간 이상', '동시 AIS 차단 30분+'], gCodes: 'G-02, G-06' },
|
||||||
|
{ no: '③', name: '스토우넷', aisType: '어구 AIS (Class-B)',
|
||||||
|
normal: ['위치 완전 고정', '신호 지속 출현'],
|
||||||
|
threshold: ['위치 편차 200m+', '출현·소실 30분 이내 반복'], gCodes: 'G-01, G-04, G-05' },
|
||||||
|
{ no: '④', name: '자망', aisType: '어구 AIS (Class-B)',
|
||||||
|
normal: ['위치 반고정', '조류에 따라 완만이동'],
|
||||||
|
threshold: ['등록 DB 미매칭', '금어기 내 신호 출현'], gCodes: 'G-02, G-03' },
|
||||||
|
{ no: '⑤', name: '통발', aisType: '어구 AIS (Class-B)',
|
||||||
|
normal: ['위치 완전 고정', '신호 지속'],
|
||||||
|
threshold: ['어선 접근·이탈 불일치 60분+', '구역 외 위치'], gCodes: 'G-01, G-04' },
|
||||||
|
];
|
||||||
|
|
||||||
// ─── ⑦ 7대 탐지 엔진 (불법조업 감시 알고리즘 v4.0) ───
|
// ─── ⑦ 7대 탐지 엔진 (불법조업 감시 알고리즘 v4.0) ───
|
||||||
|
|
||||||
interface DetectionEngine {
|
interface DetectionEngine {
|
||||||
@ -601,6 +744,166 @@ export function AIModelManagement() {
|
|||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── DAR-03 5종 어구 구조 비교 ── */}
|
||||||
|
<div className="bg-indigo-950/20 border border-indigo-900/30 rounded-xl p-4 flex items-center gap-4">
|
||||||
|
<Info className="w-5 h-5 text-indigo-400 shrink-0" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-[12px] font-bold text-indigo-300">DAR-03 · 5종 어구 구조 비교 (FAO ISSCFG)</div>
|
||||||
|
<div className="text-[10px] text-hint mt-0.5">
|
||||||
|
불법 어망·어구 탐지 참고자료 — FAO 국제 어구 분류 기준 · Wang et al.(2022) 논문 기반 · G-01~G-06 탐지 코드 연계
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge intent="purple" size="sm">참고자료</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 5종 어구 특성 비교 요약 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Anchor className="w-4 h-4 text-cyan-400" />
|
||||||
|
5종 어구 특성 비교 요약
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<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">FAO 코드</th>
|
||||||
|
<th className="text-center py-2">최소 망목</th>
|
||||||
|
<th className="text-center py-2">IUU 위험도</th>
|
||||||
|
<th className="text-center py-2">AIS 부착</th>
|
||||||
|
<th className="text-left py-2 px-2">주요 G코드</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{DAR03_GEAR_SUMMARY.map((g) => (
|
||||||
|
<tr key={g.no} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||||
|
<td className="py-2 px-2">
|
||||||
|
<span className="text-cyan-400 font-mono mr-2">{g.no}</span>
|
||||||
|
<span className="text-heading font-medium">{g.name}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-center text-label font-mono">{g.faoCode}</td>
|
||||||
|
<td className="py-2 text-center text-muted-foreground font-mono">{g.mesh}</td>
|
||||||
|
<td className="py-2 text-center">
|
||||||
|
<Badge intent={DAR03_IUU_INTENT[g.iuuRisk]} size="xs">{g.iuuRisk}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-center text-muted-foreground">{g.aisType}</td>
|
||||||
|
<td className="py-2 px-2 text-cyan-400 font-mono text-[9px]">{g.gCodes}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 어구별 구조 도식 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Eye className="w-4 h-4 text-blue-400" />
|
||||||
|
어구별 구조 도식 비교
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-[9px] text-hint italic">
|
||||||
|
※ FAO 어구 분류 기준 및 Wang et al.(2022) 논문 기반 개념도. 임계값은 사업 착수 후 해양경찰청 실무 데이터 분석으로 최종 확정.
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{DAR03_GEAR_DETAILS.map((g) => (
|
||||||
|
<Card key={g.no} className="bg-surface-raised border-border">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<span className="text-cyan-400 font-mono font-bold text-sm">{g.no}</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-[12px] font-bold text-heading">{g.name}</div>
|
||||||
|
<div className="text-[9px] text-hint">{g.nameEn}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface-overlay rounded-lg p-2 mb-3 flex items-center justify-center">
|
||||||
|
<img src={g.image} alt={g.nameEn} className="w-full h-auto max-h-48 object-contain" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 mb-3">
|
||||||
|
{g.specs.map((s) => (
|
||||||
|
<div key={s.k} className="flex justify-between text-[10px] px-2 py-1 bg-surface-overlay rounded">
|
||||||
|
<span className="text-muted-foreground">{s.k}</span>
|
||||||
|
<span className="text-heading font-medium">{s.v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-border pt-2">
|
||||||
|
<div className="text-[9px] text-hint mb-1.5 font-medium">G코드 연계</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{g.gCodes.map((gc) => (
|
||||||
|
<div key={gc.code} className="flex items-start gap-2 text-[9px]">
|
||||||
|
<Badge intent="cyan" size="xs">{gc.code}</Badge>
|
||||||
|
<span className="text-muted-foreground flex-1">{gc.desc}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* AIS 신호 특성 및 이상 판정 기준 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Radio className="w-4 h-4 text-purple-400" />
|
||||||
|
어구별 AIS 신호 특성 및 이상 판정 기준
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<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">AIS 유형</th>
|
||||||
|
<th className="text-left py-2">정상 신호 특성</th>
|
||||||
|
<th className="text-left py-2">이상 탐지 임계값</th>
|
||||||
|
<th className="text-left py-2 px-2">G코드</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{DAR03_AIS_SIGNALS.map((s) => (
|
||||||
|
<tr key={s.no} className="border-b border-border/50 hover:bg-surface-overlay transition-colors align-top">
|
||||||
|
<td className="py-2.5 px-2">
|
||||||
|
<span className="text-cyan-400 font-mono mr-1">{s.no}</span>
|
||||||
|
<span className="text-heading font-medium">{s.name}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 text-label">{s.aisType}</td>
|
||||||
|
<td className="py-2.5">
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{s.normal.map((n) => (
|
||||||
|
<li key={n} className="text-muted-foreground flex items-start gap-1">
|
||||||
|
<CheckCircle className="w-3 h-3 text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<span>{n}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5">
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{s.threshold.map((th) => (
|
||||||
|
<li key={th} className="text-muted-foreground flex items-start gap-1">
|
||||||
|
<AlertTriangle className="w-3 h-3 text-orange-400 shrink-0 mt-0.5" />
|
||||||
|
<span>{th}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-2 text-cyan-400 font-mono text-[9px]">{s.gCodes}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,11 @@ 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 { 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, EyeOff, RefreshCw } from 'lucide-react';
|
import {
|
||||||
|
Shield, AlertTriangle, Ship, Plus, Calendar, Users, Loader2, EyeOff, RefreshCw,
|
||||||
|
Navigation, Anchor, Radio, Target, Clock, Compass, Eye,
|
||||||
|
MapPin, Zap, ChevronRight, CheckCircle, Activity,
|
||||||
|
} from 'lucide-react';
|
||||||
import { BaseMap, createStaticLayers, 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';
|
||||||
@ -17,6 +21,64 @@ import { useSettingsStore } from '@stores/settingsStore';
|
|||||||
|
|
||||||
/* SFR-06: 단속 계획·경보 연계(단속 우선지역 예보) */
|
/* SFR-06: 단속 계획·경보 연계(단속 우선지역 예보) */
|
||||||
|
|
||||||
|
type PlanTab = 'overview' | 'single' | 'multi';
|
||||||
|
|
||||||
|
// ─── 단일 함정 작전 데이터 ──────────────────
|
||||||
|
|
||||||
|
const SINGLE_OP_TYPES = [
|
||||||
|
{ type: '정찰 순찰', desc: '특정 구역 내 불법조업 의심 선박 탐색·확인', duration: '4~8시간', crew: '8~12명', risk: '중간', icon: Eye },
|
||||||
|
{ type: '긴급 출동', desc: 'CRITICAL 경보 발생 시 즉시 현장 투입', duration: '1~3시간', crew: '10~15명', risk: '높음', icon: Zap },
|
||||||
|
{ type: '감시 초계', desc: '고위험 해역 정기 순찰 및 AIS 모니터링', duration: '6~12시간', crew: '8~10명', risk: '낮음', icon: Compass },
|
||||||
|
{ type: '근접 차단', desc: '도주 의심 선박 접근·정선명령·검문검색', duration: '2~4시간', crew: '12~18명', risk: '높음', icon: Target },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SINGLE_SCENARIOS = [
|
||||||
|
{ phase: '① 출항 전', actions: ['작전 브리핑 (위협 분석·해상상태)', '장비 점검 (레이더·AIS·카메라·무선)', '인력 배치 및 역할 분담'], time: '출항 60분 전' },
|
||||||
|
{ phase: '② 이동·접근', actions: ['최적 경로 항해 (연료·시간 최적화)', 'AIS 실시간 추적 + 레이더 교차확인', '본부 상황실 위치 보고 (15분 주기)'], time: '이동 중' },
|
||||||
|
{ phase: '③ 현장 작전', actions: ['정선명령 → 임검 또는 감시 유지', '증거 수집 (영상·사진·AIS 로그)', '위반 확인 시 나포 절차 진행'], time: '현장 도착 후' },
|
||||||
|
{ phase: '④ 복귀·보고', actions: ['작전 결과 보고 (상황실 실시간 전송)', '증거물 봉인 및 인계', '작전 후 회고 (AAR) 기록'], time: '작전 종료 후' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const AVAILABLE_SHIPS = [
|
||||||
|
{ name: '3009함', type: '1,000톤급', speed: '25kt', equip: '레이더·AIS·FLIR', status: '가용', crew: 18, zone: 'II구역' },
|
||||||
|
{ name: '3012함', type: '500톤급', speed: '30kt', equip: '레이더·AIS·EO/IR', status: '가용', crew: 12, zone: 'III구역' },
|
||||||
|
{ name: '1502함', type: '250톤급', speed: '28kt', equip: '레이더·AIS', status: '초계중', crew: 10, zone: 'I구역' },
|
||||||
|
{ name: '1507함', type: '250톤급', speed: '28kt', equip: '레이더·AIS·소나', status: '가용', crew: 10, zone: 'IV구역' },
|
||||||
|
{ name: '523정', type: '100톤급', speed: '32kt', equip: '레이더·AIS', status: '정비중', crew: 8, zone: '-' },
|
||||||
|
{ name: '527정', type: '100톤급', speed: '33kt', equip: '레이더·AIS·드론', status: '가용', crew: 8, zone: 'II구역' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 다함정 작전 데이터 ──────────────────
|
||||||
|
|
||||||
|
const MULTI_OP_TYPES = [
|
||||||
|
{ type: '포위 차단 작전', desc: '2~4척이 불법 선단을 포위하여 도주로 차단', ships: '3~4척', formation: '삼각·사각 포위', command: '지휘함 1 + 차단함 2~3', icon: Target },
|
||||||
|
{ type: '광역 초계 작전', desc: '넓은 해역을 분할하여 동시 순찰', ships: '4~6척', formation: '구역 분할 병렬', command: '지휘함 1 + 순찰함 3~5', icon: Compass },
|
||||||
|
{ type: '합동 단속 작전', desc: '항공·함정 협동으로 고위험 해역 집중 단속', ships: '3~5척 + 항공 1', formation: '항공 유도 + 수상 차단', command: '합동지휘소', icon: Shield },
|
||||||
|
{ type: '호위 작전', desc: '아국 어선 보호 및 불법어선 접근 억제', ships: '2~3척', formation: '호위 종대', command: '지휘함 1 + 호위함 1~2', icon: Ship },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MULTI_ROLES = [
|
||||||
|
{ role: '지휘함', duty: '작전 총괄·의사결정·상황실 보고', requirement: '500톤급 이상', comm: 'VHF Ch.16 + 보안채널', badge: 'critical' as const },
|
||||||
|
{ role: '차단함', duty: '도주로 차단·정선명령 집행', requirement: '250톤급 이상', comm: 'VHF + AIS 공유', badge: 'high' as const },
|
||||||
|
{ role: '감시함', duty: '원거리 레이더·FLIR 감시, 증거 촬영', requirement: '100톤급 이상', comm: 'VHF + 영상 전송', badge: 'info' as const },
|
||||||
|
{ role: '기동함', duty: '고속 접근·소형선박 추적·인력 투입', requirement: '100톤급 고속정', comm: 'VHF + 전술채널', badge: 'warning' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MULTI_COMM_PROTOCOL = [
|
||||||
|
{ channel: 'VHF Ch.16', purpose: '국제 조난·호출 (공용)', encryption: '비암호', usage: '초기 접촉·정선명령' },
|
||||||
|
{ channel: 'VHF Ch.22', purpose: '함정 간 전술 통신', encryption: '비암호', usage: '작전 기동·위치 보고' },
|
||||||
|
{ channel: '보안 채널 (HF)', purpose: '지휘함-상황실 암호 통신', encryption: 'AES-256', usage: '작전 지시·기밀 보고' },
|
||||||
|
{ channel: 'AIS 공유', purpose: '실시간 위치·속도 동기화', encryption: '-', usage: '함정 간 위치 인식' },
|
||||||
|
{ channel: '위성 데이터링크', purpose: '원거리 영상·데이터 전송', encryption: 'TLS 1.3', usage: '상황실 실시간 전송' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MULTI_SCENARIOS = [
|
||||||
|
{ phase: '① 작전 계획', tasks: ['AI 위험도 분석 기반 작전 구역 선정', '함정 배치 및 역할 분담', '통신 채널·보고 주기 확정', '기상·해황·조류 분석'], commander: '작전지휘관' },
|
||||||
|
{ phase: '② 전개·배치', tasks: ['각 함정 지정 위치로 이동', '포위망 형성 또는 구역 분할 완료', '지휘함 위치 확인 보고', 'AIS 상호 추적 개시'], commander: '지휘함장' },
|
||||||
|
{ phase: '③ 작전 수행', tasks: ['감시함 탐지 → 지휘함 판단 → 차단함 투입', '정선명령·임검·나포 절차', '도주 시 기동함 추적 + 차단함 우회', '증거 수집·실시간 상황실 전송'], commander: '현장지휘관' },
|
||||||
|
{ phase: '④ 철수·평가', tasks: ['나포 선박 호송 또는 감시 해제', '전 함정 귀항 또는 재배치', '합동 작전 보고서(AAR) 작성', '작전 데이터 DB 기록 (AI 학습용)'], commander: '작전지휘관' },
|
||||||
|
];
|
||||||
|
|
||||||
interface Plan { id: string; zone: string; lat: number; lng: number; risk: number; period: string; ships: string; crew: number; status: string; alert: string; [key: string]: unknown; }
|
interface Plan { id: string; zone: string; lat: number; lng: number; risk: number; period: string; ships: string; crew: number; status: string; alert: string; [key: string]: unknown; }
|
||||||
|
|
||||||
/** API 응답 → 화면용 Plan 변환 */
|
/** API 응답 → 화면용 Plan 변환 */
|
||||||
@ -54,6 +116,7 @@ export function EnforcementPlan() {
|
|||||||
const { t: tc } = useTranslation('common');
|
const { t: tc } = useTranslation('common');
|
||||||
const lang = useSettingsStore((s) => s.language);
|
const lang = useSettingsStore((s) => s.language);
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<PlanTab>('overview');
|
||||||
const [plans, setPlans] = useState<Plan[]>([]);
|
const [plans, setPlans] = useState<Plan[]>([]);
|
||||||
const [criticalEvents, setCriticalEvents] = useState<PredictionEvent[]>([]);
|
const [criticalEvents, setCriticalEvents] = useState<PredictionEvent[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -145,24 +208,17 @@ export function EnforcementPlan() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 로딩/에러 상태 */}
|
{/* 탭 */}
|
||||||
{loading && (
|
<div className="flex gap-0 border-b border-border">
|
||||||
<div className="text-center text-muted-foreground text-sm py-4">단속 계획을 불러오는 중...</div>
|
{([
|
||||||
)}
|
{ key: 'overview' as PlanTab, icon: Calendar, label: '단속 계획' },
|
||||||
{error && (
|
{ key: 'single' as PlanTab, icon: Navigation, label: '단일 함정 순찰 작전' },
|
||||||
<div className="text-center text-red-400 text-sm py-4">로드 실패: {error}</div>
|
{ key: 'multi' as PlanTab, icon: Anchor, label: '다함정 순찰 작전' },
|
||||||
)}
|
]).map((t) => (
|
||||||
|
<button type="button" key={t.key} onClick={() => setTab(t.key)}
|
||||||
<div className="flex gap-2">
|
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}
|
||||||
{ l: '오늘 계획', v: `${todayCount}건`, c: 'text-heading', i: Calendar },
|
</button>
|
||||||
{ l: '경보 발령', v: `${alertCount}건`, c: 'text-red-400', i: AlertTriangle },
|
|
||||||
{ l: '투입 함정', v: `${totalShips}척`, c: 'text-cyan-400', i: Ship },
|
|
||||||
{ l: '투입 인력', v: `${totalCrew}명`, c: 'text-green-400', i: Users },
|
|
||||||
].map(k => (
|
|
||||||
<div key={k.l} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
|
|
||||||
<k.i className={`w-4 h-4 ${k.c}`} /><span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{/* 탐지 기반 단속 대상 (CRITICAL 이벤트 통합) */}
|
{/* 탐지 기반 단속 대상 (CRITICAL 이벤트 통합) */}
|
||||||
@ -205,44 +261,319 @@ export function EnforcementPlan() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card>
|
{/* ── ① 단속 계획 (기존) ── */}
|
||||||
<CardContent className="p-4">
|
{tab === 'overview' && (
|
||||||
<div className="text-[12px] font-bold text-heading mb-3">경보 임계값 설정</div>
|
<div className="space-y-3">
|
||||||
<div className="flex gap-4 text-[10px]">
|
{loading && (
|
||||||
{[['위험도 ≥ 80', '상황실 즉시 경보 (알림+SMS)'], ['위험도 ≥ 60', '관련 부서 주의 알림'], ['위험도 ≥ 40', '참고 로그 기록']].map(([k, v]) => (
|
<div className="text-center text-muted-foreground text-sm py-4">단속 계획을 불러오는 중...</div>
|
||||||
<div key={k} className="flex items-center gap-2 px-3 py-2 bg-surface-overlay rounded-lg flex-1">
|
)}
|
||||||
<Badge intent="critical" size="sm">{k}</Badge>
|
{error && (
|
||||||
<span className="text-muted-foreground">{v}</span>
|
<div className="text-center text-red-400 text-sm py-4">로드 실패: {error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[
|
||||||
|
{ l: '오늘 계획', v: `${todayCount}건`, c: 'text-heading', i: Calendar },
|
||||||
|
{ l: '경보 발령', v: `${alertCount}건`, c: 'text-red-400', i: AlertTriangle },
|
||||||
|
{ l: '투입 함정', v: `${totalShips}척`, c: 'text-cyan-400', i: Ship },
|
||||||
|
{ l: '투입 인력', v: `${totalCrew}명`, c: 'text-green-400', i: Users },
|
||||||
|
].map(k => (
|
||||||
|
<div key={k.l} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
|
||||||
|
<k.i className={`w-4 h-4 ${k.c}`} /><span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
{criticalEvents.length > 0 && (
|
||||||
</Card>
|
<Card>
|
||||||
<DataTable data={PLANS} columns={cols} pageSize={10} searchPlaceholder="구역, 함정명 검색..." searchKeys={['zone', 'ships']} exportFilename="단속계획" />
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-red-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">미배정 CRITICAL 이벤트</span>
|
||||||
|
<Badge intent="critical" size="xs">{criticalEvents.length}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
||||||
|
{criticalEvents.map((evt) => (
|
||||||
|
<div key={evt.id} className="flex items-center gap-2 px-3 py-2 bg-red-500/5 border border-red-500/20 rounded-lg">
|
||||||
|
<Badge intent={getAlertLevelIntent(evt.level)} size="xs">
|
||||||
|
{getAlertLevelLabel(evt.level, tc, lang)}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-[10px] text-heading font-medium flex-1 truncate">{evt.title}</span>
|
||||||
|
<span className="text-[9px] text-hint shrink-0">{formatDateTime(evt.occurredAt)}</span>
|
||||||
|
<span className="text-[9px] text-cyan-400 font-mono shrink-0">{evt.vesselMmsi ?? '-'}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 단속 구역 지도 */}
|
<Card>
|
||||||
<Card>
|
<CardContent className="p-4">
|
||||||
<CardContent className="p-0 relative">
|
<div className="text-[12px] font-bold text-heading mb-3">경보 임계값 설정</div>
|
||||||
<BaseMap ref={mapRef} center={[36.2, 126.0]} zoom={7} height={420} className="rounded-lg overflow-hidden" />
|
<div className="flex gap-4 text-[10px]">
|
||||||
{/* 범례 */}
|
{[['위험도 ≥ 80', '상황실 즉시 경보 (알림+SMS)'], ['위험도 ≥ 60', '관련 부서 주의 알림'], ['위험도 ≥ 40', '참고 로그 기록']].map(([k, v]) => (
|
||||||
<div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
|
<div key={k} className="flex items-center gap-2 px-3 py-2 bg-surface-overlay rounded-lg flex-1">
|
||||||
<div className="text-[9px] text-muted-foreground font-bold mb-1.5">단속 구역</div>
|
<Badge intent="critical" size="sm">{k}</Badge>
|
||||||
<div className="space-y-1">
|
<span className="text-muted-foreground">{v}</span>
|
||||||
<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded-full bg-red-500/70 border border-red-500" /><span className="text-[8px] text-muted-foreground">위험도 80+</span></div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded-full bg-orange-500/70 border border-orange-500" /><span className="text-[8px] text-muted-foreground">위험도 60~80</span></div>
|
))}
|
||||||
<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded-full bg-yellow-500/70 border border-yellow-500" /><span className="text-[8px] text-muted-foreground">위험도 40~60</span></div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
<div className="flex items-center gap-3 mt-1.5 pt-1.5 border-t border-border">
|
</Card>
|
||||||
<div className="flex items-center gap-1"><div className="w-6 h-3 rounded-sm border-2 border-orange-500/50 bg-orange-500/15" /><span className="text-[7px] text-hint">확정</span></div>
|
<DataTable data={PLANS} columns={cols} pageSize={10} searchPlaceholder="구역, 함정명 검색..." searchKeys={['zone', 'ships']} exportFilename="단속계획" />
|
||||||
<div className="flex items-center gap-1"><div className="w-6 h-3 rounded-sm border border-dashed border-orange-500/40 bg-orange-500/5" /><span className="text-[7px] text-hint">계획중</span></div>
|
|
||||||
</div>
|
<Card>
|
||||||
|
<CardContent className="p-0 relative">
|
||||||
|
<BaseMap ref={mapRef} center={[36.2, 126.0]} zoom={7} height={420} className="rounded-lg overflow-hidden" />
|
||||||
|
<div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
|
||||||
|
<div className="text-[9px] text-muted-foreground font-bold mb-1.5">단속 구역</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded-full bg-red-500/70 border border-red-500" /><span className="text-[8px] text-muted-foreground">위험도 80+</span></div>
|
||||||
|
<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded-full bg-orange-500/70 border border-orange-500" /><span className="text-[8px] text-muted-foreground">위험도 60~80</span></div>
|
||||||
|
<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded-full bg-yellow-500/70 border border-yellow-500" /><span className="text-[8px] text-muted-foreground">위험도 40~60</span></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-1.5 pt-1.5 border-t border-border">
|
||||||
|
<div className="flex items-center gap-1"><div className="w-6 h-3 rounded-sm border-2 border-orange-500/50 bg-orange-500/15" /><span className="text-[7px] text-hint">확정</span></div>
|
||||||
|
<div className="flex items-center gap-1"><div className="w-6 h-3 rounded-sm border border-dashed border-orange-500/40 bg-orange-500/5" /><span className="text-[7px] text-hint">계획중</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
|
||||||
|
<span className="text-[10px] text-orange-400 font-bold">{PLANS.length}개</span>
|
||||||
|
<span className="text-[9px] text-hint ml-1">단속 구역 배치</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ② 단일 함정 순찰 작전 ── */}
|
||||||
|
{tab === 'single' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 작전 유형 */}
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{SINGLE_OP_TYPES.map((op) => (
|
||||||
|
<Card key={op.type} className="bg-surface-raised border-border">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<op.icon className="w-4 h-4 text-orange-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">{op.type}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[9px] text-hint mb-3">{op.desc}</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{[
|
||||||
|
['소요시간', op.duration],
|
||||||
|
['필요인력', op.crew],
|
||||||
|
['위험수준', op.risk],
|
||||||
|
].map(([k, v]) => (
|
||||||
|
<div key={k} className="flex justify-between text-[10px] px-2 py-1 bg-surface-overlay rounded">
|
||||||
|
<span className="text-muted-foreground">{k}</span>
|
||||||
|
<span className="text-heading font-medium">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute top-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
|
|
||||||
<span className="text-[10px] text-orange-400 font-bold">{PLANS.length}개</span>
|
{/* 가용 함정 현황 */}
|
||||||
<span className="text-[9px] text-hint ml-1">단속 구역 배치</span>
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Ship className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">가용 함정 현황</span>
|
||||||
|
<Badge intent="info" size="xs">{AVAILABLE_SHIPS.filter(s => s.status === '가용').length}척 가용</Badge>
|
||||||
|
</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-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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{AVAILABLE_SHIPS.map((s) => (
|
||||||
|
<tr key={s.name} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||||
|
<td className="py-2 px-2 text-heading font-bold">{s.name}</td>
|
||||||
|
<td className="py-2 text-center text-muted-foreground">{s.type}</td>
|
||||||
|
<td className="py-2 text-center text-cyan-400 font-mono">{s.speed}</td>
|
||||||
|
<td className="py-2 text-muted-foreground text-[9px]">{s.equip}</td>
|
||||||
|
<td className="py-2 text-center text-heading">{s.crew}명</td>
|
||||||
|
<td className="py-2 text-center text-muted-foreground">{s.zone}</td>
|
||||||
|
<td className="py-2 text-center"><Badge intent={getStatusIntent(s.status)} size="xs">{s.status}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 단일 함정 작전 절차 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Activity className="w-4 h-4 text-blue-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">단일 함정 작전 절차 (SOP)</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{SINGLE_SCENARIOS.map((s, i) => (
|
||||||
|
<div key={s.phase} className="relative">
|
||||||
|
{i < SINGLE_SCENARIOS.length - 1 && (
|
||||||
|
<ChevronRight className="absolute -right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-border z-10" />
|
||||||
|
)}
|
||||||
|
<div className="bg-surface-overlay rounded-lg p-3 h-full">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-[11px] font-bold text-orange-400">{s.phase}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-cyan-400 mb-2">{s.time}</div>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{s.actions.map((a) => (
|
||||||
|
<li key={a} className="flex items-start gap-1.5 text-[9px] text-muted-foreground">
|
||||||
|
<CheckCircle className="w-3 h-3 text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<span>{a}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ③ 다함정 순찰 작전 ── */}
|
||||||
|
{tab === 'multi' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 작전 유형 */}
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{MULTI_OP_TYPES.map((op) => (
|
||||||
|
<Card key={op.type} className="bg-surface-raised border-border">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<op.icon className="w-4 h-4 text-blue-400" />
|
||||||
|
<span className="text-[11px] font-bold text-heading">{op.type}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[9px] text-hint mb-3">{op.desc}</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{[
|
||||||
|
['투입 함정', op.ships],
|
||||||
|
['대형', op.formation],
|
||||||
|
['지휘 체계', op.command],
|
||||||
|
].map(([k, v]) => (
|
||||||
|
<div key={k} className="flex justify-between text-[10px] px-2 py-1 bg-surface-overlay rounded">
|
||||||
|
<span className="text-muted-foreground">{k}</span>
|
||||||
|
<span className="text-heading font-medium text-[9px]">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
{/* 함정 역할 분담 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Users className="w-4 h-4 text-purple-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">함정별 역할 분담</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{MULTI_ROLES.map((r) => (
|
||||||
|
<div key={r.role} className="bg-surface-overlay rounded-lg p-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Badge intent={r.badge} size="sm">{r.role}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="text-[10px] text-heading">{r.duty}</div>
|
||||||
|
<div className="flex justify-between text-[9px] px-2 py-1 bg-surface-raised rounded">
|
||||||
|
<span className="text-muted-foreground">요구 사양</span>
|
||||||
|
<span className="text-label">{r.requirement}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-[9px] px-2 py-1 bg-surface-raised rounded">
|
||||||
|
<span className="text-muted-foreground">통신</span>
|
||||||
|
<span className="text-label">{r.comm}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 통신 프로토콜 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Radio className="w-4 h-4 text-green-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">함정 간 통신 프로토콜</span>
|
||||||
|
</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">사용 시점</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{MULTI_COMM_PROTOCOL.map((c) => (
|
||||||
|
<tr key={c.channel} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||||
|
<td className="py-2 px-2 text-heading font-medium">{c.channel}</td>
|
||||||
|
<td className="py-2 text-muted-foreground">{c.purpose}</td>
|
||||||
|
<td className="py-2 text-center">
|
||||||
|
<Badge intent={c.encryption === 'AES-256' || c.encryption === 'TLS 1.3' ? 'success' : 'muted'} size="xs">{c.encryption}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-muted-foreground">{c.usage}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 다함정 합동 작전 절차 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Shield className="w-4 h-4 text-orange-400" />
|
||||||
|
<span className="text-[12px] font-bold text-heading">다함정 합동 작전 절차 (SOP)</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{MULTI_SCENARIOS.map((s, i) => (
|
||||||
|
<div key={s.phase} className="relative">
|
||||||
|
{i < MULTI_SCENARIOS.length - 1 && (
|
||||||
|
<ChevronRight className="absolute -right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-border z-10" />
|
||||||
|
)}
|
||||||
|
<div className="bg-surface-overlay rounded-lg p-3 h-full">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-[11px] font-bold text-blue-400">{s.phase}</span>
|
||||||
|
<Badge intent="muted" size="xs">{s.commander}</Badge>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{s.tasks.map((t) => (
|
||||||
|
<li key={t} className="flex items-start gap-1.5 text-[9px] text-muted-foreground">
|
||||||
|
<CheckCircle className="w-3 h-3 text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<span>{t}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,7 +36,10 @@
|
|||||||
"accessLogs": "Access Logs",
|
"accessLogs": "Access Logs",
|
||||||
"loginHistory": "Login History",
|
"loginHistory": "Login History",
|
||||||
"aiSecurity": "AI Security",
|
"aiSecurity": "AI Security",
|
||||||
"aiAgentSecurity": "AI Agent Security"
|
"aiAgentSecurity": "AI Agent Security",
|
||||||
|
"dataRetentionPolicy": "Data Retention",
|
||||||
|
"dataModelVerification": "Model Verification",
|
||||||
|
"performanceMonitoring": "Performance Monitoring"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"active": "Active",
|
"active": "Active",
|
||||||
|
|||||||
@ -36,7 +36,10 @@
|
|||||||
"accessLogs": "접근 이력",
|
"accessLogs": "접근 이력",
|
||||||
"loginHistory": "로그인 이력",
|
"loginHistory": "로그인 이력",
|
||||||
"aiSecurity": "AI 보안",
|
"aiSecurity": "AI 보안",
|
||||||
"aiAgentSecurity": "AI Agent 보안"
|
"aiAgentSecurity": "AI Agent 보안",
|
||||||
|
"dataRetentionPolicy": "데이터 보관·파기",
|
||||||
|
"dataModelVerification": "데이터 모델 검증",
|
||||||
|
"performanceMonitoring": "성능 모니터링"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"active": "활성",
|
"active": "활성",
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user