feat: admin-deidentify 기능 develop 머지 #168
@ -128,55 +128,125 @@ INSERT INTO RESCUE_OPS (
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- 4. RESCUE_SCENARIO 시드 데이터 (5건, RESCUE_OPS_SN=1 기준)
|
||||
-- 4. RESCUE_SCENARIO 시드 데이터 (10건, RESCUE_OPS_SN=1 기준)
|
||||
-- 긴급구난 모델 이론 기반 시간 단계별 시나리오
|
||||
-- - 손상복원성(Damage Stability): GM, 횡경사, 트림 진행
|
||||
-- - 종강도(Longitudinal Strength): BM 비율 모니터링
|
||||
-- - 유출 모델링: 파공부 유출률 변화
|
||||
-- - 부력 잔여량: 침수 구획 확대에 따른 부력 변화
|
||||
-- ============================================================
|
||||
INSERT INTO RESCUE_SCENARIO (
|
||||
RESCUE_OPS_SN, TIME_STEP, SCENARIO_DTM, SVRT_CD,
|
||||
GM_M, LIST_DEG, TRIM_M, BUOYANCY_PCT, OIL_RATE_LPM, BM_RATIO_PCT,
|
||||
DESCRIPTION, COMPARTMENTS, ASSESSMENT, ACTIONS, SORT_ORD
|
||||
) VALUES
|
||||
-- S-01: 사고 발생 (Initial Impact)
|
||||
-- 충돌 직후 초기 손상 상태. 손상복원성 이론에 따라 파공부 침수 시작, GM 급락
|
||||
(
|
||||
1, 'T+0h', '2024-10-27 10:30:00+09', 'CRITICAL',
|
||||
0.8, 15.0, 2.5, 30.0, 100.0, 92.0,
|
||||
'좌현 35° 충돌로 No.1P 화물탱크 파공, 벙커C유 유출 개시. 좌현 경사 15°, GM 위험수준.',
|
||||
'좌현 35° 충돌로 No.1P 화물탱크 파공. 벙커C유 유출 개시. 손상복원성 분석: 초기 GM 0.8m으로 IMO 기준(1.0m) 미달, 복원력 위험 판정.',
|
||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"BREACHED","color":"var(--red)"},{"name":"#2 Port Tank","status":"RISK","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
||||
'[{"label":"복원력","value":"위험 (GM < 1.0m)","color":"var(--red)"},{"label":"유출 위험","value":"활발 유출중","color":"var(--red)"},{"label":"선체 강도","value":"BM 92% (경계)","color":"var(--orange)"},{"label":"승선인원","value":"15/20 확인, 5명 수색중","color":"var(--red)"}]',
|
||||
'[{"time":"10:30","text":"충돌 발생, VHF Ch.16 조난 통보","color":"var(--red)"},{"time":"10:35","text":"해경 3009함 출동 지시","color":"var(--orange)"},{"time":"10:42","text":"인근 선박 구조 활동 개시","color":"var(--cyan)"},{"time":"10:50","text":"유출유 방제선 배치 요청","color":"var(--orange)"}]',
|
||||
'[{"label":"복원력","value":"위험 (GM 0.8m < IMO 1.0m)","color":"var(--red)"},{"label":"유출 위험","value":"활발 유출중 (100 L/min)","color":"var(--red)"},{"label":"선체 강도","value":"BM 92% (경계)","color":"var(--orange)"},{"label":"승선인원","value":"15/20 확인, 5명 수색중","color":"var(--red)"}]',
|
||||
'[{"time":"10:30","text":"충돌 발생, VHF Ch.16 조난 통보 (GMDSS DSC Alert)","color":"var(--red)"},{"time":"10:32","text":"EPIRB 자동 발신 확인","color":"var(--red)"},{"time":"10:35","text":"해경 3009함 출동 지시","color":"var(--orange)"},{"time":"10:42","text":"인근 선박 구조 활동 개시","color":"var(--cyan)"}]',
|
||||
1
|
||||
),
|
||||
-- S-02: 초동 손상 평가 (Emergency Damage Assessment)
|
||||
-- 잠수사 투입, 파공부 규모 확인. 침수 진행 모델링: 파공면적 A, 수두차 h 기반 유입률 Q=Cd·A·√(2gh)
|
||||
(
|
||||
1, 'T+2h', '2024-10-27 12:30:00+09', 'HIGH',
|
||||
0.6, 18.0, 3.2, 25.0, 150.0, 88.0,
|
||||
'침수 확대로 경사 증가, 유출량 증가 추세. 긴급 이초 작업 검토 필요.',
|
||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"RISK","color":"var(--orange)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
||||
'[{"label":"복원력","value":"위험 (GM 0.6m)","color":"var(--red)"},{"label":"유출 위험","value":"증가 추세","color":"var(--red)"},{"label":"선체 강도","value":"BM 88%","color":"var(--orange)"},{"label":"승선인원","value":"전원 퇴선 완료","color":"var(--green)"}]',
|
||||
'[{"time":"12:00","text":"2차 침수 확인 (#2 PT)","color":"var(--red)"},{"time":"12:15","text":"긴급 이초 작업 개시","color":"var(--orange)"},{"time":"12:20","text":"오일펜스 1차 전개 완료","color":"var(--cyan)"},{"time":"12:30","text":"항공기 유출유 촬영 요청","color":"var(--cyan)"}]',
|
||||
1, 'T+30m', '2024-10-27 11:00:00+09', 'CRITICAL',
|
||||
0.7, 17.0, 2.8, 28.0, 120.0, 90.0,
|
||||
'잠수사 수중 조사 결과 좌현 No.1P 파공 크기 1.2m×0.8m 확인. Bernoulli 유입률 모델 적용: 수두차 4.5m 기준 유입률 약 2.1㎥/min. 30분 경과 침수량 추정 63㎥.',
|
||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"BREACHED","color":"var(--red)"},{"name":"#2 Port Tank","status":"RISK","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
||||
'[{"label":"복원력","value":"악화 (GM 0.7m, GZ 커브 감소)","color":"var(--red)"},{"label":"유출 위험","value":"증가 (120 L/min)","color":"var(--red)"},{"label":"선체 강도","value":"BM 90% — 종강도 모니터링 개시","color":"var(--orange)"},{"label":"승선인원","value":"15명 퇴선, 5명 수색중","color":"var(--red)"}]',
|
||||
'[{"time":"10:50","text":"잠수사 투입, 수중 손상 조사 개시","color":"var(--cyan)"},{"time":"10:55","text":"파공 규모 확인: 1.2m×0.8m, 수선 하 2.5m","color":"var(--red)"},{"time":"11:00","text":"손상복원성 재계산 — IMO Res.A.749 기준 위험","color":"var(--red)"},{"time":"11:00","text":"유출유 방제선 배치 요청","color":"var(--orange)"}]',
|
||||
2
|
||||
),
|
||||
-- S-03: 구조 작전 개시 (SAR Operations Initiated)
|
||||
-- 해경 함정 현장 도착, 인명 구조 우선. GM 지속 하락, 복원력 한계 접근
|
||||
(
|
||||
1, 'T+6h', '2024-10-27 16:30:00+09', 'HIGH',
|
||||
0.4, 12.0, 2.8, 35.0, 80.0, 90.0,
|
||||
'평형수 이동으로 경사 일부 복원. 유출률 감소 추세.',
|
||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"RISK","color":"var(--orange)"},{"name":"#3 Stbd Tank","status":"RISK","color":"var(--orange)"}]',
|
||||
'[{"label":"복원력","value":"개선 추세 (GM 0.4m)","color":"var(--orange)"},{"label":"유출 위험","value":"감소 추세","color":"var(--orange)"},{"label":"선체 강도","value":"BM 90%","color":"var(--orange)"},{"label":"구조 상황","value":"구조 작전 진행중","color":"var(--cyan)"}]',
|
||||
'[{"time":"14:00","text":"평형수 이동 작업 개시","color":"var(--cyan)"},{"time":"15:00","text":"해상크레인 도착","color":"var(--cyan)"},{"time":"15:30","text":"잔류유 이적 작업 개시","color":"var(--orange)"},{"time":"16:30","text":"예인준비 완료","color":"var(--green)"}]',
|
||||
1, 'T+1h', '2024-10-27 11:30:00+09', 'CRITICAL',
|
||||
0.65, 18.5, 3.0, 26.0, 135.0, 89.0,
|
||||
'해경 3009함 현장 도착, SAR 작전 개시. 표류 예측 모델(Leeway Model) 적용: 풍속 8m/s, 해류 2.5kn NE 조건에서 실종자 표류 반경 1.2nm 산정. GZ 커브 분석: 최대 복원력 각도 25°로 감소.',
|
||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODING","color":"var(--red)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
||||
'[{"label":"복원력","value":"한계 접근 (GM 0.65m, GZ_max 25°)","color":"var(--red)"},{"label":"유출 위험","value":"파공 확대 우려 (135 L/min)","color":"var(--red)"},{"label":"선체 강도","value":"BM 89% — Hogging 모멘트 증가","color":"var(--orange)"},{"label":"인명구조","value":"실종 5명 수색중, 표류 반경 1.2nm","color":"var(--red)"}]',
|
||||
'[{"time":"11:10","text":"해경 3009함 현장 도착, SAR 구역 설정","color":"var(--cyan)"},{"time":"11:15","text":"실종자 Leeway 표류 예측 모델 적용","color":"var(--cyan)"},{"time":"11:20","text":"회전익 항공기 수색 개시 (R=1.2nm)","color":"var(--cyan)"},{"time":"11:30","text":"#2 Port Tank 2차 침수 징후 감지","color":"var(--red)"}]',
|
||||
3
|
||||
),
|
||||
-- S-04: 침수 확대 및 복원력 위기 (Flooding Progression & Stability Crisis)
|
||||
-- 2차 구획 침수, 자유표면효과(Free Surface Effect) 반영 GM 급락
|
||||
(
|
||||
1, 'T+12h', '2024-10-27 22:30:00+09', 'MEDIUM',
|
||||
0.6, 8.0, 1.5, 50.0, 30.0, 94.0,
|
||||
'예인 작업 진행중, 선체 안정화 확인. 유출 대부분 차단.',
|
||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
||||
'[{"label":"복원력","value":"안정 (GM 0.6m)","color":"var(--orange)"},{"label":"유출 위험","value":"대부분 차단","color":"var(--green)"},{"label":"선체 강도","value":"BM 94%","color":"var(--green)"},{"label":"예인 상태","value":"목포항 예인 진행중","color":"var(--cyan)"}]',
|
||||
'[{"time":"18:00","text":"예인 개시 (목포항 방향)","color":"var(--cyan)"},{"time":"19:00","text":"유출유 차단 확인","color":"var(--green)"},{"time":"20:00","text":"야간 감시 체제 전환","color":"var(--orange)"},{"time":"22:30","text":"예인 50% 진행","color":"var(--cyan)"}]',
|
||||
1, 'T+2h', '2024-10-27 12:30:00+09', 'CRITICAL',
|
||||
0.5, 20.0, 3.5, 22.0, 160.0, 86.0,
|
||||
'격벽 관통으로 #2 Port Tank 침수 확대. 자유표면효과(FSE) 보정: GM_fluid = GM_solid - Σ(i/∇) = 0.5m. 종강도 분석: 중앙부 Sagging 모멘트 허용치 86% 도달. 침몰 위험 단계 진입.',
|
||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"RISK","color":"var(--orange)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
||||
'[{"label":"복원력","value":"위기 (GM 0.5m, FSE 보정 후)","color":"var(--red)"},{"label":"유출 위험","value":"최대치 접근 (160 L/min)","color":"var(--red)"},{"label":"선체 강도","value":"BM 86% — Sagging 허용치 경고","color":"var(--red)"},{"label":"승선인원","value":"실종 3명 발견, 2명 수색 지속","color":"var(--orange)"}]',
|
||||
'[{"time":"12:00","text":"#2 Port Tank 격벽 관통 침수 확인","color":"var(--red)"},{"time":"12:10","text":"자유표면효과(FSE) 보정 재계산","color":"var(--red)"},{"time":"12:15","text":"긴급 Counter-Flooding 검토","color":"var(--orange)"},{"time":"12:30","text":"실종자 3명 추가 발견 구조","color":"var(--green)"}]',
|
||||
4
|
||||
),
|
||||
-- S-05: 응급 복원 작업 (Emergency Counter-Flooding)
|
||||
-- Counter-Flooding 이론: 반대편 구획 의도적 침수로 횡경사 교정
|
||||
(
|
||||
1, 'T+3h', '2024-10-27 13:30:00+09', 'HIGH',
|
||||
0.55, 16.0, 3.2, 25.0, 140.0, 87.0,
|
||||
'Counter-Flooding 실시: #3 Stbd Tank에 평형수 280톤 주입하여 횡경사 20°→16° 교정. 복원력 일시적 개선. 종강도: Counter-Flooding으로 중량 재배분, BM 87% 유지. 유출률 감소 추세.',
|
||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"RISK","color":"var(--orange)"},{"name":"#3 Stbd Tank","status":"BALLASTED","color":"var(--orange)"}]',
|
||||
'[{"label":"복원력","value":"개선 중 (GM 0.55m, 경사 16°)","color":"var(--orange)"},{"label":"유출 위험","value":"감소 추세 (140 L/min)","color":"var(--orange)"},{"label":"선체 강도","value":"BM 87% — Counter-Flooding 영향 평가","color":"var(--orange)"},{"label":"구조 상황","value":"실종 2명 수색 지속, 헬기 투입","color":"var(--orange)"}]',
|
||||
'[{"time":"12:45","text":"Counter-Flooding 결정 — #3 Stbd 평형수 주입 개시","color":"var(--orange)"},{"time":"13:00","text":"평형수 280톤 주입, 횡경사 20°→18° 교정 진행","color":"var(--cyan)"},{"time":"13:15","text":"종강도 재계산 — 허용 범위 내 확인","color":"var(--cyan)"},{"time":"13:30","text":"횡경사 16° 안정화, 유출률 감소 확인","color":"var(--green)"}]',
|
||||
5
|
||||
),
|
||||
-- S-06: 선체 안정화 및 잔류유 이적 (Hull Stabilization & Oil Transfer)
|
||||
-- 평형수 조정 완료, 임시 보강. Trim/Stability Booklet 기준 안정 범위 진입
|
||||
(
|
||||
1, 'T+6h', '2024-10-27 16:30:00+09', 'HIGH',
|
||||
0.7, 12.0, 2.5, 32.0, 80.0, 90.0,
|
||||
'임시 수중패치 설치, 유입률 감소. 평형수 재조정으로 GM 0.7m 회복. Trim/Stability Booklet 기준 예인 가능 최소 조건(GM≥0.5m, List≤15°) 충족. 잔류유 이적선(M/T) 배치.',
|
||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"PATCHED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"BALLASTED","color":"var(--orange)"}]',
|
||||
'[{"label":"복원력","value":"개선 (GM 0.7m, 예인 가능 조건 충족)","color":"var(--orange)"},{"label":"유출 위험","value":"수중패치 효과 (80 L/min)","color":"var(--orange)"},{"label":"선체 강도","value":"BM 90% — 안정 범위","color":"var(--green)"},{"label":"구조 상황","value":"전원 구조 완료 (실종 2명 발견)","color":"var(--green)"}]',
|
||||
'[{"time":"14:00","text":"수중패치 설치 작업 개시","color":"var(--cyan)"},{"time":"14:30","text":"잠수사 수중패치 설치 완료","color":"var(--green)"},{"time":"15:00","text":"해상크레인 도착, 잔류유 이적 준비","color":"var(--cyan)"},{"time":"16:30","text":"잔류유 1차 이적 완료 (약 45kL), 예인 준비 개시","color":"var(--green)"}]',
|
||||
6
|
||||
),
|
||||
-- S-07: 오일 방제 전개 (Oil Boom Deployment & Containment)
|
||||
-- 방제 이론: 오일붐 2중 전개, 유회수기 배치, 확산 모델 기반 방제 구역 설정
|
||||
(
|
||||
1, 'T+8h', '2024-10-27 18:30:00+09', 'MEDIUM',
|
||||
0.8, 10.0, 2.0, 38.0, 55.0, 91.0,
|
||||
'오일붐 2중 전개 완료, 유회수기 3대 가동. 유출유 확산 예측 모델(GNOME) 적용: 풍향 NE 8m/s, 해류 2.5kn 조건에서 12시간 후 확산 면적 2.3km² 예측. 기계적 회수율 35% 달성.',
|
||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"PATCHED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"BALLASTED","color":"var(--orange)"}]',
|
||||
'[{"label":"복원력","value":"안정 (GM 0.8m)","color":"var(--orange)"},{"label":"유출 위험","value":"방제 진행 (55 L/min, 회수율 35%)","color":"var(--orange)"},{"label":"선체 강도","value":"BM 91%","color":"var(--green)"},{"label":"방제 현황","value":"오일붐 2중, 유회수기 3대 가동","color":"var(--cyan)"}]',
|
||||
'[{"time":"17:00","text":"오일붐 1차 전개 (500m)","color":"var(--cyan)"},{"time":"17:30","text":"오일붐 2차 전개 (300m, 이중 방어선)","color":"var(--cyan)"},{"time":"17:45","text":"유회수기 3대 배치·가동 개시","color":"var(--cyan)"},{"time":"18:30","text":"GNOME 확산 예측 갱신 — 방제 구역 재설정","color":"var(--orange)"}]',
|
||||
7
|
||||
),
|
||||
-- S-08: 예인 작업 개시 (Towing Operation Commenced)
|
||||
-- 예인 이론: 예인 저항 계산, 기상·해상 조건 판단, 예인 경로 최적화
|
||||
(
|
||||
1, 'T+12h', '2024-10-27 22:30:00+09', 'MEDIUM',
|
||||
0.9, 8.0, 1.5, 45.0, 30.0, 94.0,
|
||||
'예인 개시. 예인 저항 계산: Rt = 1/2·ρ·Cd·A·V² 기반 예인선 4,000HP급 배정. 예인 경로: 현 위치→목포항 직선 42nm, 예인 속도 3kn 기준 ETA 14시간. 야간 감시 체제 전환.',
|
||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"PATCHED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"BALLASTED","color":"var(--orange)"}]',
|
||||
'[{"label":"복원력","value":"안정 (GM 0.9m)","color":"var(--orange)"},{"label":"유출 위험","value":"억제 중 (30 L/min)","color":"var(--green)"},{"label":"선체 강도","value":"BM 94% — 예인 하중 반영","color":"var(--green)"},{"label":"예인 상태","value":"목포항 방향, ETA 14h, 3kn","color":"var(--cyan)"}]',
|
||||
'[{"time":"18:00","text":"예인 접속 완료, 예인삭 250m 전개","color":"var(--cyan)"},{"time":"18:30","text":"예인 개시 (목포항 방향, 3kn)","color":"var(--cyan)"},{"time":"20:00","text":"야간 감시 체제 전환 (2시간 교대)","color":"var(--orange)"},{"time":"22:30","text":"예인 진행률 30%, 선체 상태 안정","color":"var(--green)"}]',
|
||||
8
|
||||
),
|
||||
-- S-09: 이동 중 감시 및 안정성 유지 (Transit Monitoring)
|
||||
-- 예인 중 동적 안정성 모니터링: 파랑 응답(RAO) 기반 횡동요 예측
|
||||
(
|
||||
1, 'T+18h', '2024-10-28 04:30:00+09', 'MEDIUM',
|
||||
1.0, 5.0, 1.0, 55.0, 15.0, 96.0,
|
||||
'예인 진행률 65%. 파랑 응답 분석(RAO): 유의파고 1.2m, 주기 6s 조건에서 횡동요 진폭 ±3° 예측 — 안전 범위 내. 잔류 유출률 15 L/min으로 대폭 감소. 선체 안정성 지속 개선.',
|
||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"PATCHED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"STABLE","color":"var(--green)"}]',
|
||||
'[{"label":"복원력","value":"양호 (GM 1.0m, IMO 기준 충족)","color":"var(--green)"},{"label":"유출 위험","value":"미량 유출 (15 L/min)","color":"var(--green)"},{"label":"선체 강도","value":"BM 96% — 정상 범위","color":"var(--green)"},{"label":"예인 상태","value":"진행률 65%, ETA 5.5h","color":"var(--cyan)"}]',
|
||||
'[{"time":"00:00","text":"야간 예인 정상 진행, 기상 양호","color":"var(--green)"},{"time":"02:00","text":"파랑 응답 분석 — 안전 범위 확인","color":"var(--green)"},{"time":"03:00","text":"잔류유 유출률 15 L/min 확인","color":"var(--green)"},{"time":"04:30","text":"목포항 VTS 통보, 입항 예정 협의","color":"var(--cyan)"}]',
|
||||
9
|
||||
),
|
||||
-- S-10: 상황 종료 및 사후 평가 (Resolution & Post-Assessment)
|
||||
-- 접안 완료, 잔류유 이적, 사후 안정성 평가
|
||||
(
|
||||
1, 'T+24h', '2024-10-28 10:30:00+09', 'RESOLVED',
|
||||
1.2, 3.0, 0.5, 75.0, 5.0, 98.0,
|
||||
'목포항 도착, 선체 안정. 잔류유 이적 완료.',
|
||||
'[{"name":"#1 FP Tank","status":"SEALED","color":"var(--orange)"},{"name":"#1 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
||||
'[{"label":"복원력","value":"안전 (GM 1.2m)","color":"var(--green)"},{"label":"유출 위험","value":"차단 완료","color":"var(--green)"},{"label":"선체 강도","value":"BM 98% 정상","color":"var(--green)"},{"label":"예인 상태","value":"목포항 접안 완료","color":"var(--green)"}]',
|
||||
'[{"time":"06:00","text":"목포항 접근","color":"var(--cyan)"},{"time":"08:00","text":"도선사 승선, 접안 개시","color":"var(--cyan)"},{"time":"09:30","text":"접안 완료","color":"var(--green)"},{"time":"10:30","text":"잔류유 이적 완료, 상황 종료","color":"var(--green)"}]',
|
||||
5
|
||||
'목포항 접안 완료. 잔류유 전량 이적(총 120kL). 최종 손상복원성 평가: GM 1.2m으로 IMO 기준 충족, 횡경사 3° 잔류. 종강도 BM 98% 정상. 방제 총 회수량 85kL (회수율 71%). 상황 종료 선포.',
|
||||
'[{"name":"#1 FP Tank","status":"SEALED","color":"var(--orange)"},{"name":"#1 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"STABLE","color":"var(--green)"}]',
|
||||
'[{"label":"복원력","value":"안전 (GM 1.2m, IMO 기준 초과)","color":"var(--green)"},{"label":"유출 위험","value":"차단 완료 (잔류 5 L/min)","color":"var(--green)"},{"label":"선체 강도","value":"BM 98% 정상","color":"var(--green)"},{"label":"최종 상태","value":"접안 완료, 잔류유 이적 완료","color":"var(--green)"}]',
|
||||
'[{"time":"06:00","text":"목포항 접근, 도선사 대기","color":"var(--cyan)"},{"time":"08:00","text":"도선사 승선, 접안 개시","color":"var(--cyan)"},{"time":"09:30","text":"접안 완료, 잔류유 이적선 접현","color":"var(--green)"},{"time":"10:30","text":"잔류유 전량 이적 완료, 상황 종료 선포","color":"var(--green)"}]',
|
||||
10
|
||||
);
|
||||
|
||||
@ -20,6 +20,11 @@ import CollectHrPanel from './CollectHrPanel';
|
||||
import MonitorForecastPanel from './MonitorForecastPanel';
|
||||
import VesselMaterialsPanel from './VesselMaterialsPanel';
|
||||
import DeidentifyPanel from './DeidentifyPanel';
|
||||
import RndPoseidonPanel from './RndPoseidonPanel';
|
||||
import RndKospsPanel from './RndKospsPanel';
|
||||
import RndHnsAtmosPanel from './RndHnsAtmosPanel';
|
||||
import RndRescuePanel from './RndRescuePanel';
|
||||
import SystemArchPanel from './SystemArchPanel';
|
||||
|
||||
/** 기존 패널이 있는 메뉴 ID 매핑 */
|
||||
const PANEL_MAP: Record<string, () => React.JSX.Element> = {
|
||||
@ -44,6 +49,11 @@ const PANEL_MAP: Record<string, () => React.JSX.Element> = {
|
||||
'collect-hr': () => <CollectHrPanel />,
|
||||
'monitor-forecast': () => <MonitorForecastPanel />,
|
||||
deidentify: () => <DeidentifyPanel />,
|
||||
'rnd-poseidon': () => <RndPoseidonPanel />,
|
||||
'rnd-kosps': () => <RndKospsPanel />,
|
||||
'rnd-hns-atmos': () => <RndHnsAtmosPanel />,
|
||||
'rnd-rescue': () => <RndRescuePanel />,
|
||||
'system-arch': () => <SystemArchPanel />,
|
||||
};
|
||||
|
||||
export function AdminView() {
|
||||
|
||||
638
frontend/src/tabs/admin/components/RndHnsAtmosPanel.tsx
Normal file
638
frontend/src/tabs/admin/components/RndHnsAtmosPanel.tsx
Normal file
@ -0,0 +1,638 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
// ─── 타입 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
type PipelineStatus = '정상' | '지연' | '중단';
|
||||
type ReceiveStatus = '수신완료' | '수신대기' | '수신실패' | '시간초과';
|
||||
type ProcessStatus = '처리완료' | '처리중' | '대기' | '오류';
|
||||
type DataSource = 'HYCOM' | '기상청' | '충북대 API';
|
||||
type AlertLevel = '경고' | '주의' | '정보';
|
||||
|
||||
interface PipelineNode {
|
||||
id: string;
|
||||
name: string;
|
||||
status: PipelineStatus;
|
||||
lastReceived: string;
|
||||
cycle: string;
|
||||
}
|
||||
|
||||
interface DataLogRow {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
source: DataSource;
|
||||
dataType: string;
|
||||
size: string;
|
||||
receiveStatus: ReceiveStatus;
|
||||
processStatus: ProcessStatus;
|
||||
}
|
||||
|
||||
interface AlertItem {
|
||||
id: string;
|
||||
level: AlertLevel;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// ─── Mock 데이터 ────────────────────────────────────────────────────────────────
|
||||
|
||||
const MOCK_PIPELINE: PipelineNode[] = [
|
||||
{
|
||||
id: 'hycom',
|
||||
name: 'HYCOM 해양순환모델',
|
||||
status: '정상',
|
||||
lastReceived: '2026-04-11 06:00',
|
||||
cycle: '6시간 주기',
|
||||
},
|
||||
{
|
||||
id: 'kma',
|
||||
name: '기상청 수치모델',
|
||||
status: '정상',
|
||||
lastReceived: '2026-04-11 06:00',
|
||||
cycle: '3시간 주기',
|
||||
},
|
||||
{
|
||||
id: 'chungbuk-api',
|
||||
name: '충북대 API 서버',
|
||||
status: '정상',
|
||||
lastReceived: '2026-04-11 06:05',
|
||||
cycle: 'API 호출',
|
||||
},
|
||||
{
|
||||
id: 'atmos-compute',
|
||||
name: 'HNS 대기확산 연산',
|
||||
status: '정상',
|
||||
lastReceived: '2026-04-11 06:10',
|
||||
cycle: '예측 시작 즉시',
|
||||
},
|
||||
{
|
||||
id: 'result-receive',
|
||||
name: '결과 수신',
|
||||
status: '지연',
|
||||
lastReceived: '2026-04-11 06:00',
|
||||
cycle: '연산 완료 즉시',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_LOGS: DataLogRow[] = [
|
||||
{
|
||||
id: 'log-01',
|
||||
timestamp: '2026-04-11 06:10',
|
||||
source: 'HYCOM',
|
||||
dataType: 'SST',
|
||||
size: '98 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-02',
|
||||
timestamp: '2026-04-11 06:10',
|
||||
source: 'HYCOM',
|
||||
dataType: '해류',
|
||||
size: '142 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-03',
|
||||
timestamp: '2026-04-11 06:05',
|
||||
source: '기상청',
|
||||
dataType: '풍향/풍속',
|
||||
size: '38 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-04',
|
||||
timestamp: '2026-04-11 06:05',
|
||||
source: '기상청',
|
||||
dataType: '기압',
|
||||
size: '22 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-05',
|
||||
timestamp: '2026-04-11 06:05',
|
||||
source: '기상청',
|
||||
dataType: '기온',
|
||||
size: '19 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-06',
|
||||
timestamp: '2026-04-11 06:05',
|
||||
source: '기상청',
|
||||
dataType: '대기안정도',
|
||||
size: '14 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-07',
|
||||
timestamp: '2026-04-11 06:07',
|
||||
source: '충북대 API',
|
||||
dataType: 'API 호출 요청',
|
||||
size: '0.2 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-08',
|
||||
timestamp: '2026-04-11 06:15',
|
||||
source: '충북대 API',
|
||||
dataType: 'HNS 확산 결과',
|
||||
size: '-',
|
||||
receiveStatus: '수신대기',
|
||||
processStatus: '대기',
|
||||
},
|
||||
{
|
||||
id: 'log-09',
|
||||
timestamp: '2026-04-11 06:00',
|
||||
source: 'HYCOM',
|
||||
dataType: 'SSH',
|
||||
size: '54 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-10',
|
||||
timestamp: '2026-04-11 03:05',
|
||||
source: '기상청',
|
||||
dataType: '풍향/풍속',
|
||||
size: '37 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-11',
|
||||
timestamp: '2026-04-11 03:07',
|
||||
source: '충북대 API',
|
||||
dataType: 'API 호출 요청',
|
||||
size: '0.2 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-12',
|
||||
timestamp: '2026-04-11 03:20',
|
||||
source: '충북대 API',
|
||||
dataType: 'HNS 확산 결과',
|
||||
size: '12 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-13',
|
||||
timestamp: '2026-04-11 03:20',
|
||||
source: '충북대 API',
|
||||
dataType: '피해범위 데이터',
|
||||
size: '4 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-14',
|
||||
timestamp: '2026-04-11 00:05',
|
||||
source: '기상청',
|
||||
dataType: '기압',
|
||||
size: '23 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-15',
|
||||
timestamp: '2026-04-11 00:00',
|
||||
source: 'HYCOM',
|
||||
dataType: 'SST',
|
||||
size: '97 MB',
|
||||
receiveStatus: '시간초과',
|
||||
processStatus: '오류',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_ALERTS: AlertItem[] = [
|
||||
{
|
||||
id: 'alert-01',
|
||||
level: '주의',
|
||||
message: '충북대 API 결과 수신 지연 — 최근 응답 15분 지연 (2026-04-11 06:15)',
|
||||
timestamp: '2026-04-11 06:30',
|
||||
},
|
||||
{
|
||||
id: 'alert-02',
|
||||
level: '정보',
|
||||
message: 'HYCOM 데이터 정상 수신 완료 (2026-04-11 06:00)',
|
||||
timestamp: '2026-04-11 06:00',
|
||||
},
|
||||
{
|
||||
id: 'alert-03',
|
||||
level: '정보',
|
||||
message: '금일 HNS 대기확산 예측 완료: 2회/4회',
|
||||
timestamp: '2026-04-11 06:12',
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Mock fetch ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface HnsAtmosData {
|
||||
pipeline: PipelineNode[];
|
||||
logs: DataLogRow[];
|
||||
alerts: AlertItem[];
|
||||
}
|
||||
|
||||
function fetchHnsAtmosData(): Promise<HnsAtmosData> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
pipeline: MOCK_PIPELINE,
|
||||
logs: MOCK_LOGS,
|
||||
alerts: MOCK_ALERTS,
|
||||
}),
|
||||
300,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 유틸 ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
function getPipelineStatusStyle(status: PipelineStatus): string {
|
||||
if (status === '정상') return 'text-emerald-400 bg-emerald-500/10';
|
||||
if (status === '지연') return 'text-yellow-400 bg-yellow-500/10';
|
||||
return 'text-red-400 bg-red-500/10';
|
||||
}
|
||||
|
||||
function getPipelineBorderStyle(status: PipelineStatus): string {
|
||||
if (status === '정상') return 'border-l-emerald-500';
|
||||
if (status === '지연') return 'border-l-yellow-500';
|
||||
return 'border-l-red-500';
|
||||
}
|
||||
|
||||
function getReceiveStatusStyle(status: ReceiveStatus): string {
|
||||
if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10';
|
||||
if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10';
|
||||
return 'text-red-400 bg-red-500/10';
|
||||
}
|
||||
|
||||
function getProcessStatusStyle(status: ProcessStatus): string {
|
||||
if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10';
|
||||
if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10';
|
||||
if (status === '대기') return 'text-yellow-400 bg-yellow-500/10';
|
||||
return 'text-red-400 bg-red-500/10';
|
||||
}
|
||||
|
||||
function getAlertStyle(level: AlertLevel): string {
|
||||
if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30';
|
||||
if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30';
|
||||
return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30';
|
||||
}
|
||||
|
||||
// ─── 파이프라인 카드 ─────────────────────────────────────────────────────────────
|
||||
|
||||
function PipelineCard({ node }: { node: PipelineNode }) {
|
||||
const badgeStyle = getPipelineStatusStyle(node.status);
|
||||
const borderStyle = getPipelineBorderStyle(node.status);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke-1 border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
|
||||
>
|
||||
<div className="text-xs font-medium text-t1 leading-snug">{node.name}</div>
|
||||
<span
|
||||
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
|
||||
>
|
||||
{node.status}
|
||||
</span>
|
||||
<div className="text-label-2 text-t3 mt-0.5">최근 수신: {node.lastReceived}</div>
|
||||
<div className="text-label-2 text-t3">{node.cycle}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: boolean }) {
|
||||
if (loading && nodes.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 animate-pulse">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-1">
|
||||
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
|
||||
{i < 4 && <span className="text-t3 text-sm px-0.5">→</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-stretch gap-1">
|
||||
{nodes.map((node, idx) => (
|
||||
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
|
||||
<PipelineCard node={node} />
|
||||
{idx < nodes.length - 1 && (
|
||||
<span className="text-t3 text-sm shrink-0 px-0.5">→</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 수신 이력 테이블 ────────────────────────────────────────────────────────────
|
||||
|
||||
type FilterSource = 'all' | DataSource;
|
||||
type FilterReceive = 'all' | ReceiveStatus;
|
||||
type FilterPeriod = '6h' | '12h' | '24h';
|
||||
|
||||
const PERIOD_HOURS: Record<FilterPeriod, number> = { '6h': 6, '12h': 12, '24h': 24 };
|
||||
|
||||
function filterLogs(
|
||||
rows: DataLogRow[],
|
||||
source: FilterSource,
|
||||
receive: FilterReceive,
|
||||
period: FilterPeriod,
|
||||
): DataLogRow[] {
|
||||
const cutoff = new Date('2026-04-11T06:30:00');
|
||||
const hours = PERIOD_HOURS[period];
|
||||
const from = new Date(cutoff.getTime() - hours * 60 * 60 * 1000);
|
||||
|
||||
return rows.filter((r) => {
|
||||
if (source !== 'all' && r.source !== source) return false;
|
||||
if (receive !== 'all' && r.receiveStatus !== receive) return false;
|
||||
const ts = new Date(r.timestamp.replace(' ', 'T'));
|
||||
if (ts < from) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '수신상태', '처리상태'];
|
||||
|
||||
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
|
||||
return (
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
||||
{LOG_HEADERS.map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && rows.length === 0
|
||||
? Array.from({ length: 8 }).map((_, i) => (
|
||||
<tr key={i} className="border-b border-stroke-1 animate-pulse">
|
||||
{LOG_HEADERS.map((_, j) => (
|
||||
<td key={j} className="px-3 py-2">
|
||||
<div className="h-3 bg-bg-elevated rounded w-16" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
: rows.map((row) => (
|
||||
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
|
||||
{row.timestamp}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span
|
||||
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getReceiveStatusStyle(row.receiveStatus)}`}
|
||||
>
|
||||
{row.receiveStatus}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span
|
||||
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getProcessStatusStyle(row.processStatus)}`}
|
||||
>
|
||||
{row.processStatus}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-3 py-8 text-center text-t3">
|
||||
조회된 데이터가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 알림 목록 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean }) {
|
||||
if (loading && alerts.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 animate-pulse">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-8 bg-bg-elevated rounded" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (alerts.length === 0) {
|
||||
return <p className="text-xs text-t3 py-2">활성 알림이 없습니다.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{alerts.map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className={`flex items-start gap-2 px-3 py-2 rounded border text-xs ${getAlertStyle(alert.level)}`}
|
||||
>
|
||||
<span className="font-semibold shrink-0">[{alert.level}]</span>
|
||||
<span className="flex-1">{alert.message}</span>
|
||||
<span className="shrink-0 opacity-70 font-mono">{alert.timestamp}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 메인 패널 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function RndHnsAtmosPanel() {
|
||||
const [pipeline, setPipeline] = useState<PipelineNode[]>([]);
|
||||
const [logs, setLogs] = useState<DataLogRow[]>([]);
|
||||
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
||||
|
||||
// 필터
|
||||
const [filterSource, setFilterSource] = useState<FilterSource>('all');
|
||||
const [filterReceive, setFilterReceive] = useState<FilterReceive>('all');
|
||||
const [filterPeriod, setFilterPeriod] = useState<FilterPeriod>('24h');
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchHnsAtmosData();
|
||||
setPipeline(data.pipeline);
|
||||
setLogs(data.logs);
|
||||
setAlerts(data.alerts);
|
||||
setLastUpdate(new Date());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
void Promise.resolve().then(() => {
|
||||
if (isMounted) void fetchData();
|
||||
});
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [fetchData]);
|
||||
|
||||
const filteredLogs = filterLogs(logs, filterSource, filterReceive, filterPeriod);
|
||||
|
||||
const totalReceived = logs.filter((r) => r.receiveStatus === '수신완료').length;
|
||||
const totalDelayed = logs.filter((r) => r.receiveStatus === '수신대기').length;
|
||||
const totalFailed = logs.filter(
|
||||
(r) => r.receiveStatus === '수신실패' || r.receiveStatus === '시간초과',
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* ── 헤더 ── */}
|
||||
<div className="shrink-0 border-b border-stroke-1">
|
||||
<div className="flex items-center justify-between px-5 py-3">
|
||||
<h2 className="text-sm font-semibold text-t1">HNS 대기확산 (충북대) 연계 모니터링</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
{lastUpdate && (
|
||||
<span className="text-xs text-t3">
|
||||
갱신:{' '}
|
||||
{lastUpdate.toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => void fetchData()}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 요약 통계 바 */}
|
||||
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
|
||||
<span>
|
||||
정상 수신:{' '}
|
||||
<span className="text-emerald-400 font-medium">{totalReceived}건</span>
|
||||
</span>
|
||||
<span className="text-stroke-1">|</span>
|
||||
<span>
|
||||
지연: <span className="text-yellow-400 font-medium">{totalDelayed}건</span>
|
||||
</span>
|
||||
<span className="text-stroke-1">|</span>
|
||||
<span>
|
||||
실패: <span className="text-red-400 font-medium">{totalFailed}건</span>
|
||||
</span>
|
||||
<span className="text-stroke-1">|</span>
|
||||
<span>
|
||||
금일 예측 완료:{' '}
|
||||
<span className="text-cyan-400 font-medium">2 / 4회</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 스크롤 영역 ── */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{/* 파이프라인 현황 */}
|
||||
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
|
||||
<h3 className="text-xs font-semibold text-t2 mb-3 uppercase tracking-wide">
|
||||
데이터 파이프라인 현황
|
||||
</h3>
|
||||
<PipelineFlow nodes={pipeline} loading={loading} />
|
||||
</section>
|
||||
|
||||
{/* 필터 바 + 수신 이력 테이블 */}
|
||||
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
|
||||
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
|
||||
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide shrink-0">
|
||||
데이터 수신 이력
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* 데이터소스 필터 */}
|
||||
<select
|
||||
value={filterSource}
|
||||
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
|
||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
||||
>
|
||||
<option value="all">모두 (소스)</option>
|
||||
<option value="HYCOM">HYCOM</option>
|
||||
<option value="기상청">기상청</option>
|
||||
<option value="충북대 API">충북대 API</option>
|
||||
</select>
|
||||
{/* 수신상태 필터 */}
|
||||
<select
|
||||
value={filterReceive}
|
||||
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
|
||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
||||
>
|
||||
<option value="all">모두 (상태)</option>
|
||||
<option value="수신완료">수신완료</option>
|
||||
<option value="수신대기">수신대기</option>
|
||||
<option value="수신실패">수신실패</option>
|
||||
<option value="시간초과">시간초과</option>
|
||||
</select>
|
||||
{/* 기간 필터 */}
|
||||
<select
|
||||
value={filterPeriod}
|
||||
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
|
||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
||||
>
|
||||
<option value="6h">최근 6시간</option>
|
||||
<option value="12h">최근 12시간</option>
|
||||
<option value="24h">최근 24시간</option>
|
||||
</select>
|
||||
<span className="text-xs text-t3">{filteredLogs.length}건</span>
|
||||
</div>
|
||||
</div>
|
||||
<DataLogTable rows={filteredLogs} loading={loading} />
|
||||
</section>
|
||||
|
||||
{/* 알림 현황 */}
|
||||
<section className="px-5 pt-4 pb-5">
|
||||
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3">
|
||||
알림 현황
|
||||
</h3>
|
||||
<AlertList alerts={alerts} loading={loading} />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
638
frontend/src/tabs/admin/components/RndKospsPanel.tsx
Normal file
638
frontend/src/tabs/admin/components/RndKospsPanel.tsx
Normal file
@ -0,0 +1,638 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
// ─── 타입 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
type PipelineStatus = '정상' | '지연' | '중단';
|
||||
type ReceiveStatus = '수신완료' | '수신대기' | '수신실패' | '시간초과';
|
||||
type ProcessStatus = '처리완료' | '처리중' | '대기' | '오류';
|
||||
type DataSource = 'HYCOM' | '기상청' | 'KOSPS DLL';
|
||||
type AlertLevel = '경고' | '주의' | '정보';
|
||||
|
||||
interface PipelineNode {
|
||||
id: string;
|
||||
name: string;
|
||||
status: PipelineStatus;
|
||||
lastReceived: string;
|
||||
cycle: string;
|
||||
}
|
||||
|
||||
interface DataLogRow {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
source: DataSource;
|
||||
dataType: string;
|
||||
size: string;
|
||||
receiveStatus: ReceiveStatus;
|
||||
processStatus: ProcessStatus;
|
||||
}
|
||||
|
||||
interface AlertItem {
|
||||
id: string;
|
||||
level: AlertLevel;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// ─── Mock 데이터 ────────────────────────────────────────────────────────────────
|
||||
|
||||
const MOCK_PIPELINE: PipelineNode[] = [
|
||||
{
|
||||
id: 'hycom',
|
||||
name: 'HYCOM 해양순환모델',
|
||||
status: '정상',
|
||||
lastReceived: '2026-04-11 06:00',
|
||||
cycle: '6시간 주기',
|
||||
},
|
||||
{
|
||||
id: 'kma',
|
||||
name: '기상청 수치모델',
|
||||
status: '정상',
|
||||
lastReceived: '2026-04-11 06:00',
|
||||
cycle: '3시간 주기',
|
||||
},
|
||||
{
|
||||
id: 'kosps-server',
|
||||
name: '광주 KOSPS 서버',
|
||||
status: '정상',
|
||||
lastReceived: '2026-04-11 06:05',
|
||||
cycle: '수신 즉시',
|
||||
},
|
||||
{
|
||||
id: 'fortran-dll',
|
||||
name: 'KOSPS Fortran DLL 연산',
|
||||
status: '지연',
|
||||
lastReceived: '2026-04-11 05:45',
|
||||
cycle: '예측 시작 즉시',
|
||||
},
|
||||
{
|
||||
id: 'result-api',
|
||||
name: '결과 수신 API',
|
||||
status: '정상',
|
||||
lastReceived: '2026-04-11 06:10',
|
||||
cycle: '예측 완료 즉시',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_LOGS: DataLogRow[] = [
|
||||
{
|
||||
id: 'log-01',
|
||||
timestamp: '2026-04-11 06:10',
|
||||
source: 'KOSPS DLL',
|
||||
dataType: 'DLL 응답 결과',
|
||||
size: '28 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-02',
|
||||
timestamp: '2026-04-11 06:05',
|
||||
source: '기상청',
|
||||
dataType: '풍향/풍속',
|
||||
size: '38 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-03',
|
||||
timestamp: '2026-04-11 06:05',
|
||||
source: '기상청',
|
||||
dataType: '기압',
|
||||
size: '22 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-04',
|
||||
timestamp: '2026-04-11 06:00',
|
||||
source: 'HYCOM',
|
||||
dataType: '해수면온도(SST)',
|
||||
size: '98 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-05',
|
||||
timestamp: '2026-04-11 06:00',
|
||||
source: 'HYCOM',
|
||||
dataType: '해류(U/V)',
|
||||
size: '142 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-06',
|
||||
timestamp: '2026-04-11 06:00',
|
||||
source: '기상청',
|
||||
dataType: '기온',
|
||||
size: '19 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-07',
|
||||
timestamp: '2026-04-11 05:55',
|
||||
source: 'KOSPS DLL',
|
||||
dataType: 'DLL 호출 요청',
|
||||
size: '3 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리중',
|
||||
},
|
||||
{
|
||||
id: 'log-08',
|
||||
timestamp: '2026-04-11 05:45',
|
||||
source: 'KOSPS DLL',
|
||||
dataType: 'DLL 응답 결과',
|
||||
size: '-',
|
||||
receiveStatus: '수신대기',
|
||||
processStatus: '대기',
|
||||
},
|
||||
{
|
||||
id: 'log-09',
|
||||
timestamp: '2026-04-11 03:10',
|
||||
source: 'HYCOM',
|
||||
dataType: '해류(U/V)',
|
||||
size: '140 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-10',
|
||||
timestamp: '2026-04-11 03:05',
|
||||
source: '기상청',
|
||||
dataType: '풍향/풍속',
|
||||
size: '37 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-11',
|
||||
timestamp: '2026-04-11 03:00',
|
||||
source: 'HYCOM',
|
||||
dataType: '해수면높이(SSH)',
|
||||
size: '54 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-12',
|
||||
timestamp: '2026-04-11 03:00',
|
||||
source: 'KOSPS DLL',
|
||||
dataType: 'DLL 응답 결과',
|
||||
size: '27 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-13',
|
||||
timestamp: '2026-04-11 00:05',
|
||||
source: '기상청',
|
||||
dataType: '기압',
|
||||
size: '23 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-14',
|
||||
timestamp: '2026-04-11 00:00',
|
||||
source: 'HYCOM',
|
||||
dataType: '해수면높이(SSH)',
|
||||
size: '53 MB',
|
||||
receiveStatus: '시간초과',
|
||||
processStatus: '오류',
|
||||
},
|
||||
{
|
||||
id: 'log-15',
|
||||
timestamp: '2026-04-11 00:00',
|
||||
source: 'KOSPS DLL',
|
||||
dataType: 'DLL 호출 요청',
|
||||
size: '-',
|
||||
receiveStatus: '수신실패',
|
||||
processStatus: '오류',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_ALERTS: AlertItem[] = [
|
||||
{
|
||||
id: 'alert-01',
|
||||
level: '경고',
|
||||
message: 'KOSPS Fortran DLL 응답 지연 — 평균 응답시간 초과',
|
||||
timestamp: '2026-04-11 05:45',
|
||||
},
|
||||
{
|
||||
id: 'alert-02',
|
||||
level: '주의',
|
||||
message: 'HYCOM SSH 데이터 다음 수신 예정: 09:00',
|
||||
timestamp: '2026-04-11 06:00',
|
||||
},
|
||||
{
|
||||
id: 'alert-03',
|
||||
level: '정보',
|
||||
message: '금일 KOSPS 예측 완료: 3회 / 6회',
|
||||
timestamp: '2026-04-11 06:12',
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Mock fetch ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface KospsData {
|
||||
pipeline: PipelineNode[];
|
||||
logs: DataLogRow[];
|
||||
alerts: AlertItem[];
|
||||
}
|
||||
|
||||
function fetchKospsData(): Promise<KospsData> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
pipeline: MOCK_PIPELINE,
|
||||
logs: MOCK_LOGS,
|
||||
alerts: MOCK_ALERTS,
|
||||
}),
|
||||
300,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 유틸 ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
function getPipelineStatusStyle(status: PipelineStatus): string {
|
||||
if (status === '정상') return 'text-emerald-400 bg-emerald-500/10';
|
||||
if (status === '지연') return 'text-yellow-400 bg-yellow-500/10';
|
||||
return 'text-red-400 bg-red-500/10';
|
||||
}
|
||||
|
||||
function getPipelineBorderStyle(status: PipelineStatus): string {
|
||||
if (status === '정상') return 'border-l-emerald-500';
|
||||
if (status === '지연') return 'border-l-yellow-500';
|
||||
return 'border-l-red-500';
|
||||
}
|
||||
|
||||
function getReceiveStatusStyle(status: ReceiveStatus): string {
|
||||
if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10';
|
||||
if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10';
|
||||
return 'text-red-400 bg-red-500/10';
|
||||
}
|
||||
|
||||
function getProcessStatusStyle(status: ProcessStatus): string {
|
||||
if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10';
|
||||
if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10';
|
||||
if (status === '대기') return 'text-yellow-400 bg-yellow-500/10';
|
||||
return 'text-red-400 bg-red-500/10';
|
||||
}
|
||||
|
||||
function getAlertStyle(level: AlertLevel): string {
|
||||
if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30';
|
||||
if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30';
|
||||
return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30';
|
||||
}
|
||||
|
||||
// ─── 파이프라인 카드 ─────────────────────────────────────────────────────────────
|
||||
|
||||
function PipelineCard({ node }: { node: PipelineNode }) {
|
||||
const badgeStyle = getPipelineStatusStyle(node.status);
|
||||
const borderStyle = getPipelineBorderStyle(node.status);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke-1 border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
|
||||
>
|
||||
<div className="text-xs font-medium text-t1 leading-snug">{node.name}</div>
|
||||
<span
|
||||
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
|
||||
>
|
||||
{node.status}
|
||||
</span>
|
||||
<div className="text-label-2 text-t3 mt-0.5">최근 수신: {node.lastReceived}</div>
|
||||
<div className="text-label-2 text-t3">{node.cycle}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: boolean }) {
|
||||
if (loading && nodes.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 animate-pulse">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-1">
|
||||
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
|
||||
{i < 4 && <span className="text-t3 text-sm px-0.5">→</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-stretch gap-1">
|
||||
{nodes.map((node, idx) => (
|
||||
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
|
||||
<PipelineCard node={node} />
|
||||
{idx < nodes.length - 1 && (
|
||||
<span className="text-t3 text-sm shrink-0 px-0.5">→</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 수신 이력 테이블 ────────────────────────────────────────────────────────────
|
||||
|
||||
type FilterSource = 'all' | DataSource;
|
||||
type FilterReceive = 'all' | ReceiveStatus;
|
||||
type FilterPeriod = '6h' | '12h' | '24h';
|
||||
|
||||
const PERIOD_HOURS: Record<FilterPeriod, number> = { '6h': 6, '12h': 12, '24h': 24 };
|
||||
|
||||
function filterLogs(
|
||||
rows: DataLogRow[],
|
||||
source: FilterSource,
|
||||
receive: FilterReceive,
|
||||
period: FilterPeriod,
|
||||
): DataLogRow[] {
|
||||
const cutoff = new Date('2026-04-11T06:30:00');
|
||||
const hours = PERIOD_HOURS[period];
|
||||
const from = new Date(cutoff.getTime() - hours * 60 * 60 * 1000);
|
||||
|
||||
return rows.filter((r) => {
|
||||
if (source !== 'all' && r.source !== source) return false;
|
||||
if (receive !== 'all' && r.receiveStatus !== receive) return false;
|
||||
const ts = new Date(r.timestamp.replace(' ', 'T'));
|
||||
if (ts < from) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '수신상태', '처리상태'];
|
||||
|
||||
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
|
||||
return (
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
||||
{LOG_HEADERS.map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && rows.length === 0
|
||||
? Array.from({ length: 8 }).map((_, i) => (
|
||||
<tr key={i} className="border-b border-stroke-1 animate-pulse">
|
||||
{LOG_HEADERS.map((_, j) => (
|
||||
<td key={j} className="px-3 py-2">
|
||||
<div className="h-3 bg-bg-elevated rounded w-16" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
: rows.map((row) => (
|
||||
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
|
||||
{row.timestamp}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span
|
||||
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getReceiveStatusStyle(row.receiveStatus)}`}
|
||||
>
|
||||
{row.receiveStatus}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span
|
||||
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getProcessStatusStyle(row.processStatus)}`}
|
||||
>
|
||||
{row.processStatus}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-3 py-8 text-center text-t3">
|
||||
조회된 데이터가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 알림 목록 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean }) {
|
||||
if (loading && alerts.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 animate-pulse">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-8 bg-bg-elevated rounded" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (alerts.length === 0) {
|
||||
return <p className="text-xs text-t3 py-2">활성 알림이 없습니다.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{alerts.map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className={`flex items-start gap-2 px-3 py-2 rounded border text-xs ${getAlertStyle(alert.level)}`}
|
||||
>
|
||||
<span className="font-semibold shrink-0">[{alert.level}]</span>
|
||||
<span className="flex-1">{alert.message}</span>
|
||||
<span className="shrink-0 opacity-70 font-mono">{alert.timestamp}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 메인 패널 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function RndKospsPanel() {
|
||||
const [pipeline, setPipeline] = useState<PipelineNode[]>([]);
|
||||
const [logs, setLogs] = useState<DataLogRow[]>([]);
|
||||
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
||||
|
||||
// 필터
|
||||
const [filterSource, setFilterSource] = useState<FilterSource>('all');
|
||||
const [filterReceive, setFilterReceive] = useState<FilterReceive>('all');
|
||||
const [filterPeriod, setFilterPeriod] = useState<FilterPeriod>('24h');
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchKospsData();
|
||||
setPipeline(data.pipeline);
|
||||
setLogs(data.logs);
|
||||
setAlerts(data.alerts);
|
||||
setLastUpdate(new Date());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
void Promise.resolve().then(() => {
|
||||
if (isMounted) void fetchData();
|
||||
});
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [fetchData]);
|
||||
|
||||
const filteredLogs = filterLogs(logs, filterSource, filterReceive, filterPeriod);
|
||||
|
||||
const totalReceived = logs.filter((r) => r.receiveStatus === '수신완료').length;
|
||||
const totalDelayed = logs.filter((r) => r.receiveStatus === '수신대기').length;
|
||||
const totalFailed = logs.filter(
|
||||
(r) => r.receiveStatus === '수신실패' || r.receiveStatus === '시간초과',
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* ── 헤더 ── */}
|
||||
<div className="shrink-0 border-b border-stroke-1">
|
||||
<div className="flex items-center justify-between px-5 py-3">
|
||||
<h2 className="text-sm font-semibold text-t1">유출유확산예측 (KOSPS) 연계 모니터링</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
{lastUpdate && (
|
||||
<span className="text-xs text-t3">
|
||||
갱신:{' '}
|
||||
{lastUpdate.toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => void fetchData()}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 요약 통계 바 */}
|
||||
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
|
||||
<span>
|
||||
정상 수신:{' '}
|
||||
<span className="text-emerald-400 font-medium">{totalReceived}건</span>
|
||||
</span>
|
||||
<span className="text-stroke-1">|</span>
|
||||
<span>
|
||||
지연: <span className="text-yellow-400 font-medium">{totalDelayed}건</span>
|
||||
</span>
|
||||
<span className="text-stroke-1">|</span>
|
||||
<span>
|
||||
실패: <span className="text-red-400 font-medium">{totalFailed}건</span>
|
||||
</span>
|
||||
<span className="text-stroke-1">|</span>
|
||||
<span>
|
||||
금일 예측 완료:{' '}
|
||||
<span className="text-cyan-400 font-medium">3 / 6회</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 스크롤 영역 ── */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{/* 파이프라인 현황 */}
|
||||
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
|
||||
<h3 className="text-xs font-semibold text-t2 mb-3 uppercase tracking-wide">
|
||||
데이터 파이프라인 현황
|
||||
</h3>
|
||||
<PipelineFlow nodes={pipeline} loading={loading} />
|
||||
</section>
|
||||
|
||||
{/* 필터 바 + 수신 이력 테이블 */}
|
||||
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
|
||||
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
|
||||
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide shrink-0">
|
||||
데이터 수신 이력
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* 데이터소스 필터 */}
|
||||
<select
|
||||
value={filterSource}
|
||||
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
|
||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
||||
>
|
||||
<option value="all">모두 (소스)</option>
|
||||
<option value="HYCOM">HYCOM</option>
|
||||
<option value="기상청">기상청</option>
|
||||
<option value="KOSPS DLL">KOSPS DLL</option>
|
||||
</select>
|
||||
{/* 수신상태 필터 */}
|
||||
<select
|
||||
value={filterReceive}
|
||||
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
|
||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
||||
>
|
||||
<option value="all">모두 (상태)</option>
|
||||
<option value="수신완료">수신완료</option>
|
||||
<option value="수신대기">수신대기</option>
|
||||
<option value="수신실패">수신실패</option>
|
||||
<option value="시간초과">시간초과</option>
|
||||
</select>
|
||||
{/* 기간 필터 */}
|
||||
<select
|
||||
value={filterPeriod}
|
||||
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
|
||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
||||
>
|
||||
<option value="6h">최근 6시간</option>
|
||||
<option value="12h">최근 12시간</option>
|
||||
<option value="24h">최근 24시간</option>
|
||||
</select>
|
||||
<span className="text-xs text-t3">{filteredLogs.length}건</span>
|
||||
</div>
|
||||
</div>
|
||||
<DataLogTable rows={filteredLogs} loading={loading} />
|
||||
</section>
|
||||
|
||||
{/* 알림 현황 */}
|
||||
<section className="px-5 pt-4 pb-5">
|
||||
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3">
|
||||
알림 현황
|
||||
</h3>
|
||||
<AlertList alerts={alerts} loading={loading} />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
665
frontend/src/tabs/admin/components/RndPoseidonPanel.tsx
Normal file
665
frontend/src/tabs/admin/components/RndPoseidonPanel.tsx
Normal file
@ -0,0 +1,665 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
// ─── 타입 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
type PipelineStatus = '정상' | '지연' | '중단';
|
||||
type ReceiveStatus = '수신완료' | '수신대기' | '수신실패' | '시간초과';
|
||||
type ProcessStatus = '처리완료' | '처리중' | '대기' | '오류';
|
||||
type DataSource = 'HYCOM' | '기상수치모델' | '기상기술';
|
||||
type AlertLevel = '경고' | '주의' | '정보';
|
||||
|
||||
interface PipelineNode {
|
||||
id: string;
|
||||
name: string;
|
||||
status: PipelineStatus;
|
||||
lastReceived: string;
|
||||
cycle: string;
|
||||
}
|
||||
|
||||
interface DataLogRow {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
source: DataSource;
|
||||
dataType: string;
|
||||
size: string;
|
||||
receiveStatus: ReceiveStatus;
|
||||
processStatus: ProcessStatus;
|
||||
}
|
||||
|
||||
interface AlertItem {
|
||||
id: string;
|
||||
level: AlertLevel;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// ─── Mock 데이터 ────────────────────────────────────────────────────────────────
|
||||
|
||||
const MOCK_PIPELINE: PipelineNode[] = [
|
||||
{
|
||||
id: 'hycom',
|
||||
name: 'HYCOM 해양순환모델',
|
||||
status: '정상',
|
||||
lastReceived: '2026-04-11 06:00',
|
||||
cycle: '6시간 주기',
|
||||
},
|
||||
{
|
||||
id: 'kma',
|
||||
name: '기상수치모델(KMA)',
|
||||
status: '정상',
|
||||
lastReceived: '2026-04-11 06:00',
|
||||
cycle: '3시간 주기',
|
||||
},
|
||||
{
|
||||
id: 'relay',
|
||||
name: '기상기술 중계서버',
|
||||
status: '지연',
|
||||
lastReceived: '2026-04-11 05:30',
|
||||
cycle: '3시간 주기',
|
||||
},
|
||||
{
|
||||
id: 'api',
|
||||
name: '해경 9층 API서버',
|
||||
status: '정상',
|
||||
lastReceived: '2026-04-11 06:05',
|
||||
cycle: '수신 즉시',
|
||||
},
|
||||
{
|
||||
id: 'gpu',
|
||||
name: 'GPU 연산서버',
|
||||
status: '정상',
|
||||
lastReceived: '2026-04-11 06:10',
|
||||
cycle: '예측 시작 즉시',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_LOGS: DataLogRow[] = [
|
||||
{
|
||||
id: 'log-01',
|
||||
timestamp: '2026-04-11 06:10',
|
||||
source: 'HYCOM',
|
||||
dataType: '해류(U/V)',
|
||||
size: '142 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-02',
|
||||
timestamp: '2026-04-11 06:05',
|
||||
source: '기상수치모델',
|
||||
dataType: '풍향/풍속',
|
||||
size: '38 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-03',
|
||||
timestamp: '2026-04-11 06:05',
|
||||
source: '기상수치모델',
|
||||
dataType: '기압',
|
||||
size: '22 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-04',
|
||||
timestamp: '2026-04-11 06:00',
|
||||
source: 'HYCOM',
|
||||
dataType: '해수면온도(SST)',
|
||||
size: '98 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-05',
|
||||
timestamp: '2026-04-11 06:00',
|
||||
source: 'HYCOM',
|
||||
dataType: '해수면높이(SSH)',
|
||||
size: '54 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-06',
|
||||
timestamp: '2026-04-11 06:00',
|
||||
source: '기상수치모델',
|
||||
dataType: '기온',
|
||||
size: '19 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-07',
|
||||
timestamp: '2026-04-11 06:00',
|
||||
source: '기상수치모델',
|
||||
dataType: '강수량',
|
||||
size: '11 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-08',
|
||||
timestamp: '2026-04-11 06:00',
|
||||
source: '기상기술',
|
||||
dataType: '전처리 완료 데이터',
|
||||
size: '310 MB',
|
||||
receiveStatus: '수신대기',
|
||||
processStatus: '대기',
|
||||
},
|
||||
{
|
||||
id: 'log-09',
|
||||
timestamp: '2026-04-11 03:10',
|
||||
source: 'HYCOM',
|
||||
dataType: '해류(U/V)',
|
||||
size: '140 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-10',
|
||||
timestamp: '2026-04-11 03:05',
|
||||
source: '기상수치모델',
|
||||
dataType: '풍향/풍속',
|
||||
size: '37 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-11',
|
||||
timestamp: '2026-04-11 03:00',
|
||||
source: 'HYCOM',
|
||||
dataType: '해수면온도(SST)',
|
||||
size: '97 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-12',
|
||||
timestamp: '2026-04-11 03:00',
|
||||
source: '기상기술',
|
||||
dataType: '전처리 완료 데이터',
|
||||
size: '305 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-13',
|
||||
timestamp: '2026-04-11 00:05',
|
||||
source: '기상수치모델',
|
||||
dataType: '기압',
|
||||
size: '23 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-14',
|
||||
timestamp: '2026-04-11 00:00',
|
||||
source: 'HYCOM',
|
||||
dataType: '해수면높이(SSH)',
|
||||
size: '53 MB',
|
||||
receiveStatus: '시간초과',
|
||||
processStatus: '오류',
|
||||
},
|
||||
{
|
||||
id: 'log-15',
|
||||
timestamp: '2026-04-11 00:00',
|
||||
source: '기상기술',
|
||||
dataType: '전처리 완료 데이터',
|
||||
size: '-',
|
||||
receiveStatus: '수신실패',
|
||||
processStatus: '오류',
|
||||
},
|
||||
{
|
||||
id: 'log-16',
|
||||
timestamp: '2026-04-10 21:05',
|
||||
source: '기상수치모델',
|
||||
dataType: '풍향/풍속',
|
||||
size: '36 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-17',
|
||||
timestamp: '2026-04-10 21:00',
|
||||
source: 'HYCOM',
|
||||
dataType: '해류(U/V)',
|
||||
size: '139 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-18',
|
||||
timestamp: '2026-04-10 21:00',
|
||||
source: '기상기술',
|
||||
dataType: '전처리 완료 데이터',
|
||||
size: '302 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_ALERTS: AlertItem[] = [
|
||||
{
|
||||
id: 'alert-01',
|
||||
level: '경고',
|
||||
message: '기상기술 중계서버 데이터 30분 지연 — 06:00 배치 전처리 미완료',
|
||||
timestamp: '2026-04-11 06:30',
|
||||
},
|
||||
{
|
||||
id: 'alert-02',
|
||||
level: '주의',
|
||||
message: 'HYCOM SSH 데이터 다음 수신 예정: 09:00',
|
||||
timestamp: '2026-04-11 06:00',
|
||||
},
|
||||
{
|
||||
id: 'alert-03',
|
||||
level: '정보',
|
||||
message: 'GPU 연산서버 금일 처리 완료: 4회 / 8회',
|
||||
timestamp: '2026-04-11 06:12',
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Mock fetch ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface PoseidonData {
|
||||
pipeline: PipelineNode[];
|
||||
logs: DataLogRow[];
|
||||
alerts: AlertItem[];
|
||||
}
|
||||
|
||||
function fetchPoseidonData(): Promise<PoseidonData> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
pipeline: MOCK_PIPELINE,
|
||||
logs: MOCK_LOGS,
|
||||
alerts: MOCK_ALERTS,
|
||||
}),
|
||||
300,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 유틸 ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
function getPipelineStatusStyle(status: PipelineStatus): string {
|
||||
if (status === '정상') return 'text-emerald-400 bg-emerald-500/10';
|
||||
if (status === '지연') return 'text-yellow-400 bg-yellow-500/10';
|
||||
return 'text-red-400 bg-red-500/10';
|
||||
}
|
||||
|
||||
function getPipelineBorderStyle(status: PipelineStatus): string {
|
||||
if (status === '정상') return 'border-l-emerald-500';
|
||||
if (status === '지연') return 'border-l-yellow-500';
|
||||
return 'border-l-red-500';
|
||||
}
|
||||
|
||||
function getReceiveStatusStyle(status: ReceiveStatus): string {
|
||||
if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10';
|
||||
if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10';
|
||||
return 'text-red-400 bg-red-500/10';
|
||||
}
|
||||
|
||||
function getProcessStatusStyle(status: ProcessStatus): string {
|
||||
if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10';
|
||||
if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10';
|
||||
if (status === '대기') return 'text-yellow-400 bg-yellow-500/10';
|
||||
return 'text-red-400 bg-red-500/10';
|
||||
}
|
||||
|
||||
function getAlertStyle(level: AlertLevel): string {
|
||||
if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30';
|
||||
if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30';
|
||||
return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30';
|
||||
}
|
||||
|
||||
// ─── 파이프라인 카드 ─────────────────────────────────────────────────────────────
|
||||
|
||||
function PipelineCard({ node }: { node: PipelineNode }) {
|
||||
const badgeStyle = getPipelineStatusStyle(node.status);
|
||||
const borderStyle = getPipelineBorderStyle(node.status);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke-1 border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
|
||||
>
|
||||
<div className="text-xs font-medium text-t1 leading-snug">{node.name}</div>
|
||||
<span
|
||||
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
|
||||
>
|
||||
{node.status}
|
||||
</span>
|
||||
<div className="text-label-2 text-t3 mt-0.5">최근 수신: {node.lastReceived}</div>
|
||||
<div className="text-label-2 text-t3">{node.cycle}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: boolean }) {
|
||||
if (loading && nodes.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 animate-pulse">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-1">
|
||||
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
|
||||
{i < 4 && <span className="text-t3 text-sm px-0.5">→</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-stretch gap-1">
|
||||
{nodes.map((node, idx) => (
|
||||
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
|
||||
<PipelineCard node={node} />
|
||||
{idx < nodes.length - 1 && (
|
||||
<span className="text-t3 text-sm shrink-0 px-0.5">→</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 수신 이력 테이블 ────────────────────────────────────────────────────────────
|
||||
|
||||
type FilterSource = 'all' | DataSource;
|
||||
type FilterReceive = 'all' | ReceiveStatus;
|
||||
type FilterPeriod = '6h' | '12h' | '24h';
|
||||
|
||||
const PERIOD_HOURS: Record<FilterPeriod, number> = { '6h': 6, '12h': 12, '24h': 24 };
|
||||
|
||||
function filterLogs(
|
||||
rows: DataLogRow[],
|
||||
source: FilterSource,
|
||||
receive: FilterReceive,
|
||||
period: FilterPeriod,
|
||||
): DataLogRow[] {
|
||||
const cutoff = new Date('2026-04-11T06:30:00');
|
||||
const hours = PERIOD_HOURS[period];
|
||||
const from = new Date(cutoff.getTime() - hours * 60 * 60 * 1000);
|
||||
|
||||
return rows.filter((r) => {
|
||||
if (source !== 'all' && r.source !== source) return false;
|
||||
if (receive !== 'all' && r.receiveStatus !== receive) return false;
|
||||
const ts = new Date(r.timestamp.replace(' ', 'T'));
|
||||
if (ts < from) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '수신상태', '처리상태'];
|
||||
|
||||
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
|
||||
return (
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
||||
{LOG_HEADERS.map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && rows.length === 0
|
||||
? Array.from({ length: 8 }).map((_, i) => (
|
||||
<tr key={i} className="border-b border-stroke-1 animate-pulse">
|
||||
{LOG_HEADERS.map((_, j) => (
|
||||
<td key={j} className="px-3 py-2">
|
||||
<div className="h-3 bg-bg-elevated rounded w-16" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
: rows.map((row) => (
|
||||
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
|
||||
{row.timestamp}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span
|
||||
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getReceiveStatusStyle(row.receiveStatus)}`}
|
||||
>
|
||||
{row.receiveStatus}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span
|
||||
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getProcessStatusStyle(row.processStatus)}`}
|
||||
>
|
||||
{row.processStatus}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-3 py-8 text-center text-t3">
|
||||
조회된 데이터가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 알림 목록 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean }) {
|
||||
if (loading && alerts.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 animate-pulse">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-8 bg-bg-elevated rounded" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (alerts.length === 0) {
|
||||
return <p className="text-xs text-t3 py-2">활성 알림이 없습니다.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{alerts.map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className={`flex items-start gap-2 px-3 py-2 rounded border text-xs ${getAlertStyle(alert.level)}`}
|
||||
>
|
||||
<span className="font-semibold shrink-0">[{alert.level}]</span>
|
||||
<span className="flex-1">{alert.message}</span>
|
||||
<span className="shrink-0 opacity-70 font-mono">{alert.timestamp}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 메인 패널 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function RndPoseidonPanel() {
|
||||
const [pipeline, setPipeline] = useState<PipelineNode[]>([]);
|
||||
const [logs, setLogs] = useState<DataLogRow[]>([]);
|
||||
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
||||
|
||||
// 필터
|
||||
const [filterSource, setFilterSource] = useState<FilterSource>('all');
|
||||
const [filterReceive, setFilterReceive] = useState<FilterReceive>('all');
|
||||
const [filterPeriod, setFilterPeriod] = useState<FilterPeriod>('24h');
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchPoseidonData();
|
||||
setPipeline(data.pipeline);
|
||||
setLogs(data.logs);
|
||||
setAlerts(data.alerts);
|
||||
setLastUpdate(new Date());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
void Promise.resolve().then(() => {
|
||||
if (isMounted) void fetchData();
|
||||
});
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [fetchData]);
|
||||
|
||||
const filteredLogs = filterLogs(logs, filterSource, filterReceive, filterPeriod);
|
||||
|
||||
const totalReceived = logs.filter((r) => r.receiveStatus === '수신완료').length;
|
||||
const totalDelayed = logs.filter((r) => r.receiveStatus === '수신대기').length;
|
||||
const totalFailed = logs.filter(
|
||||
(r) => r.receiveStatus === '수신실패' || r.receiveStatus === '시간초과',
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* ── 헤더 ── */}
|
||||
<div className="shrink-0 border-b border-stroke-1">
|
||||
<div className="flex items-center justify-between px-5 py-3">
|
||||
<h2 className="text-sm font-semibold text-t1">유출유확산예측 (포세이돈) 연계 모니터링</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
{lastUpdate && (
|
||||
<span className="text-xs text-t3">
|
||||
갱신:{' '}
|
||||
{lastUpdate.toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => void fetchData()}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 요약 통계 바 */}
|
||||
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
|
||||
<span>
|
||||
정상 수신:{' '}
|
||||
<span className="text-emerald-400 font-medium">{totalReceived}건</span>
|
||||
</span>
|
||||
<span className="text-stroke-1">|</span>
|
||||
<span>
|
||||
지연: <span className="text-yellow-400 font-medium">{totalDelayed}건</span>
|
||||
</span>
|
||||
<span className="text-stroke-1">|</span>
|
||||
<span>
|
||||
실패: <span className="text-red-400 font-medium">{totalFailed}건</span>
|
||||
</span>
|
||||
<span className="text-stroke-1">|</span>
|
||||
<span>
|
||||
금일 예측 완료:{' '}
|
||||
<span className="text-cyan-400 font-medium">4 / 8회</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 스크롤 영역 ── */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{/* 파이프라인 현황 */}
|
||||
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
|
||||
<h3 className="text-xs font-semibold text-t2 mb-3 uppercase tracking-wide">
|
||||
데이터 파이프라인 현황
|
||||
</h3>
|
||||
<PipelineFlow nodes={pipeline} loading={loading} />
|
||||
</section>
|
||||
|
||||
{/* 필터 바 + 수신 이력 테이블 */}
|
||||
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
|
||||
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
|
||||
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide shrink-0">
|
||||
데이터 수신 이력
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* 데이터소스 필터 */}
|
||||
<select
|
||||
value={filterSource}
|
||||
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
|
||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
||||
>
|
||||
<option value="all">모두 (소스)</option>
|
||||
<option value="HYCOM">HYCOM</option>
|
||||
<option value="기상수치모델">기상수치모델</option>
|
||||
<option value="기상기술">기상기술</option>
|
||||
</select>
|
||||
{/* 수신상태 필터 */}
|
||||
<select
|
||||
value={filterReceive}
|
||||
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
|
||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
||||
>
|
||||
<option value="all">모두 (상태)</option>
|
||||
<option value="수신완료">수신완료</option>
|
||||
<option value="수신대기">수신대기</option>
|
||||
<option value="수신실패">수신실패</option>
|
||||
<option value="시간초과">시간초과</option>
|
||||
</select>
|
||||
{/* 기간 필터 */}
|
||||
<select
|
||||
value={filterPeriod}
|
||||
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
|
||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
||||
>
|
||||
<option value="6h">최근 6시간</option>
|
||||
<option value="12h">최근 12시간</option>
|
||||
<option value="24h">최근 24시간</option>
|
||||
</select>
|
||||
<span className="text-xs text-t3">{filteredLogs.length}건</span>
|
||||
</div>
|
||||
</div>
|
||||
<DataLogTable rows={filteredLogs} loading={loading} />
|
||||
</section>
|
||||
|
||||
{/* 알림 현황 */}
|
||||
<section className="px-5 pt-4 pb-5">
|
||||
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3">
|
||||
알림 현황
|
||||
</h3>
|
||||
<AlertList alerts={alerts} loading={loading} />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
638
frontend/src/tabs/admin/components/RndRescuePanel.tsx
Normal file
638
frontend/src/tabs/admin/components/RndRescuePanel.tsx
Normal file
@ -0,0 +1,638 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
// ─── 타입 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
type PipelineStatus = '정상' | '지연' | '중단';
|
||||
type ReceiveStatus = '수신완료' | '수신대기' | '수신실패' | '시간초과';
|
||||
type ProcessStatus = '처리완료' | '처리중' | '대기' | '오류';
|
||||
type DataSource = 'HYCOM' | '기상청' | '긴급구난시스템';
|
||||
type AlertLevel = '경고' | '주의' | '정보';
|
||||
|
||||
interface PipelineNode {
|
||||
id: string;
|
||||
name: string;
|
||||
status: PipelineStatus;
|
||||
lastReceived: string;
|
||||
cycle: string;
|
||||
}
|
||||
|
||||
interface DataLogRow {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
source: DataSource;
|
||||
dataType: string;
|
||||
size: string;
|
||||
receiveStatus: ReceiveStatus;
|
||||
processStatus: ProcessStatus;
|
||||
}
|
||||
|
||||
interface AlertItem {
|
||||
id: string;
|
||||
level: AlertLevel;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// ─── Mock 데이터 ────────────────────────────────────────────────────────────────
|
||||
|
||||
const MOCK_PIPELINE: PipelineNode[] = [
|
||||
{
|
||||
id: 'hycom',
|
||||
name: 'HYCOM 해양순환모델',
|
||||
status: '정상',
|
||||
lastReceived: '2026-04-11 06:00',
|
||||
cycle: '6시간 주기',
|
||||
},
|
||||
{
|
||||
id: 'kma',
|
||||
name: '기상청 수치모델',
|
||||
status: '정상',
|
||||
lastReceived: '2026-04-11 06:00',
|
||||
cycle: '3시간 주기',
|
||||
},
|
||||
{
|
||||
id: 'rescue',
|
||||
name: '해경 긴급구난 시스템',
|
||||
status: '정상',
|
||||
lastReceived: '2026-04-11 06:30',
|
||||
cycle: '내부 연계',
|
||||
},
|
||||
{
|
||||
id: 'analysis',
|
||||
name: '구난 분석 연산',
|
||||
status: '정상',
|
||||
lastReceived: '2026-04-11 06:35',
|
||||
cycle: '연계 시작 즉시',
|
||||
},
|
||||
{
|
||||
id: 'result',
|
||||
name: '결과 연계 수신',
|
||||
status: '정상',
|
||||
lastReceived: '2026-04-11 06:40',
|
||||
cycle: '분석 완료 즉시',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_LOGS: DataLogRow[] = [
|
||||
{
|
||||
id: 'log-01',
|
||||
timestamp: '2026-04-11 06:40',
|
||||
source: '긴급구난시스템',
|
||||
dataType: '구난 가능성 판단',
|
||||
size: '1.2 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-02',
|
||||
timestamp: '2026-04-11 06:35',
|
||||
source: '긴급구난시스템',
|
||||
dataType: '선체상태 분석',
|
||||
size: '3.4 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-03',
|
||||
timestamp: '2026-04-11 06:30',
|
||||
source: '긴급구난시스템',
|
||||
dataType: '사고선 위치정보',
|
||||
size: '0.8 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-04',
|
||||
timestamp: '2026-04-11 06:30',
|
||||
source: '긴급구난시스템',
|
||||
dataType: '비상배인력 정보',
|
||||
size: '0.5 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-05',
|
||||
timestamp: '2026-04-11 06:00',
|
||||
source: 'HYCOM',
|
||||
dataType: '해수면온도(SST)',
|
||||
size: '98 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-06',
|
||||
timestamp: '2026-04-11 06:00',
|
||||
source: 'HYCOM',
|
||||
dataType: '해류(U/V)',
|
||||
size: '142 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-07',
|
||||
timestamp: '2026-04-11 06:00',
|
||||
source: 'HYCOM',
|
||||
dataType: '해수면높이(SSH)',
|
||||
size: '54 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-08',
|
||||
timestamp: '2026-04-11 06:00',
|
||||
source: '기상청',
|
||||
dataType: '풍향/풍속',
|
||||
size: '38 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-09',
|
||||
timestamp: '2026-04-11 06:00',
|
||||
source: '기상청',
|
||||
dataType: '기압',
|
||||
size: '22 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-10',
|
||||
timestamp: '2026-04-11 06:00',
|
||||
source: '기상청',
|
||||
dataType: '기온',
|
||||
size: '19 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-11',
|
||||
timestamp: '2026-04-11 03:30',
|
||||
source: '긴급구난시스템',
|
||||
dataType: '선체상태 분석',
|
||||
size: '3.1 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-12',
|
||||
timestamp: '2026-04-11 03:30',
|
||||
source: '긴급구난시스템',
|
||||
dataType: '구난 가능성 판단',
|
||||
size: '1.1 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-13',
|
||||
timestamp: '2026-04-11 00:30',
|
||||
source: '긴급구난시스템',
|
||||
dataType: '사고선 위치정보',
|
||||
size: '0.8 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
{
|
||||
id: 'log-14',
|
||||
timestamp: '2026-04-11 00:00',
|
||||
source: 'HYCOM',
|
||||
dataType: '해수면높이(SSH)',
|
||||
size: '53 MB',
|
||||
receiveStatus: '시간초과',
|
||||
processStatus: '오류',
|
||||
},
|
||||
{
|
||||
id: 'log-15',
|
||||
timestamp: '2026-04-10 21:30',
|
||||
source: '긴급구난시스템',
|
||||
dataType: '비상배인력 정보',
|
||||
size: '0.4 MB',
|
||||
receiveStatus: '수신완료',
|
||||
processStatus: '처리완료',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_ALERTS: AlertItem[] = [
|
||||
{
|
||||
id: 'alert-01',
|
||||
level: '정보',
|
||||
message: '해경 긴급구난 시스템 정상 연계 중',
|
||||
timestamp: '2026-04-11 06:30',
|
||||
},
|
||||
{
|
||||
id: 'alert-02',
|
||||
level: '정보',
|
||||
message: 'HYCOM 데이터 정상 수신',
|
||||
timestamp: '2026-04-11 06:00',
|
||||
},
|
||||
{
|
||||
id: 'alert-03',
|
||||
level: '정보',
|
||||
message: '금일 긴급구난 분석 완료: 5회/6회',
|
||||
timestamp: '2026-04-11 06:40',
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Mock fetch ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface RescueData {
|
||||
pipeline: PipelineNode[];
|
||||
logs: DataLogRow[];
|
||||
alerts: AlertItem[];
|
||||
}
|
||||
|
||||
function fetchRescueData(): Promise<RescueData> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
pipeline: MOCK_PIPELINE,
|
||||
logs: MOCK_LOGS,
|
||||
alerts: MOCK_ALERTS,
|
||||
}),
|
||||
300,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 유틸 ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
function getPipelineStatusStyle(status: PipelineStatus): string {
|
||||
if (status === '정상') return 'text-emerald-400 bg-emerald-500/10';
|
||||
if (status === '지연') return 'text-yellow-400 bg-yellow-500/10';
|
||||
return 'text-red-400 bg-red-500/10';
|
||||
}
|
||||
|
||||
function getPipelineBorderStyle(status: PipelineStatus): string {
|
||||
if (status === '정상') return 'border-l-emerald-500';
|
||||
if (status === '지연') return 'border-l-yellow-500';
|
||||
return 'border-l-red-500';
|
||||
}
|
||||
|
||||
function getReceiveStatusStyle(status: ReceiveStatus): string {
|
||||
if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10';
|
||||
if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10';
|
||||
return 'text-red-400 bg-red-500/10';
|
||||
}
|
||||
|
||||
function getProcessStatusStyle(status: ProcessStatus): string {
|
||||
if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10';
|
||||
if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10';
|
||||
if (status === '대기') return 'text-yellow-400 bg-yellow-500/10';
|
||||
return 'text-red-400 bg-red-500/10';
|
||||
}
|
||||
|
||||
function getAlertStyle(level: AlertLevel): string {
|
||||
if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30';
|
||||
if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30';
|
||||
return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30';
|
||||
}
|
||||
|
||||
// ─── 파이프라인 카드 ─────────────────────────────────────────────────────────────
|
||||
|
||||
function PipelineCard({ node }: { node: PipelineNode }) {
|
||||
const badgeStyle = getPipelineStatusStyle(node.status);
|
||||
const borderStyle = getPipelineBorderStyle(node.status);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke-1 border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
|
||||
>
|
||||
<div className="text-xs font-medium text-t1 leading-snug">{node.name}</div>
|
||||
<span
|
||||
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
|
||||
>
|
||||
{node.status}
|
||||
</span>
|
||||
<div className="text-label-2 text-t3 mt-0.5">최근 수신: {node.lastReceived}</div>
|
||||
<div className="text-label-2 text-t3">{node.cycle}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: boolean }) {
|
||||
if (loading && nodes.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 animate-pulse">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-1">
|
||||
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
|
||||
{i < 4 && <span className="text-t3 text-sm px-0.5">→</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-stretch gap-1">
|
||||
{nodes.map((node, idx) => (
|
||||
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
|
||||
<PipelineCard node={node} />
|
||||
{idx < nodes.length - 1 && (
|
||||
<span className="text-t3 text-sm shrink-0 px-0.5">→</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 수신 이력 테이블 ────────────────────────────────────────────────────────────
|
||||
|
||||
type FilterSource = 'all' | DataSource;
|
||||
type FilterReceive = 'all' | ReceiveStatus;
|
||||
type FilterPeriod = '6h' | '12h' | '24h';
|
||||
|
||||
const PERIOD_HOURS: Record<FilterPeriod, number> = { '6h': 6, '12h': 12, '24h': 24 };
|
||||
|
||||
function filterLogs(
|
||||
rows: DataLogRow[],
|
||||
source: FilterSource,
|
||||
receive: FilterReceive,
|
||||
period: FilterPeriod,
|
||||
): DataLogRow[] {
|
||||
const cutoff = new Date('2026-04-11T06:40:00');
|
||||
const hours = PERIOD_HOURS[period];
|
||||
const from = new Date(cutoff.getTime() - hours * 60 * 60 * 1000);
|
||||
|
||||
return rows.filter((r) => {
|
||||
if (source !== 'all' && r.source !== source) return false;
|
||||
if (receive !== 'all' && r.receiveStatus !== receive) return false;
|
||||
const ts = new Date(r.timestamp.replace(' ', 'T'));
|
||||
if (ts < from) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '수신상태', '처리상태'];
|
||||
|
||||
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
|
||||
return (
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
||||
{LOG_HEADERS.map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && rows.length === 0
|
||||
? Array.from({ length: 8 }).map((_, i) => (
|
||||
<tr key={i} className="border-b border-stroke-1 animate-pulse">
|
||||
{LOG_HEADERS.map((_, j) => (
|
||||
<td key={j} className="px-3 py-2">
|
||||
<div className="h-3 bg-bg-elevated rounded w-16" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
: rows.map((row) => (
|
||||
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
|
||||
{row.timestamp}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span
|
||||
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getReceiveStatusStyle(row.receiveStatus)}`}
|
||||
>
|
||||
{row.receiveStatus}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span
|
||||
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getProcessStatusStyle(row.processStatus)}`}
|
||||
>
|
||||
{row.processStatus}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-3 py-8 text-center text-t3">
|
||||
조회된 데이터가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 알림 목록 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean }) {
|
||||
if (loading && alerts.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 animate-pulse">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-8 bg-bg-elevated rounded" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (alerts.length === 0) {
|
||||
return <p className="text-xs text-t3 py-2">활성 알림이 없습니다.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{alerts.map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className={`flex items-start gap-2 px-3 py-2 rounded border text-xs ${getAlertStyle(alert.level)}`}
|
||||
>
|
||||
<span className="font-semibold shrink-0">[{alert.level}]</span>
|
||||
<span className="flex-1">{alert.message}</span>
|
||||
<span className="shrink-0 opacity-70 font-mono">{alert.timestamp}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 메인 패널 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function RndRescuePanel() {
|
||||
const [pipeline, setPipeline] = useState<PipelineNode[]>([]);
|
||||
const [logs, setLogs] = useState<DataLogRow[]>([]);
|
||||
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
||||
|
||||
// 필터
|
||||
const [filterSource, setFilterSource] = useState<FilterSource>('all');
|
||||
const [filterReceive, setFilterReceive] = useState<FilterReceive>('all');
|
||||
const [filterPeriod, setFilterPeriod] = useState<FilterPeriod>('24h');
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchRescueData();
|
||||
setPipeline(data.pipeline);
|
||||
setLogs(data.logs);
|
||||
setAlerts(data.alerts);
|
||||
setLastUpdate(new Date());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
void Promise.resolve().then(() => {
|
||||
if (isMounted) void fetchData();
|
||||
});
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [fetchData]);
|
||||
|
||||
const filteredLogs = filterLogs(logs, filterSource, filterReceive, filterPeriod);
|
||||
|
||||
const totalReceived = logs.filter((r) => r.receiveStatus === '수신완료').length;
|
||||
const totalDelayed = logs.filter((r) => r.receiveStatus === '수신대기').length;
|
||||
const totalFailed = logs.filter(
|
||||
(r) => r.receiveStatus === '수신실패' || r.receiveStatus === '시간초과',
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* ── 헤더 ── */}
|
||||
<div className="shrink-0 border-b border-stroke-1">
|
||||
<div className="flex items-center justify-between px-5 py-3">
|
||||
<h2 className="text-sm font-semibold text-t1">긴급구난과제 연계 모니터링</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
{lastUpdate && (
|
||||
<span className="text-xs text-t3">
|
||||
갱신:{' '}
|
||||
{lastUpdate.toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => void fetchData()}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 요약 통계 바 */}
|
||||
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
|
||||
<span>
|
||||
정상 수신:{' '}
|
||||
<span className="text-emerald-400 font-medium">{totalReceived}건</span>
|
||||
</span>
|
||||
<span className="text-stroke-1">|</span>
|
||||
<span>
|
||||
지연: <span className="text-yellow-400 font-medium">{totalDelayed}건</span>
|
||||
</span>
|
||||
<span className="text-stroke-1">|</span>
|
||||
<span>
|
||||
실패: <span className="text-red-400 font-medium">{totalFailed}건</span>
|
||||
</span>
|
||||
<span className="text-stroke-1">|</span>
|
||||
<span>
|
||||
금일 분석 완료:{' '}
|
||||
<span className="text-cyan-400 font-medium">5 / 6회</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 스크롤 영역 ── */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{/* 파이프라인 현황 */}
|
||||
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
|
||||
<h3 className="text-xs font-semibold text-t2 mb-3 uppercase tracking-wide">
|
||||
데이터 파이프라인 현황
|
||||
</h3>
|
||||
<PipelineFlow nodes={pipeline} loading={loading} />
|
||||
</section>
|
||||
|
||||
{/* 필터 바 + 수신 이력 테이블 */}
|
||||
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
|
||||
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
|
||||
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide shrink-0">
|
||||
데이터 수신 이력
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* 데이터소스 필터 */}
|
||||
<select
|
||||
value={filterSource}
|
||||
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
|
||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
||||
>
|
||||
<option value="all">모두 (소스)</option>
|
||||
<option value="HYCOM">HYCOM</option>
|
||||
<option value="기상청">기상청</option>
|
||||
<option value="긴급구난시스템">긴급구난시스템</option>
|
||||
</select>
|
||||
{/* 수신상태 필터 */}
|
||||
<select
|
||||
value={filterReceive}
|
||||
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
|
||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
||||
>
|
||||
<option value="all">모두 (상태)</option>
|
||||
<option value="수신완료">수신완료</option>
|
||||
<option value="수신대기">수신대기</option>
|
||||
<option value="수신실패">수신실패</option>
|
||||
<option value="시간초과">시간초과</option>
|
||||
</select>
|
||||
{/* 기간 필터 */}
|
||||
<select
|
||||
value={filterPeriod}
|
||||
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
|
||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
||||
>
|
||||
<option value="6h">최근 6시간</option>
|
||||
<option value="12h">최근 12시간</option>
|
||||
<option value="24h">최근 24시간</option>
|
||||
</select>
|
||||
<span className="text-xs text-t3">{filteredLogs.length}건</span>
|
||||
</div>
|
||||
</div>
|
||||
<DataLogTable rows={filteredLogs} loading={loading} />
|
||||
</section>
|
||||
|
||||
{/* 알림 현황 */}
|
||||
<section className="px-5 pt-4 pb-5">
|
||||
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3">
|
||||
알림 현황
|
||||
</h3>
|
||||
<AlertList alerts={alerts} loading={loading} />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1544
frontend/src/tabs/admin/components/SystemArchPanel.tsx
Normal file
1544
frontend/src/tabs/admin/components/SystemArchPanel.tsx
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -15,6 +15,7 @@ export const ADMIN_MENU: AdminMenuItem[] = [
|
||||
children: [
|
||||
{ id: 'menus', label: '메뉴관리' },
|
||||
{ id: 'settings', label: '시스템설정' },
|
||||
{ id: 'system-arch', label: '시스템구조' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -91,6 +92,16 @@ export const ADMIN_MENU: AdminMenuItem[] = [
|
||||
{ id: 'monitor-vessel', label: '선박위치정보' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rnd',
|
||||
label: 'R&D과제',
|
||||
children: [
|
||||
{ id: 'rnd-poseidon', label: '유출유확산예측(포세이돈)' },
|
||||
{ id: 'rnd-kosps', label: '유출유확산예측(KOSPS)' },
|
||||
{ id: 'rnd-hns-atmos', label: 'HNS대기확산(충북대)' },
|
||||
{ id: 'rnd-rescue', label: '긴급구난과제' },
|
||||
],
|
||||
},
|
||||
{ id: 'deidentify', label: '비식별화조치' },
|
||||
],
|
||||
},
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { Map, Marker, Popup } from '@vis.gl/react-maplibre';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
|
||||
import { fetchRescueOps, fetchRescueScenarios } from '../services/rescueApi';
|
||||
import type { RescueOpsItem, RescueScenarioItem } from '../services/rescueApi';
|
||||
|
||||
@ -103,6 +106,295 @@ interface ChartDataItem {
|
||||
severity: Severity;
|
||||
}
|
||||
|
||||
/* ─── 시나리오 관리 요건 ─── */
|
||||
const SCENARIO_MGMT_GUIDELINES = [
|
||||
'긴급구난 R&D 분석 결과는 시간 단계별 시나리오의 형태로 관리되어야 함',
|
||||
'각 시나리오는 사고 발생 시점부터 구난 진행 단계별 상태 변화를 포함하여야 함',
|
||||
'시나리오별 분석 결과는 사고 단위로 기존 사고 정보와 연계되어 관리되어야 함',
|
||||
'동일 사고에 대해 복수 시나리오(시간대, 조건별)가 존재할 경우, 상호 비교·검토가 되어야 함',
|
||||
'시나리오별 분석결과는 긴급구난 대응 판단을 지원할 수 있도록 요약 정보 형태로 제공되어야 함',
|
||||
'시나리오 관리 기능은 기존 통합지원시스템의 흐름과 연계되어 실질적인 구난 대응 업무에 활용 가능하도록 반영되어야 함',
|
||||
'긴급구난 시나리오 관리 기능 구현 시 1차 구축 완료된 GIS기능을 활용하여 구축하여 재개발하거나 중복구현하지 않도록 함',
|
||||
];
|
||||
|
||||
/* ─── Mock 시나리오 (API 미연결 시 폴백) — 긴급구난 모델 이론 기반 10개 ─── */
|
||||
const MOCK_SCENARIOS: RescueScenarioItem[] = [
|
||||
{
|
||||
scenarioSn: 1, rescueOpsSn: 1, timeStep: 'T+0h',
|
||||
scenarioDtm: '2024-10-27T01:30:00.000Z', svrtCd: 'CRITICAL',
|
||||
gmM: 0.8, listDeg: 15.0, trimM: 2.5, buoyancyPct: 30.0, oilRateLpm: 100.0, bmRatioPct: 92.0,
|
||||
description: '좌현 35° 충돌로 No.1P 화물탱크 파공. 벙커C유 유출 개시. 손상복원성 분석: 초기 GM 0.8m으로 IMO 기준(1.0m) 미달, 복원력 위험 판정.',
|
||||
compartments: [
|
||||
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||
{ name: '#1 Port Tank', status: 'BREACHED', color: 'var(--red)' },
|
||||
{ name: '#2 Port Tank', status: 'RISK', color: 'var(--orange)' },
|
||||
{ name: 'Engine Room', status: 'INTACT', color: 'var(--green)' },
|
||||
{ name: '#3 Stbd Tank', status: 'INTACT', color: 'var(--green)' },
|
||||
],
|
||||
assessment: [
|
||||
{ label: '복원력', value: '위험 (GM 0.8m < IMO 1.0m)', color: 'var(--red)' },
|
||||
{ label: '유출 위험', value: '활발 유출중 (100 L/min)', color: 'var(--red)' },
|
||||
{ label: '선체 강도', value: 'BM 92% (경계)', color: 'var(--orange)' },
|
||||
{ label: '승선인원', value: '15/20 확인, 5명 수색중', color: 'var(--red)' },
|
||||
],
|
||||
actions: [
|
||||
{ time: '10:30', text: '충돌 발생, VHF Ch.16 조난 통보', color: 'var(--red)' },
|
||||
{ time: '10:32', text: 'EPIRB 자동 발신 확인', color: 'var(--red)' },
|
||||
{ time: '10:35', text: '해경 3009함 출동 지시', color: 'var(--orange)' },
|
||||
{ time: '10:42', text: '인근 선박 구조 활동 개시', color: 'var(--cyan)' },
|
||||
],
|
||||
sortOrd: 1,
|
||||
},
|
||||
{
|
||||
scenarioSn: 2, rescueOpsSn: 1, timeStep: 'T+30m',
|
||||
scenarioDtm: '2024-10-27T02:00:00.000Z', svrtCd: 'CRITICAL',
|
||||
gmM: 0.7, listDeg: 17.0, trimM: 2.8, buoyancyPct: 28.0, oilRateLpm: 120.0, bmRatioPct: 90.0,
|
||||
description: '잠수사 수중 조사 결과 좌현 No.1P 파공 크기 1.2m×0.8m 확인. Bernoulli 유입률 모델 적용: 수두차 4.5m 기준 유입률 약 2.1㎥/min.',
|
||||
compartments: [
|
||||
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||
{ name: '#1 Port Tank', status: 'BREACHED', color: 'var(--red)' },
|
||||
{ name: '#2 Port Tank', status: 'RISK', color: 'var(--orange)' },
|
||||
{ name: 'Engine Room', status: 'INTACT', color: 'var(--green)' },
|
||||
{ name: '#3 Stbd Tank', status: 'INTACT', color: 'var(--green)' },
|
||||
],
|
||||
assessment: [
|
||||
{ label: '복원력', value: '악화 (GM 0.7m, GZ 커브 감소)', color: 'var(--red)' },
|
||||
{ label: '유출 위험', value: '증가 (120 L/min)', color: 'var(--red)' },
|
||||
{ label: '선체 강도', value: 'BM 90% — 종강도 모니터링 개시', color: 'var(--orange)' },
|
||||
{ label: '승선인원', value: '15명 퇴선, 5명 수색중', color: 'var(--red)' },
|
||||
],
|
||||
actions: [
|
||||
{ time: '10:50', text: '잠수사 투입, 수중 손상 조사 개시', color: 'var(--cyan)' },
|
||||
{ time: '10:55', text: '파공 규모 확인: 1.2m×0.8m', color: 'var(--red)' },
|
||||
{ time: '11:00', text: '손상복원성 재계산 — IMO 기준 위험', color: 'var(--red)' },
|
||||
{ time: '11:00', text: '유출유 방제선 배치 요청', color: 'var(--orange)' },
|
||||
],
|
||||
sortOrd: 2,
|
||||
},
|
||||
{
|
||||
scenarioSn: 3, rescueOpsSn: 1, timeStep: 'T+1h',
|
||||
scenarioDtm: '2024-10-27T02:30:00.000Z', svrtCd: 'CRITICAL',
|
||||
gmM: 0.65, listDeg: 18.5, trimM: 3.0, buoyancyPct: 26.0, oilRateLpm: 135.0, bmRatioPct: 89.0,
|
||||
description: '해경 3009함 현장 도착, SAR 작전 개시. Leeway 표류 예측 모델: 풍속 8m/s, 해류 2.5kn NE — 실종자 표류 반경 1.2nm. GZ 최대 복원력 각도 25°로 감소.',
|
||||
compartments: [
|
||||
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||
{ name: '#1 Port Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||
{ name: '#2 Port Tank', status: 'FLOODING', color: 'var(--red)' },
|
||||
{ name: 'Engine Room', status: 'INTACT', color: 'var(--green)' },
|
||||
{ name: '#3 Stbd Tank', status: 'INTACT', color: 'var(--green)' },
|
||||
],
|
||||
assessment: [
|
||||
{ label: '복원력', value: '한계 접근 (GM 0.65m)', color: 'var(--red)' },
|
||||
{ label: '유출 위험', value: '파공 확대 우려 (135 L/min)', color: 'var(--red)' },
|
||||
{ label: '선체 강도', value: 'BM 89% — Hogging 증가', color: 'var(--orange)' },
|
||||
{ label: '인명구조', value: '실종 5명 수색중, 표류 1.2nm', color: 'var(--red)' },
|
||||
],
|
||||
actions: [
|
||||
{ time: '11:10', text: '해경 3009함 현장 도착, SAR 구역 설정', color: 'var(--cyan)' },
|
||||
{ time: '11:15', text: 'Leeway 표류 예측 모델 적용', color: 'var(--cyan)' },
|
||||
{ time: '11:20', text: '회전익 항공기 수색 개시', color: 'var(--cyan)' },
|
||||
{ time: '11:30', text: '#2 Port Tank 2차 침수 징후', color: 'var(--red)' },
|
||||
],
|
||||
sortOrd: 3,
|
||||
},
|
||||
{
|
||||
scenarioSn: 4, rescueOpsSn: 1, timeStep: 'T+2h',
|
||||
scenarioDtm: '2024-10-27T03:30:00.000Z', svrtCd: 'CRITICAL',
|
||||
gmM: 0.5, listDeg: 20.0, trimM: 3.5, buoyancyPct: 22.0, oilRateLpm: 160.0, bmRatioPct: 86.0,
|
||||
description: '격벽 관통으로 #2 Port Tank 침수 확대. 자유표면효과(FSE) 보정: GM_fluid = 0.5m. 종강도: Sagging 모멘트 86%. 침몰 위험 단계 진입.',
|
||||
compartments: [
|
||||
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||
{ name: '#1 Port Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||
{ name: '#2 Port Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||
{ name: 'Engine Room', status: 'RISK', color: 'var(--orange)' },
|
||||
{ name: '#3 Stbd Tank', status: 'INTACT', color: 'var(--green)' },
|
||||
],
|
||||
assessment: [
|
||||
{ label: '복원력', value: '위기 (GM 0.5m, FSE 보정)', color: 'var(--red)' },
|
||||
{ label: '유출 위험', value: '최대치 접근 (160 L/min)', color: 'var(--red)' },
|
||||
{ label: '선체 강도', value: 'BM 86% — Sagging 경고', color: 'var(--red)' },
|
||||
{ label: '승선인원', value: '실종 3명 발견, 2명 수색', color: 'var(--orange)' },
|
||||
],
|
||||
actions: [
|
||||
{ time: '12:00', text: '#2 Port Tank 격벽 관통 침수', color: 'var(--red)' },
|
||||
{ time: '12:10', text: '자유표면효과(FSE) 보정 재계산', color: 'var(--red)' },
|
||||
{ time: '12:15', text: '긴급 Counter-Flooding 검토', color: 'var(--orange)' },
|
||||
{ time: '12:30', text: '실종자 3명 추가 발견 구조', color: 'var(--green)' },
|
||||
],
|
||||
sortOrd: 4,
|
||||
},
|
||||
{
|
||||
scenarioSn: 5, rescueOpsSn: 1, timeStep: 'T+3h',
|
||||
scenarioDtm: '2024-10-27T04:30:00.000Z', svrtCd: 'HIGH',
|
||||
gmM: 0.55, listDeg: 16.0, trimM: 3.2, buoyancyPct: 25.0, oilRateLpm: 140.0, bmRatioPct: 87.0,
|
||||
description: 'Counter-Flooding 실시: #3 Stbd Tank에 평형수 280톤 주입, 횡경사 20°→16° 교정. 종강도: 중량 재배분으로 BM 87% 유지.',
|
||||
compartments: [
|
||||
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||
{ name: '#1 Port Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||
{ name: '#2 Port Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||
{ name: 'Engine Room', status: 'RISK', color: 'var(--orange)' },
|
||||
{ name: '#3 Stbd Tank', status: 'BALLASTED', color: 'var(--orange)' },
|
||||
],
|
||||
assessment: [
|
||||
{ label: '복원력', value: '개선 중 (GM 0.55m, 경사 16°)', color: 'var(--orange)' },
|
||||
{ label: '유출 위험', value: '감소 추세 (140 L/min)', color: 'var(--orange)' },
|
||||
{ label: '선체 강도', value: 'BM 87% — Counter-Flooding 평가', color: 'var(--orange)' },
|
||||
{ label: '구조 상황', value: '실종 2명 수색 지속', color: 'var(--orange)' },
|
||||
],
|
||||
actions: [
|
||||
{ time: '12:45', text: 'Counter-Flooding — #3 Stbd 주입 개시', color: 'var(--orange)' },
|
||||
{ time: '13:00', text: '평형수 280톤 주입, 경사 교정 진행', color: 'var(--cyan)' },
|
||||
{ time: '13:15', text: '종강도 재계산 — 허용 범위 내', color: 'var(--cyan)' },
|
||||
{ time: '13:30', text: '횡경사 16° 안정화 확인', color: 'var(--green)' },
|
||||
],
|
||||
sortOrd: 5,
|
||||
},
|
||||
{
|
||||
scenarioSn: 6, rescueOpsSn: 1, timeStep: 'T+6h',
|
||||
scenarioDtm: '2024-10-27T07:30:00.000Z', svrtCd: 'HIGH',
|
||||
gmM: 0.7, listDeg: 12.0, trimM: 2.5, buoyancyPct: 32.0, oilRateLpm: 80.0, bmRatioPct: 90.0,
|
||||
description: '수중패치 설치, 유입률 감소. GM 0.7m 회복. Trim/Stability Booklet 기준 예인 가능 최소 조건(GM≥0.5m, List≤15°) 충족.',
|
||||
compartments: [
|
||||
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||
{ name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' },
|
||||
{ name: '#2 Port Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||
{ name: 'Engine Room', status: 'INTACT', color: 'var(--green)' },
|
||||
{ name: '#3 Stbd Tank', status: 'BALLASTED', color: 'var(--orange)' },
|
||||
],
|
||||
assessment: [
|
||||
{ label: '복원력', value: '개선 (GM 0.7m, 예인 가능)', color: 'var(--orange)' },
|
||||
{ label: '유출 위험', value: '수중패치 효과 (80 L/min)', color: 'var(--orange)' },
|
||||
{ label: '선체 강도', value: 'BM 90% — 안정 범위', color: 'var(--green)' },
|
||||
{ label: '구조 상황', value: '전원 구조 완료', color: 'var(--green)' },
|
||||
],
|
||||
actions: [
|
||||
{ time: '14:00', text: '수중패치 설치 작업 개시', color: 'var(--cyan)' },
|
||||
{ time: '14:30', text: '수중패치 설치 완료', color: 'var(--green)' },
|
||||
{ time: '15:00', text: '해상크레인 도착, 잔류유 이적 준비', color: 'var(--cyan)' },
|
||||
{ time: '16:30', text: '잔류유 1차 이적 완료 (45kL)', color: 'var(--green)' },
|
||||
],
|
||||
sortOrd: 6,
|
||||
},
|
||||
{
|
||||
scenarioSn: 7, rescueOpsSn: 1, timeStep: 'T+8h',
|
||||
scenarioDtm: '2024-10-27T09:30:00.000Z', svrtCd: 'MEDIUM',
|
||||
gmM: 0.8, listDeg: 10.0, trimM: 2.0, buoyancyPct: 38.0, oilRateLpm: 55.0, bmRatioPct: 91.0,
|
||||
description: '오일붐 2중 전개, 유회수기 3대 가동. GNOME 확산 모델: 12시간 후 확산 면적 2.3km² 예측. 기계적 회수율 35%.',
|
||||
compartments: [
|
||||
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||
{ name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' },
|
||||
{ name: '#2 Port Tank', status: 'SEALED', color: 'var(--orange)' },
|
||||
{ name: 'Engine Room', status: 'INTACT', color: 'var(--green)' },
|
||||
{ name: '#3 Stbd Tank', status: 'BALLASTED', color: 'var(--orange)' },
|
||||
],
|
||||
assessment: [
|
||||
{ label: '복원력', value: '안정 (GM 0.8m)', color: 'var(--orange)' },
|
||||
{ label: '유출 위험', value: '방제 진행 (55 L/min, 회수 35%)', color: 'var(--orange)' },
|
||||
{ label: '선체 강도', value: 'BM 91%', color: 'var(--green)' },
|
||||
{ label: '방제 현황', value: '오일붐 2중, 유회수기 3대', color: 'var(--cyan)' },
|
||||
],
|
||||
actions: [
|
||||
{ time: '17:00', text: '오일붐 1차 전개 (500m)', color: 'var(--cyan)' },
|
||||
{ time: '17:30', text: '오일붐 2차 전개 (이중 방어선)', color: 'var(--cyan)' },
|
||||
{ time: '17:45', text: '유회수기 3대 배치·가동', color: 'var(--cyan)' },
|
||||
{ time: '18:30', text: 'GNOME 확산 예측 갱신', color: 'var(--orange)' },
|
||||
],
|
||||
sortOrd: 7,
|
||||
},
|
||||
{
|
||||
scenarioSn: 8, rescueOpsSn: 1, timeStep: 'T+12h',
|
||||
scenarioDtm: '2024-10-27T13:30:00.000Z', svrtCd: 'MEDIUM',
|
||||
gmM: 0.9, listDeg: 8.0, trimM: 1.5, buoyancyPct: 45.0, oilRateLpm: 30.0, bmRatioPct: 94.0,
|
||||
description: '예인 개시. 예인 저항 Rt=1/2·ρ·Cd·A·V² 기반 4,000HP급 배정. 목포항 42nm, 예인 속도 3kn, ETA 14h.',
|
||||
compartments: [
|
||||
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||
{ name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' },
|
||||
{ name: '#2 Port Tank', status: 'SEALED', color: 'var(--orange)' },
|
||||
{ name: 'Engine Room', status: 'INTACT', color: 'var(--green)' },
|
||||
{ name: '#3 Stbd Tank', status: 'BALLASTED', color: 'var(--orange)' },
|
||||
],
|
||||
assessment: [
|
||||
{ label: '복원력', value: '안정 (GM 0.9m)', color: 'var(--orange)' },
|
||||
{ label: '유출 위험', value: '억제 중 (30 L/min)', color: 'var(--green)' },
|
||||
{ label: '선체 강도', value: 'BM 94%', color: 'var(--green)' },
|
||||
{ label: '예인 상태', value: '목포항, ETA 14h, 3kn', color: 'var(--cyan)' },
|
||||
],
|
||||
actions: [
|
||||
{ time: '18:00', text: '예인 접속, 예인삭 250m 전개', color: 'var(--cyan)' },
|
||||
{ time: '18:30', text: '예인 개시 (목포항 방향)', color: 'var(--cyan)' },
|
||||
{ time: '20:00', text: '야간 감시 체제 전환', color: 'var(--orange)' },
|
||||
{ time: '22:30', text: '예인 진행률 30%, 선체 안정', color: 'var(--green)' },
|
||||
],
|
||||
sortOrd: 8,
|
||||
},
|
||||
{
|
||||
scenarioSn: 9, rescueOpsSn: 1, timeStep: 'T+18h',
|
||||
scenarioDtm: '2024-10-27T19:30:00.000Z', svrtCd: 'MEDIUM',
|
||||
gmM: 1.0, listDeg: 5.0, trimM: 1.0, buoyancyPct: 55.0, oilRateLpm: 15.0, bmRatioPct: 96.0,
|
||||
description: '예인 진행률 65%. 파랑 응답 분석(RAO): 유의파고 1.2m, 주기 6s — 횡동요 ±3° 안전 범위. 잔류 유출률 15 L/min.',
|
||||
compartments: [
|
||||
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||
{ name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' },
|
||||
{ name: '#2 Port Tank', status: 'SEALED', color: 'var(--orange)' },
|
||||
{ name: 'Engine Room', status: 'INTACT', color: 'var(--green)' },
|
||||
{ name: '#3 Stbd Tank', status: 'STABLE', color: 'var(--green)' },
|
||||
],
|
||||
assessment: [
|
||||
{ label: '복원력', value: '양호 (GM 1.0m, IMO 충족)', color: 'var(--green)' },
|
||||
{ label: '유출 위험', value: '미량 유출 (15 L/min)', color: 'var(--green)' },
|
||||
{ label: '선체 강도', value: 'BM 96% 정상', color: 'var(--green)' },
|
||||
{ label: '예인 상태', value: '진행률 65%, ETA 5.5h', color: 'var(--cyan)' },
|
||||
],
|
||||
actions: [
|
||||
{ time: '00:00', text: '야간 예인 정상 진행', color: 'var(--green)' },
|
||||
{ time: '02:00', text: '파랑 응답 분석 — 안전 확인', color: 'var(--green)' },
|
||||
{ time: '03:00', text: '잔류유 유출률 15 L/min', color: 'var(--green)' },
|
||||
{ time: '04:30', text: '목포항 VTS 통보, 입항 협의', color: 'var(--cyan)' },
|
||||
],
|
||||
sortOrd: 9,
|
||||
},
|
||||
{
|
||||
scenarioSn: 10, rescueOpsSn: 1, timeStep: 'T+24h',
|
||||
scenarioDtm: '2024-10-28T01:30:00.000Z', svrtCd: 'RESOLVED',
|
||||
gmM: 1.2, listDeg: 3.0, trimM: 0.5, buoyancyPct: 75.0, oilRateLpm: 5.0, bmRatioPct: 98.0,
|
||||
description: '목포항 접안 완료. 잔류유 전량 이적(120kL). 최종 GM 1.2m IMO 충족, BM 98% 정상. 방제 총 회수량 85kL (회수율 71%). 상황 종료.',
|
||||
compartments: [
|
||||
{ name: '#1 FP Tank', status: 'SEALED', color: 'var(--orange)' },
|
||||
{ name: '#1 Port Tank', status: 'SEALED', color: 'var(--orange)' },
|
||||
{ name: '#2 Port Tank', status: 'SEALED', color: 'var(--orange)' },
|
||||
{ name: 'Engine Room', status: 'INTACT', color: 'var(--green)' },
|
||||
{ name: '#3 Stbd Tank', status: 'STABLE', color: 'var(--green)' },
|
||||
],
|
||||
assessment: [
|
||||
{ label: '복원력', value: '안전 (GM 1.2m)', color: 'var(--green)' },
|
||||
{ label: '유출 위험', value: '차단 완료', color: 'var(--green)' },
|
||||
{ label: '선체 강도', value: 'BM 98% 정상', color: 'var(--green)' },
|
||||
{ label: '최종 상태', value: '접안 완료, 상황 종료', color: 'var(--green)' },
|
||||
],
|
||||
actions: [
|
||||
{ time: '06:00', text: '목포항 접근, 도선사 대기', color: 'var(--cyan)' },
|
||||
{ time: '08:00', text: '도선사 승선, 접안 개시', color: 'var(--cyan)' },
|
||||
{ time: '09:30', text: '접안 완료, 잔류유 이적선 접현', color: 'var(--green)' },
|
||||
{ time: '10:30', text: '잔류유 전량 이적, 상황 종료', color: 'var(--green)' },
|
||||
],
|
||||
sortOrd: 10,
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_OPS: RescueOpsItem[] = [
|
||||
{
|
||||
rescueOpsSn: 1, acdntSn: 1, opsCd: 'RSC-2026-001', acdntTpCd: 'collision',
|
||||
vesselNm: 'M/V SEA GUARDIAN', commanderNm: null,
|
||||
lon: 126.25, lat: 37.467, locDc: '37°28\'N, 126°15\'E',
|
||||
depthM: 25.0, currentDc: '2.5kn NE',
|
||||
gmM: 0.8, listDeg: 15.0, trimM: 2.5, buoyancyPct: 30.0,
|
||||
oilRateLpm: 100.0, bmRatioPct: 92.0,
|
||||
totalCrew: 20, survivors: 15, missing: 5,
|
||||
hydroData: null, gmdssData: null,
|
||||
sttsCd: 'ACTIVE', regDtm: '2024-10-27T01:30:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
RescueScenarioView
|
||||
═══════════════════════════════════════════════════════════════════ */
|
||||
@ -116,14 +408,15 @@ export function RescueScenarioView() {
|
||||
const [sortBy, setSortBy] = useState<'time' | 'risk'>('time');
|
||||
const [detailView, setDetailView] = useState<DetailView>(0);
|
||||
const [newScnModalOpen, setNewScnModalOpen] = useState(false);
|
||||
const [guideOpen, setGuideOpen] = useState(false);
|
||||
|
||||
const loadScenarios = useCallback(async (opsSn: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const items = await fetchRescueScenarios(opsSn);
|
||||
setApiScenarios(items);
|
||||
} catch (err) {
|
||||
console.error('[rescue] 시나리오 조회 실패:', err);
|
||||
setApiScenarios(items.length > 0 ? items : MOCK_SCENARIOS);
|
||||
} catch {
|
||||
setApiScenarios(MOCK_SCENARIOS);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -132,14 +425,17 @@ export function RescueScenarioView() {
|
||||
const loadOps = useCallback(async () => {
|
||||
try {
|
||||
const items = await fetchRescueOps();
|
||||
setOps(items);
|
||||
if (items.length > 0) {
|
||||
setOps(items);
|
||||
loadScenarios(items[0].rescueOpsSn);
|
||||
} else {
|
||||
setOps(MOCK_OPS);
|
||||
setApiScenarios(MOCK_SCENARIOS);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[rescue] 구난 작전 목록 조회 실패:', err);
|
||||
} catch {
|
||||
setOps(MOCK_OPS);
|
||||
setApiScenarios(MOCK_SCENARIOS);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loadScenarios]);
|
||||
@ -229,9 +525,35 @@ export function RescueScenarioView() {
|
||||
>
|
||||
+ 신규 시나리오
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setGuideOpen((v) => !v)}
|
||||
className="cursor-pointer whitespace-nowrap font-semibold text-label-2 px-[14px] py-1.5 rounded-sm"
|
||||
style={{
|
||||
border: '1px solid rgba(6,182,212,.15)',
|
||||
background: guideOpen ? 'rgba(6,182,212,.12)' : 'transparent',
|
||||
color: guideOpen ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
||||
}}
|
||||
>
|
||||
{guideOpen ? '▴ 관리 요건' : '▾ 관리 요건'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 시나리오 관리 요건 가이드라인 ── */}
|
||||
{guideOpen && (
|
||||
<div className="border-b border-stroke px-5 py-3 bg-bg-surface shrink-0">
|
||||
<p className="text-label-1 font-bold mb-2">시나리오 관리 요건</p>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{SCENARIO_MGMT_GUIDELINES.map((g, i) => (
|
||||
<li key={i} className="text-caption text-fg-sub leading-relaxed flex gap-1.5">
|
||||
<span className="text-color-accent shrink-0">{i + 1}.</span>
|
||||
<span>{g}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Content: Left List + Right Detail ── */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* ═══ LEFT: 시나리오 목록 ═══ */}
|
||||
@ -376,7 +698,7 @@ export function RescueScenarioView() {
|
||||
</div>
|
||||
|
||||
{/* View content */}
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin">
|
||||
<div className={`flex-1 ${detailView === 2 ? 'flex flex-col overflow-hidden' : 'overflow-y-auto scrollbar-thin'}`}>
|
||||
{/* ─── VIEW 0: 시나리오 상세 ─── */}
|
||||
{detailView === 0 && selected && (
|
||||
<div className="p-5">
|
||||
@ -536,37 +858,14 @@ export function RescueScenarioView() {
|
||||
|
||||
{/* ─── VIEW 2: 지도 오버레이 ─── */}
|
||||
{detailView === 2 && (
|
||||
<div className="p-5">
|
||||
<div className="bg-bg-card border border-stroke rounded-[10px] p-5 text-center">
|
||||
<div className="text-[32px] opacity-30 mb-2.5">🗺</div>
|
||||
<div className="text-title-4 font-bold mb-1.5">GIS 기반 시나리오 비교</div>
|
||||
<div className="text-label-2 text-fg-disabled leading-relaxed mb-4">
|
||||
선택된 시나리오의 침수 구역을 지도 위에 오버레이하여 비교합니다.
|
||||
</div>
|
||||
<div className="flex gap-2 justify-center flex-wrap">
|
||||
{scenarios.map((sc) => (
|
||||
<div
|
||||
key={sc.id}
|
||||
className="px-3 py-1.5 rounded-md text-caption"
|
||||
style={{
|
||||
border: `1px solid ${SEV_STYLE[sc.severity].color}40`,
|
||||
background: SEV_STYLE[sc.severity].bg,
|
||||
}}
|
||||
>
|
||||
<span className="font-bold" style={{ color: SEV_STYLE[sc.severity].color }}>
|
||||
{sc.id}
|
||||
</span>
|
||||
<span className="text-fg-sub ml-1.5">{sc.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 p-[30px] bg-bg-base rounded-md border border-dashed border-stroke">
|
||||
<div className="text-label-2 text-fg-disabled">
|
||||
지도 뷰 영역 — 구난 분석 지도와 연동하여 침수 구역 오버레이 표시
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ScenarioMapOverlay
|
||||
ops={ops}
|
||||
selectedIncident={selectedIncident}
|
||||
scenarios={scenarios}
|
||||
selectedId={selectedId}
|
||||
checked={checked}
|
||||
onSelectScenario={setSelectedId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -578,6 +877,310 @@ export function RescueScenarioView() {
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══ 지도 오버레이 ═══ */
|
||||
interface ScenarioMapOverlayProps {
|
||||
ops: RescueOpsItem[];
|
||||
selectedIncident: number;
|
||||
scenarios: RescueScenario[];
|
||||
selectedId: string;
|
||||
checked: Set<string>;
|
||||
onSelectScenario: (id: string) => void;
|
||||
}
|
||||
|
||||
function ScenarioMapOverlay({
|
||||
ops,
|
||||
selectedIncident,
|
||||
scenarios,
|
||||
selectedId,
|
||||
checked,
|
||||
onSelectScenario,
|
||||
}: ScenarioMapOverlayProps) {
|
||||
const [popupId, setPopupId] = useState<string | null>(null);
|
||||
const baseMapStyle = useBaseMapStyle();
|
||||
|
||||
const currentOp = ops[selectedIncident] ?? null;
|
||||
const center = useMemo<[number, number]>(
|
||||
() =>
|
||||
currentOp?.lon != null && currentOp?.lat != null
|
||||
? [currentOp.lon, currentOp.lat]
|
||||
: [126.25, 37.467],
|
||||
[currentOp],
|
||||
);
|
||||
|
||||
const visibleScenarios = useMemo(
|
||||
() => scenarios.filter((s) => checked.has(s.id)),
|
||||
[scenarios, checked],
|
||||
);
|
||||
|
||||
const selected = scenarios.find((s) => s.id === selectedId);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 overflow-hidden">
|
||||
{/* 시나리오 선택 바 */}
|
||||
<div className="flex items-center gap-1.5 px-4 py-2 border-b border-stroke bg-bg-surface shrink-0 overflow-x-auto">
|
||||
<span className="text-caption text-fg-disabled shrink-0">시나리오:</span>
|
||||
{visibleScenarios.map((sc) => {
|
||||
const sev = SEV_STYLE[sc.severity];
|
||||
const isActive = selectedId === sc.id;
|
||||
return (
|
||||
<button
|
||||
key={sc.id}
|
||||
onClick={() => onSelectScenario(sc.id)}
|
||||
className="cursor-pointer shrink-0 px-2 py-1 rounded text-caption font-semibold transition-all"
|
||||
style={{
|
||||
border: `1.5px solid ${isActive ? sev.color : sev.color + '40'}`,
|
||||
background: isActive ? sev.bg : 'transparent',
|
||||
color: isActive ? sev.color : 'var(--fg-disabled)',
|
||||
}}
|
||||
>
|
||||
{sc.id} {sc.timeStep}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 지도 영역 */}
|
||||
<div className="flex-1 relative">
|
||||
<Map
|
||||
initialViewState={{ longitude: center[0], latitude: center[1], zoom: 11 }}
|
||||
mapStyle={baseMapStyle}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
attributionControl={false}
|
||||
>
|
||||
{/* 사고 위치 마커 */}
|
||||
{currentOp && currentOp.lon != null && currentOp.lat != null && (
|
||||
<Marker longitude={currentOp.lon} latitude={currentOp.lat} anchor="center">
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(239,68,68,0.25)',
|
||||
border: '2px solid var(--color-danger)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--color-danger)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Marker>
|
||||
)}
|
||||
|
||||
{/* 시나리오별 마커 — 사고 지점 주변에 시간 순서대로 배치 */}
|
||||
{visibleScenarios.map((sc, idx) => {
|
||||
const angle = (idx / visibleScenarios.length) * Math.PI * 2 - Math.PI / 2;
|
||||
const radius = 0.015 + idx * 0.003;
|
||||
const lng = center[0] + Math.cos(angle) * radius;
|
||||
const lat = center[1] + Math.sin(angle) * radius * 0.8;
|
||||
const sev = SEV_STYLE[sc.severity];
|
||||
const isActive = selectedId === sc.id;
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={sc.id}
|
||||
longitude={lng}
|
||||
latitude={lat}
|
||||
anchor="center"
|
||||
onClick={(e) => {
|
||||
e.originalEvent.stopPropagation();
|
||||
onSelectScenario(sc.id);
|
||||
setPopupId(popupId === sc.id ? null : sc.id);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="cursor-pointer flex items-center justify-center transition-transform"
|
||||
style={{
|
||||
width: isActive ? 36 : 28,
|
||||
height: isActive ? 36 : 28,
|
||||
borderRadius: '50%',
|
||||
background: sev.bg,
|
||||
border: `2px solid ${sev.color}`,
|
||||
transform: isActive ? 'scale(1.15)' : 'scale(1)',
|
||||
boxShadow: isActive ? `0 0 12px ${sev.color}60` : 'none',
|
||||
zIndex: isActive ? 10 : 1,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="font-bold font-mono"
|
||||
style={{ fontSize: isActive ? 11 : 9, color: sev.color }}
|
||||
>
|
||||
{sc.timeStep.replace('T+', '')}
|
||||
</span>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 팝업 — 클릭한 시나리오 정보 표출 */}
|
||||
{popupId &&
|
||||
(() => {
|
||||
const sc = visibleScenarios.find((s) => s.id === popupId);
|
||||
if (!sc) return null;
|
||||
const idx = visibleScenarios.indexOf(sc);
|
||||
const angle = (idx / visibleScenarios.length) * Math.PI * 2 - Math.PI / 2;
|
||||
const radius = 0.015 + idx * 0.003;
|
||||
const lng = center[0] + Math.cos(angle) * radius;
|
||||
const lat = center[1] + Math.sin(angle) * radius * 0.8;
|
||||
const sev = SEV_STYLE[sc.severity];
|
||||
|
||||
return (
|
||||
<Popup
|
||||
longitude={lng}
|
||||
latitude={lat}
|
||||
anchor="bottom"
|
||||
closeOnClick={false}
|
||||
onClose={() => setPopupId(null)}
|
||||
maxWidth="320px"
|
||||
className="rescue-map-popup"
|
||||
>
|
||||
<div style={{ padding: '8px 4px', minWidth: 260, background: 'var(--bg-card)', color: 'var(--fg)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
|
||||
<span style={{ fontWeight: 800, fontFamily: 'monospace', color: sev.color, fontSize: 13 }}>
|
||||
{sc.id}
|
||||
</span>
|
||||
<span style={{ fontWeight: 700, fontSize: 12 }}>{sc.timeStep}</span>
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
padding: '1px 6px',
|
||||
borderRadius: 8,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
background: sev.bg,
|
||||
color: sev.color,
|
||||
}}
|
||||
>
|
||||
{sev.label}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--fg-sub)', lineHeight: 1.5, marginBottom: 6 }}>
|
||||
{sc.description}
|
||||
</div>
|
||||
{/* KPI */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 4, marginBottom: 6 }}>
|
||||
{[
|
||||
{ label: 'GM', value: `${sc.gm}m`, color: gmColor(parseFloat(sc.gm)) },
|
||||
{ label: '횡경사', value: `${sc.list}°`, color: listColor(parseFloat(sc.list)) },
|
||||
{ label: '부력', value: `${sc.buoyancy}%`, color: buoyColor(sc.buoyancy) },
|
||||
{ label: '유출', value: sc.oilRate.split(' ')[0], color: oilColor(parseFloat(sc.oilRate)) },
|
||||
].map((m) => (
|
||||
<div
|
||||
key={m.label}
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '3px 2px',
|
||||
borderRadius: 3,
|
||||
background: 'var(--bg-base)',
|
||||
fontSize: 10,
|
||||
}}
|
||||
>
|
||||
<div style={{ color: 'var(--fg-disabled)' }}>{m.label}</div>
|
||||
<div style={{ fontWeight: 700, fontFamily: 'monospace', color: m.color }}>{m.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* 구획 상태 */}
|
||||
{sc.compartments.length > 0 && (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 600, color: 'var(--fg-disabled)', marginBottom: 3 }}>
|
||||
구획 상태
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
|
||||
{sc.compartments.map((c) => (
|
||||
<span
|
||||
key={c.name}
|
||||
style={{
|
||||
fontSize: 9,
|
||||
padding: '1px 5px',
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${c.color}40`,
|
||||
color: c.color,
|
||||
}}
|
||||
>
|
||||
{c.name}: {c.status}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
})()}
|
||||
</Map>
|
||||
|
||||
{/* 좌측 하단 — 선택된 시나리오 요약 오버레이 */}
|
||||
{selected && (
|
||||
<div
|
||||
className="absolute bottom-3 left-3 z-10 rounded-lg border border-stroke overflow-hidden"
|
||||
style={{ background: 'rgba(15,23,42,0.92)', width: 280, backdropFilter: 'blur(8px)' }}
|
||||
>
|
||||
<div className="px-3 py-2 border-b border-stroke flex items-center gap-2">
|
||||
<span className="font-bold font-mono text-color-accent text-label-2">{selected.id}</span>
|
||||
<span className="text-caption font-bold">{selected.timeStep}</span>
|
||||
<span
|
||||
className="ml-auto px-1.5 py-0.5 rounded-md text-caption font-bold"
|
||||
style={{ background: SEV_STYLE[selected.severity].bg, color: SEV_STYLE[selected.severity].color }}
|
||||
>
|
||||
{SEV_STYLE[selected.severity].label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-3 py-2">
|
||||
<div className="grid grid-cols-4 gap-1 font-mono text-caption mb-2">
|
||||
{[
|
||||
{ label: 'GM', value: `${selected.gm}m`, color: gmColor(parseFloat(selected.gm)) },
|
||||
{ label: '횡경사', value: `${selected.list}°`, color: listColor(parseFloat(selected.list)) },
|
||||
{ label: '부력', value: `${selected.buoyancy}%`, color: buoyColor(selected.buoyancy) },
|
||||
{ label: '유출', value: selected.oilRate.split(' ')[0], color: oilColor(parseFloat(selected.oilRate)) },
|
||||
].map((m) => (
|
||||
<div key={m.label} className="text-center p-1 bg-bg-base rounded">
|
||||
<div className="text-fg-disabled" style={{ fontSize: 9 }}>{m.label}</div>
|
||||
<div className="font-bold" style={{ color: m.color, fontSize: 11 }}>{m.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-caption text-fg-sub leading-relaxed" style={{ fontSize: 10 }}>
|
||||
{selected.description.slice(0, 120)}
|
||||
{selected.description.length > 120 ? '...' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 우측 상단 — 범례 */}
|
||||
<div
|
||||
className="absolute top-3 right-3 z-10 rounded-lg border border-stroke px-3 py-2"
|
||||
style={{ background: 'rgba(15,23,42,0.88)', backdropFilter: 'blur(8px)' }}
|
||||
>
|
||||
<div className="text-caption font-bold text-fg-disabled mb-1.5">시나리오 범례</div>
|
||||
{(['CRITICAL', 'HIGH', 'MEDIUM', 'RESOLVED'] as Severity[]).map((sev) => (
|
||||
<div key={sev} className="flex items-center gap-1.5 mb-0.5">
|
||||
<span
|
||||
className="inline-block rounded-full"
|
||||
style={{ width: 8, height: 8, background: SEV_COLOR[sev] }}
|
||||
/>
|
||||
<span className="text-caption text-fg-sub">{SEV_STYLE[sev].label}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-1.5 mt-1 pt-1 border-t border-stroke">
|
||||
<span
|
||||
className="inline-block rounded-full"
|
||||
style={{ width: 8, height: 8, background: 'var(--color-danger)', border: '1px solid var(--color-danger)' }}
|
||||
/>
|
||||
<span className="text-caption text-fg-sub">사고 위치</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══ 신규 시나리오 생성 모달 ═══ */
|
||||
function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: () => void }) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
import { Fragment, useState, useEffect, useCallback } from 'react';
|
||||
import { useSubMenu } from '@common/hooks/useSubMenu';
|
||||
import { MapView } from '@common/components/map/MapView';
|
||||
import { RescueTheoryView } from './RescueTheoryView';
|
||||
import { RescueScenarioView } from './RescueScenarioView';
|
||||
import { fetchRescueOps } from '../services/rescueApi';
|
||||
import type { RescueOpsItem } from '../services/rescueApi';
|
||||
import { fetchIncidentsRaw } from '@tabs/incidents/services/incidentsApi';
|
||||
import type { IncidentListItem } from '@tabs/incidents/services/incidentsApi';
|
||||
|
||||
/* ─── Types ─── */
|
||||
type AccidentType =
|
||||
@ -221,12 +224,145 @@ function TopInfoBar({ activeType }: { activeType: AccidentType }) {
|
||||
function LeftPanel({
|
||||
activeType,
|
||||
onTypeChange,
|
||||
incidents,
|
||||
selectedAcdnt,
|
||||
onSelectAcdnt,
|
||||
}: {
|
||||
activeType: AccidentType;
|
||||
onTypeChange: (t: AccidentType) => void;
|
||||
incidents: IncidentListItem[];
|
||||
selectedAcdnt: IncidentListItem | null;
|
||||
onSelectAcdnt: (item: IncidentListItem | null) => void;
|
||||
}) {
|
||||
const [acdntName, setAcdntName] = useState('');
|
||||
const [acdntDate, setAcdntDate] = useState('');
|
||||
const [acdntTime, setAcdntTime] = useState('');
|
||||
const [acdntLat, setAcdntLat] = useState('');
|
||||
const [acdntLon, setAcdntLon] = useState('');
|
||||
const [showList, setShowList] = useState(false);
|
||||
|
||||
// 사고 선택 시 필드 자동 채움
|
||||
const handlePickIncident = (item: IncidentListItem) => {
|
||||
onSelectAcdnt(item);
|
||||
setAcdntName(item.acdntNm);
|
||||
const dt = new Date(item.occrnDtm);
|
||||
setAcdntDate(
|
||||
`${dt.getFullYear()}. ${String(dt.getMonth() + 1).padStart(2, '0')}. ${String(dt.getDate()).padStart(2, '0')}.`,
|
||||
);
|
||||
setAcdntTime(
|
||||
`${dt.getHours() >= 12 ? '오후' : '오전'} ${String(dt.getHours() % 12 || 12).padStart(2, '0')}:${String(dt.getMinutes()).padStart(2, '0')}`,
|
||||
);
|
||||
setAcdntLat(String(item.lat));
|
||||
setAcdntLon(String(item.lng));
|
||||
setShowList(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-[208px] min-w-[208px] bg-bg-base border-r border-stroke flex flex-col overflow-y-auto scrollbar-thin p-2 gap-0.5">
|
||||
{/* ── 사고 기본정보 ── */}
|
||||
<div className="text-caption font-bold text-fg-disabled font-korean mb-0.5 tracking-wider">
|
||||
사고 기본정보
|
||||
</div>
|
||||
|
||||
{/* 사고명 직접 입력 */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="사고명 직접 입력"
|
||||
value={acdntName}
|
||||
onChange={(e) => {
|
||||
setAcdntName(e.target.value);
|
||||
if (selectedAcdnt) onSelectAcdnt(null);
|
||||
}}
|
||||
className="w-full px-2 py-1.5 text-caption bg-bg-card border border-stroke rounded font-korean placeholder:text-fg-disabled/50 text-fg focus:border-[rgba(6,182,212,0.5)] focus:outline-none"
|
||||
/>
|
||||
|
||||
{/* 또는 사고 리스트에서 선택 */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowList(!showList)}
|
||||
className="w-full px-2 py-1.5 text-caption bg-bg-card border border-stroke rounded font-korean text-left cursor-pointer hover:border-[var(--stroke-light)] flex items-center justify-between"
|
||||
>
|
||||
<span className={selectedAcdnt ? 'text-fg' : 'text-fg-disabled/50'}>
|
||||
{selectedAcdnt ? selectedAcdnt.acdntCd : '또는 사고 리스트에서 선택'}
|
||||
</span>
|
||||
<span className="text-fg-disabled text-[10px]">{showList ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{showList && (
|
||||
<div className="absolute left-0 right-0 top-full mt-0.5 z-30 bg-bg-card border border-stroke rounded shadow-lg max-h-[200px] overflow-y-auto scrollbar-thin">
|
||||
{incidents.length === 0 && (
|
||||
<div className="px-2 py-3 text-caption text-fg-disabled text-center font-korean">
|
||||
사고 데이터 없음
|
||||
</div>
|
||||
)}
|
||||
{incidents.map((item) => (
|
||||
<button
|
||||
key={item.acdntSn}
|
||||
onClick={() => handlePickIncident(item)}
|
||||
className="w-full text-left px-2 py-1.5 text-caption font-korean hover:bg-bg-surface cursor-pointer border-b border-stroke last:border-b-0"
|
||||
>
|
||||
<div className="text-fg font-semibold truncate">{item.acdntNm}</div>
|
||||
<div className="text-fg-disabled text-[10px]">
|
||||
{item.acdntCd} · {item.regionNm}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 사고 발생 일시 */}
|
||||
<div className="text-[10px] text-fg-disabled font-korean mt-1">사고 발생 일시</div>
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="2026. 04. 11."
|
||||
value={acdntDate}
|
||||
onChange={(e) => setAcdntDate(e.target.value)}
|
||||
className="flex-1 min-w-0 px-1.5 py-1 text-caption bg-bg-card border border-stroke rounded font-mono text-fg placeholder:text-fg-disabled/50 focus:border-[rgba(6,182,212,0.5)] focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="오후 03:42"
|
||||
value={acdntTime}
|
||||
onChange={(e) => setAcdntTime(e.target.value)}
|
||||
className="flex-1 min-w-0 px-1.5 py-1 text-caption bg-bg-card border border-stroke rounded font-mono text-fg placeholder:text-fg-disabled/50 focus:border-[rgba(6,182,212,0.5)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 위도 / 경도 */}
|
||||
<div className="flex gap-1 mt-0.5">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="위도"
|
||||
value={acdntLat}
|
||||
onChange={(e) => setAcdntLat(e.target.value)}
|
||||
className="flex-1 min-w-0 px-1.5 py-1 text-caption bg-bg-card border border-stroke rounded font-mono text-fg placeholder:text-fg-disabled/50 focus:border-[rgba(6,182,212,0.5)] focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="경도"
|
||||
value={acdntLon}
|
||||
onChange={(e) => setAcdntLon(e.target.value)}
|
||||
className="flex-1 min-w-0 px-1.5 py-1 text-caption bg-bg-card border border-stroke rounded font-mono text-fg placeholder:text-fg-disabled/50 focus:border-[rgba(6,182,212,0.5)] focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
className="shrink-0 px-1.5 py-1 text-[10px] font-bold rounded cursor-pointer"
|
||||
style={{
|
||||
background: 'rgba(239,68,68,0.15)',
|
||||
color: 'var(--color-danger)',
|
||||
border: '1px solid rgba(239,68,68,0.3)',
|
||||
}}
|
||||
>
|
||||
지도
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-[10px] text-fg-disabled font-korean text-center mb-1">
|
||||
지도에서 위치를 선택하세요
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="border-t border-stroke my-1" />
|
||||
|
||||
{/* 사고유형 제목 */}
|
||||
<div className="text-caption font-bold text-fg-disabled font-korean mb-0.5 tracking-wider">
|
||||
사고 유형 (INCIDENT TYPE)
|
||||
@ -290,191 +426,6 @@ function LeftPanel({
|
||||
}
|
||||
|
||||
/* ─── 중앙 지도 영역 ─── */
|
||||
function CenterMap({ activeType }: { activeType: AccidentType }) {
|
||||
const d = rscTypeData[activeType];
|
||||
|
||||
return (
|
||||
<div className="flex-1 relative overflow-hidden bg-bg-base">
|
||||
{/* 해양 배경 그라데이션 */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(ellipse at 30% 40%, rgba(6,90,130,.25) 0%, transparent 60%), radial-gradient(ellipse at 70% 60%, rgba(8,60,100,.2) 0%, transparent 50%), linear-gradient(180deg, #0a1628, #0d1f35 50%, #091520)',
|
||||
}}
|
||||
/>
|
||||
{/* 격자 */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(rgba(30,60,100,.12) 1px, transparent 1px), linear-gradient(90deg, rgba(30,60,100,.12) 1px, transparent 1px)',
|
||||
backgroundSize: '80px 80px',
|
||||
}}
|
||||
/>
|
||||
{/* 해안선 힌트 */}
|
||||
<div
|
||||
className="absolute right-0 top-0 w-[55%] h-full"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(225deg, rgba(30,50,70,.55), rgba(20,35,50,.25) 35%, transparent 55%)',
|
||||
clipPath:
|
||||
'polygon(60% 0%, 65% 5%, 70% 12%, 72% 20%, 68% 30%, 65% 40%, 60% 50%, 55% 58%, 50% 65%, 45% 72%, 42% 80%, 48% 88%, 100% 100%, 100% 0%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 사고 해역 정보 */}
|
||||
<div className="absolute top-2.5 left-2.5 z-20 bg-[var(--dropdown-bg)] border border-stroke rounded-md px-3 py-2 backdrop-blur-sm font-mono text-caption text-fg-disabled">
|
||||
<div className="text-label-2 font-bold text-fg font-korean mb-1">사고 해역 정보</div>
|
||||
<div className="grid gap-x-1.5 gap-y-px" style={{ gridTemplateColumns: '32px 1fr' }}>
|
||||
<span>위치</span>
|
||||
<b className="text-fg">37°28'N, 126°15'E</b>
|
||||
<span>수심</span>
|
||||
<b className="text-fg">45m</b>
|
||||
<span>조류</span>
|
||||
<b className="text-fg">2.5 knots NE</b>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 선박 모형 */}
|
||||
<div className="absolute z-[15] top-[42%] left-[46%] -rotate-[15deg]">
|
||||
<div
|
||||
className="relative w-[72px] h-5"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, #4a3728, #6b4c33)',
|
||||
borderRadius: '3px 10px 10px 3px',
|
||||
border: '1px solid rgba(255,150,50,.4)',
|
||||
boxShadow: '0 0 18px rgba(255,100,0,.2)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute w-0.5 h-[7px] bg-fg-disabled rounded-[1px]"
|
||||
style={{ top: '-3px', left: '60%' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-caption text-center mt-0.5 font-mono text-[rgba(255,200,150,0.5)]">
|
||||
M/V SEA GUARDIAN
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 예측 구역 원 */}
|
||||
<div
|
||||
className="absolute z-[5] rounded-full"
|
||||
style={{
|
||||
top: '32%',
|
||||
left: '32%',
|
||||
width: '220px',
|
||||
height: '220px',
|
||||
background:
|
||||
'radial-gradient(circle, rgba(6,182,212,.07), rgba(6,182,212,.02) 50%, transparent 70%)',
|
||||
border: '1.5px dashed rgba(6,182,212,.2)',
|
||||
}}
|
||||
/>
|
||||
{/* 구역 라벨 */}
|
||||
<div className="absolute z-[6] text-caption font-korean font-semibold tracking-wider uppercase whitespace-pre-line text-[rgba(6,182,212,0.45)] top-1/2 left-[36%]">
|
||||
{d.zone.replace('\\n', '\n')}
|
||||
</div>
|
||||
|
||||
{/* SAR 자산 */}
|
||||
<div className="absolute z-10 text-caption font-mono text-[rgba(200,220,255,0.35)] top-[10%] left-[42%]">
|
||||
ETA 5 MIN ─
|
||||
</div>
|
||||
<div className="absolute z-10 text-caption font-mono text-[rgba(200,220,255,0.35)] top-[14%] left-[56%]">
|
||||
ETA 15 MIN ─
|
||||
</div>
|
||||
<div className="absolute z-[12] text-title-3 opacity-60 top-[7%] left-[52%] -rotate-[30deg]">
|
||||
🚁
|
||||
</div>
|
||||
<div className="absolute z-[12] text-caption font-mono text-[rgba(200,220,255,0.45)] top-[20%] left-[60%]">
|
||||
6M
|
||||
</div>
|
||||
<div className="absolute z-[12] text-label-2 opacity-45 top-[28%] left-[54%]">🚢</div>
|
||||
|
||||
{/* 환경 민감 구역 */}
|
||||
<div className="absolute z-10 px-3.5 py-2 rounded bg-[rgba(34,197,94,0.06)] border border-[rgba(34,197,94,0.2)] bottom-[6%] right-[6%]">
|
||||
<div className="text-caption font-bold font-serif uppercase tracking-wider leading-snug text-[rgba(34,197,94,0.55)]">
|
||||
ENVIRONMENTALLY SENSITIVE
|
||||
<br />
|
||||
AREA: AQUACULTURE FARM
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 지도 컨트롤 */}
|
||||
<div className="absolute top-2.5 right-2.5 z-20 flex flex-col gap-0.5">
|
||||
{['🗺', '🔍', '📐'].map((ico, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className="w-7 h-7 bg-[rgba(13,17,23,0.85)] border border-stroke rounded text-fg-disabled text-label-1 flex items-center justify-center cursor-pointer hover:text-fg"
|
||||
>
|
||||
{ico}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 스케일 바 */}
|
||||
<div className="absolute bottom-2.5 left-2.5 z-20 bg-[rgba(13,17,23,0.8)] border border-stroke rounded px-2.5 py-1 text-caption text-fg-disabled font-mono">
|
||||
<div
|
||||
className="w-[70px] h-0.5 mb-0.5"
|
||||
style={{ background: 'linear-gradient(90deg, #e4e8f1 50%, var(--stroke-default) 50%)' }}
|
||||
/>
|
||||
5 km · Zoom: 100%
|
||||
</div>
|
||||
|
||||
{/* 사고 유형 표시 */}
|
||||
{/* <div className="absolute bottom-2.5 right-2.5 z-20 bg-[rgba(13,17,23,0.85)] border border-stroke rounded px-3 py-1.5">
|
||||
<div className="text-caption text-fg-disabled font-korean">현재 사고 유형</div>
|
||||
<div className="text-label-2 font-bold font-korean text-color-accent">
|
||||
{at.icon} {at.label} ({at.eng})
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{/* 타임라인 시뮬레이션 컨트롤 */}
|
||||
<div className="absolute bottom-2.5 left-1/2 -translate-x-1/2 z-20 bg-[rgba(13,17,23,0.9)] border border-stroke rounded-md px-4 py-2 flex items-center gap-4 backdrop-blur-sm">
|
||||
<div className="text-caption font-bold text-fg font-korean whitespace-nowrap">TIMELINE</div>
|
||||
<div className="flex items-center gap-1.5 text-caption text-fg-disabled font-mono">
|
||||
<span>[-6h]</span>
|
||||
<span className="font-bold text-fg">[NOW]</span>
|
||||
<span>[+6H]</span>
|
||||
<span>[+12H]</span>
|
||||
<span>[+24H]</span>
|
||||
</div>
|
||||
<div className="relative w-24 h-1.5 bg-bg-surface-hover rounded-sm">
|
||||
<div
|
||||
className="absolute rounded-full border-2 border-bg-0 bg-color-accent"
|
||||
style={{
|
||||
left: '35%',
|
||||
top: '-3px',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
boxShadow: '0 0 8px rgba(6,182,212,.4)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button className="w-6 h-6 bg-bg-card border border-stroke rounded-full text-fg-disabled text-label-2 flex items-center justify-center cursor-pointer hover:text-fg">
|
||||
⏮
|
||||
</button>
|
||||
<button
|
||||
className="w-8 h-8 rounded-full text-color-accent text-title-4 flex items-center justify-center cursor-pointer hover:brightness-125"
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--color-accent) 15%, transparent)',
|
||||
border: '1px solid color-mix(in srgb, var(--color-accent) 30%, transparent)',
|
||||
}}
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
<button className="w-6 h-6 bg-bg-card border border-stroke rounded-full text-fg-disabled text-label-2 flex items-center justify-center cursor-pointer hover:text-fg">
|
||||
⏭
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-caption text-fg-disabled font-mono whitespace-nowrap">
|
||||
<b className="text-color-accent">10:45</b> KST
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── 오른쪽 분석 패널 ─── */
|
||||
function RightPanel({
|
||||
activeAnalysis,
|
||||
@ -1572,6 +1523,44 @@ export function RescueView() {
|
||||
const { activeSubTab } = useSubMenu('rescue');
|
||||
const [activeType, setActiveType] = useState<AccidentType>('collision');
|
||||
const [activeAnalysis, setActiveAnalysis] = useState<AnalysisTab>('rescue');
|
||||
const [incidents, setIncidents] = useState<IncidentListItem[]>([]);
|
||||
const [selectedAcdnt, setSelectedAcdnt] = useState<IncidentListItem | null>(null);
|
||||
const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null);
|
||||
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchIncidentsRaw()
|
||||
.then((items) => setIncidents(items))
|
||||
.catch(() => setIncidents([]));
|
||||
}, []);
|
||||
|
||||
// 지도 클릭 시 좌표 선택
|
||||
const handleMapClick = useCallback((lon: number, lat: number) => {
|
||||
setIncidentCoord({ lon, lat });
|
||||
setIsSelectingLocation(false);
|
||||
}, []);
|
||||
|
||||
// 사고 선택 시 사고유형 자동 매핑
|
||||
const handleSelectAcdnt = useCallback(
|
||||
(item: IncidentListItem | null) => {
|
||||
setSelectedAcdnt(item);
|
||||
if (item) {
|
||||
const typeMap: Record<string, AccidentType> = {
|
||||
collision: 'collision',
|
||||
grounding: 'grounding',
|
||||
turning: 'turning',
|
||||
capsizing: 'capsizing',
|
||||
sharpTurn: 'sharpTurn',
|
||||
flooding: 'flooding',
|
||||
sinking: 'sinking',
|
||||
};
|
||||
const mapped = typeMap[item.acdntTpCd];
|
||||
if (mapped) setActiveType(mapped);
|
||||
setIncidentCoord({ lon: item.lng, lat: item.lat });
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (activeSubTab === 'list') {
|
||||
return (
|
||||
@ -1596,8 +1585,23 @@ export function RescueView() {
|
||||
|
||||
{/* 3단 레이아웃: 사고유형 | 지도 | 분석 패널 */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<LeftPanel activeType={activeType} onTypeChange={setActiveType} />
|
||||
<CenterMap activeType={activeType} />
|
||||
<LeftPanel
|
||||
activeType={activeType}
|
||||
onTypeChange={setActiveType}
|
||||
incidents={incidents}
|
||||
selectedAcdnt={selectedAcdnt}
|
||||
onSelectAcdnt={handleSelectAcdnt}
|
||||
/>
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
<MapView
|
||||
incidentCoord={incidentCoord ?? undefined}
|
||||
isSelectingLocation={isSelectingLocation}
|
||||
onMapClick={handleMapClick}
|
||||
oilTrajectory={[]}
|
||||
enabledLayers={new Set()}
|
||||
showOverlays={false}
|
||||
/>
|
||||
</div>
|
||||
<RightPanel
|
||||
activeAnalysis={activeAnalysis}
|
||||
onAnalysisChange={setActiveAnalysis}
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user