diff --git a/.claude/workflow-version.json b/.claude/workflow-version.json index a9c8b08..839d5f4 100644 --- a/.claude/workflow-version.json +++ b/.claude/workflow-version.json @@ -1,6 +1,6 @@ { "applied_global_version": "1.6.1", - "applied_date": "2026-03-24", + "applied_date": "2026-03-25", "project_type": "react-ts", "gitea_url": "https://gitea.gc-si.dev", "custom_pre_commit": true diff --git a/backend/src/monitor/monitorRouter.ts b/backend/src/monitor/monitorRouter.ts new file mode 100644 index 0000000..700be1e --- /dev/null +++ b/backend/src/monitor/monitorRouter.ts @@ -0,0 +1,20 @@ +import { Router } from 'express' +import { requireAuth } from '../auth/authMiddleware.js' +import { getNumericalDataStatus } from './monitorService.js' + +const router = Router() + +router.use(requireAuth) + +// GET /api/monitor/numerical — 수치예측자료 다운로드 상태 조회 +router.get('/numerical', async (_req, res) => { + try { + const data = await getNumericalDataStatus() + res.json(data) + } catch (err) { + console.error('[monitor] 수치예측자료 상태 조회 오류:', err) + res.status(500).json({ error: '수치예측자료 상태를 조회할 수 없습니다.' }) + } +}) + +export default router diff --git a/backend/src/monitor/monitorService.ts b/backend/src/monitor/monitorService.ts new file mode 100644 index 0000000..29adff8 --- /dev/null +++ b/backend/src/monitor/monitorService.ts @@ -0,0 +1,121 @@ +export interface NumericalDataStatus { + modelName: string; + jobName: string; + lastStatus: 'COMPLETED' | 'FAILED' | 'STARTED' | 'UNKNOWN'; + lastDataDate: string | null; // 데이터 기준일 (YYYY-MM-DD) + lastDownloadedAt: string | null; // 마지막 실행 완료 시각 (ISO) + nextScheduledAt: string | null; // Quartz 다음 예정 시각 (ISO) + durationSec: number | null; // 소요 시간 (초) + consecutiveFailures: number; // 연속 실패 횟수 +} + +// ============================================================ +// Mock 데이터 (Spring Batch/Quartz DB 연동 전) +// DB 연동 준비 완료 후 getMockNumericalDataStatus → getActualNumericalDataStatus 교체 +// ============================================================ +const MOCK_DATA: NumericalDataStatus[] = [ + { + modelName: 'HYCOM', + jobName: 'downloadHycomJob', + lastStatus: 'COMPLETED', + lastDataDate: '2026-03-25', + lastDownloadedAt: '2026-03-25T06:12:34', + nextScheduledAt: '2026-03-25T12:00:00', + durationSec: 342, + consecutiveFailures: 0, + }, + { + modelName: 'GFS', + jobName: 'downloadGfsJob', + lastStatus: 'COMPLETED', + lastDataDate: '2026-03-25', + lastDownloadedAt: '2026-03-25T06:48:11', + nextScheduledAt: '2026-03-25T12:00:00', + durationSec: 518, + consecutiveFailures: 0, + }, + { + modelName: 'WW3', + jobName: 'downloadWw3Job', + lastStatus: 'FAILED', + lastDataDate: '2026-03-24', + lastDownloadedAt: '2026-03-25T07:03:55', + nextScheduledAt: '2026-03-25T13:00:00', + durationSec: null, + consecutiveFailures: 2, + }, + { + modelName: 'KOAST POS_WIND', + jobName: 'downloadKoastWindJob', + lastStatus: 'COMPLETED', + lastDataDate: '2026-03-25', + lastDownloadedAt: '2026-03-25T07:21:05', + nextScheduledAt: '2026-03-25T13:00:00', + durationSec: 127, + consecutiveFailures: 0, + }, + { + modelName: 'KOAST POS_HYDR', + jobName: 'downloadKoastHydrJob', + lastStatus: 'COMPLETED', + lastDataDate: '2026-03-25', + lastDownloadedAt: '2026-03-25T07:35:48', + nextScheduledAt: '2026-03-25T13:00:00', + durationSec: 183, + consecutiveFailures: 0, + }, + { + modelName: 'KOAST POS_WAVE', + jobName: 'downloadKoastWaveJob', + lastStatus: 'COMPLETED', + lastDataDate: '2026-03-25', + lastDownloadedAt: '2026-03-25T07:52:19', + nextScheduledAt: '2026-03-25T13:00:00', + durationSec: 156, + consecutiveFailures: 0, + }, +]; + +export async function getNumericalDataStatus(): Promise { + // TODO: Spring Batch + Quartz DB 테이블 생성 후 아래 실제 쿼리로 교체 + // + // import { wingDb } from '../db/wingDb.js' + // + // -- 각 Job의 최신 실행 결과 조회 (BATCH_JOB_EXECUTION) + // SELECT + // ji.JOB_NAME, + // je.START_TIME, je.END_TIME, + // je.STATUS, je.EXIT_CODE, je.EXIT_MESSAGE, + // jep.STRING_VAL AS data_date, + // EXTRACT(EPOCH FROM (je.END_TIME - je.START_TIME))::INT AS duration_sec + // FROM BATCH_JOB_EXECUTION je + // JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID + // LEFT JOIN BATCH_JOB_EXECUTION_PARAMS jep + // ON je.JOB_EXECUTION_ID = jep.JOB_EXECUTION_ID + // AND jep.KEY_NAME = 'data_date' + // WHERE je.JOB_EXECUTION_ID IN ( + // SELECT MAX(je2.JOB_EXECUTION_ID) + // FROM BATCH_JOB_EXECUTION je2 + // GROUP BY je2.JOB_INSTANCE_ID + // ) + // ORDER BY je.START_TIME DESC; + // + // -- Quartz 다음 실행 예정 시각 (NEXT_FIRE_TIME은 epoch milliseconds) + // SELECT JOB_NAME, to_timestamp(NEXT_FIRE_TIME / 1000) AS next_fire_time + // FROM QRTZ_TRIGGERS; + // + // -- 연속 실패 횟수 집계 (최근 실행부터 COMPLETED 전까지 카운트) + // SELECT ji.JOB_NAME, COUNT(*) AS consecutive_failures + // FROM BATCH_JOB_EXECUTION je + // JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID + // WHERE je.STATUS = 'FAILED' + // AND je.JOB_EXECUTION_ID > ( + // SELECT COALESCE(MAX(je2.JOB_EXECUTION_ID), 0) + // FROM BATCH_JOB_EXECUTION je2 + // WHERE je2.JOB_INSTANCE_ID = je.JOB_INSTANCE_ID + // AND je2.STATUS = 'COMPLETED' + // ) + // GROUP BY ji.JOB_NAME; + + return MOCK_DATA; +} diff --git a/backend/src/prediction/predictionRouter.ts b/backend/src/prediction/predictionRouter.ts index a2111b1..6c0e562 100644 --- a/backend/src/prediction/predictionRouter.ts +++ b/backend/src/prediction/predictionRouter.ts @@ -46,7 +46,7 @@ router.get('/analyses/:acdntSn', requireAuth, requirePermission('prediction', 'R } }); -// GET /api/prediction/analyses/:acdntSn/trajectory — 최신 OpenDrift 결과 조회 +// GET /api/prediction/analyses/:acdntSn/trajectory — 예측 결과 조회 (predRunSn으로 특정 실행 지정 가능) router.get('/analyses/:acdntSn/trajectory', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => { try { const acdntSn = parseInt(req.params.acdntSn as string, 10); @@ -54,7 +54,8 @@ router.get('/analyses/:acdntSn/trajectory', requireAuth, requirePermission('pred res.status(400).json({ error: '유효하지 않은 사고 번호' }); return; } - const result = await getAnalysisTrajectory(acdntSn); + const predRunSn = req.query.predRunSn ? parseInt(req.query.predRunSn as string, 10) : undefined; + const result = await getAnalysisTrajectory(acdntSn, predRunSn); if (!result) { res.json({ trajectory: null, summary: null }); return; diff --git a/backend/src/prediction/predictionService.ts b/backend/src/prediction/predictionService.ts index 833304e..649c92d 100644 --- a/backend/src/prediction/predictionService.ts +++ b/backend/src/prediction/predictionService.ts @@ -19,6 +19,8 @@ interface PredictionAnalysis { analyst: string; officeName: string; acdntSttsCd: string; + predRunSn: number | null; + runDtm: string | null; } interface PredictionDetail { @@ -142,21 +144,26 @@ export async function listAnalyses(input: ListAnalysesInput): Promise { +export async function getAnalysisTrajectory(acdntSn: number, predRunSn?: number): Promise { // 완료된 모든 모델(OPENDRIFT, POSEIDON) 결과 조회 - const sql = ` - SELECT ALGO_CD, RSLT_DATA, CMPL_DTM FROM wing.PRED_EXEC - WHERE ACDNT_SN = $1 - AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON') - AND EXEC_STTS_CD = 'COMPLETED' - ORDER BY CMPL_DTM DESC - `; - const { rows } = await wingPool.query(sql, [acdntSn]); + // predRunSn이 있으면 해당 실행의 결과만, 없으면 최신 결과 + const sql = predRunSn != null + ? ` + SELECT ALGO_CD, RSLT_DATA, CMPL_DTM FROM wing.PRED_EXEC + WHERE ACDNT_SN = $1 + AND PRED_RUN_SN = $2 + AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON') + AND EXEC_STTS_CD = 'COMPLETED' + ORDER BY CMPL_DTM DESC + ` + : ` + SELECT ALGO_CD, RSLT_DATA, CMPL_DTM FROM wing.PRED_EXEC + WHERE ACDNT_SN = $1 + AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON') + AND EXEC_STTS_CD = 'COMPLETED' + ORDER BY CMPL_DTM DESC + `; + const params = predRunSn != null ? [acdntSn, predRunSn] : [acdntSn]; + const { rows } = await wingPool.query(sql, params); if (rows.length === 0) return null; // 모든 모델의 파티클을 하나의 배열로 병합 diff --git a/backend/src/routes/simulation.ts b/backend/src/routes/simulation.ts index 2eea5bd..8c4aaf4 100755 --- a/backend/src/routes/simulation.ts +++ b/backend/src/routes/simulation.ts @@ -210,14 +210,21 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => { if (resolvedAcdntSn && !resolvedSpilDataSn) { try { const spilRes = await wingPool.query( - `SELECT SPIL_DATA_SN FROM wing.SPIL_DATA WHERE ACDNT_SN = $1 ORDER BY SPIL_DATA_SN DESC LIMIT 1`, - [resolvedAcdntSn] + `INSERT INTO wing.SPIL_DATA (ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, REG_DTM) + VALUES ($1, $2, $3, $4, $5, $6, NOW()) + RETURNING SPIL_DATA_SN`, + [ + resolvedAcdntSn, + OIL_DB_CODE_MAP[matTy as string] ?? 'BUNKER_C', + matVol ?? 0, + UNIT_MAP[spillUnit as string] ?? 'KL', + SPIL_TYPE_MAP[spillTypeCd as string] ?? 'CONTINUOUS', + runTime, + ] ) - if (spilRes.rows.length > 0) { - resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number - } + resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number } catch (dbErr) { - console.error('[simulation] SPIL_DATA 조회 실패:', dbErr) + console.error('[simulation] SPIL_DATA INSERT 실패:', dbErr) } } @@ -545,30 +552,47 @@ router.post('/run-model', requireAuth, async (req: Request, res: Response) => { if (resolvedAcdntSn && !resolvedSpilDataSn) { try { const spilRes = await wingPool.query( - `SELECT SPIL_DATA_SN FROM wing.SPIL_DATA WHERE ACDNT_SN = $1 ORDER BY SPIL_DATA_SN DESC LIMIT 1`, - [resolvedAcdntSn] + `INSERT INTO wing.SPIL_DATA (ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, REG_DTM) + VALUES ($1, $2, $3, $4, $5, $6, NOW()) + RETURNING SPIL_DATA_SN`, + [ + resolvedAcdntSn, + OIL_DB_CODE_MAP[matTy as string] ?? 'BUNKER_C', + matVol ?? 0, + UNIT_MAP[spillUnit as string] ?? 'KL', + SPIL_TYPE_MAP[spillTypeCd as string] ?? 'CONTINUOUS', + runTime, + ] ) - if (spilRes.rows.length > 0) { - resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number - } + resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number } catch (dbErr) { - console.error('[simulation/run-model] SPIL_DATA 조회 실패:', dbErr) + console.error('[simulation/run-model] SPIL_DATA INSERT 실패:', dbErr) } } const odMatTy = matTy !== undefined ? (OIL_TYPE_MAP[matTy as string] ?? (matTy as string)) : undefined const execNmBase = `EXPC_${Date.now()}` + // 이번 예측 실행을 식별하는 그룹 SN 생성 + let predRunSn: number + try { + const runSnRes = await wingPool.query("SELECT nextval('wing.PRED_RUN_SN_SEQ') AS pred_run_sn") + predRunSn = runSnRes.rows[0].pred_run_sn as number + } catch (dbErr) { + console.error('[simulation/run-model] PRED_RUN_SN_SEQ 조회 실패:', dbErr) + return res.status(500).json({ error: '실행 SN 생성 실패' }) + } + // KOSPS: PRED_EXEC INSERT(PENDING)만 수행 const execSns: Array<{ model: string; execSn: number }> = [] if (requestedModels.includes('KOSPS')) { try { const kospsExecNm = `${execNmBase}_KOSPS` const insertRes = await wingPool.query( - `INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, BGNG_DTM) - VALUES ($1, $2, 'KOSPS', 'PENDING', $3, NOW()) + `INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, PRED_RUN_SN, BGNG_DTM) + VALUES ($1, $2, 'KOSPS', 'PENDING', $3, $4, NOW()) RETURNING PRED_EXEC_SN`, - [resolvedAcdntSn, resolvedSpilDataSn, kospsExecNm] + [resolvedAcdntSn, resolvedSpilDataSn, kospsExecNm, predRunSn] ) execSns.push({ model: 'KOSPS', execSn: insertRes.rows[0].pred_exec_sn as number }) } catch (dbErr) { @@ -602,10 +626,10 @@ router.post('/run-model', requireAuth, async (req: Request, res: Response) => { let predExecSn: number try { const insertRes = await wingPool.query( - `INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, BGNG_DTM) - VALUES ($1, $2, $3, 'PENDING', $4, NOW()) + `INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, PRED_RUN_SN, BGNG_DTM) + VALUES ($1, $2, $3, 'PENDING', $4, $5, NOW()) RETURNING PRED_EXEC_SN`, - [resolvedAcdntSn, resolvedSpilDataSn, algoCd, execNm] + [resolvedAcdntSn, resolvedSpilDataSn, algoCd, execNm, predRunSn] ) predExecSn = insertRes.rows[0].pred_exec_sn as number } catch (dbErr) { @@ -713,6 +737,7 @@ router.post('/run-model', requireAuth, async (req: Request, res: Response) => { res.json({ success: true, acdntSn: resolvedAcdntSn, + predRunSn, execSns: [...execSns, ...modelResults.map(({ model, execSn }) => ({ model, execSn }))], results: modelResults, }) diff --git a/backend/src/server.ts b/backend/src/server.ts index 8e48bdb..cf7e6a1 100755 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -23,6 +23,7 @@ import predictionRouter from './prediction/predictionRouter.js' import aerialRouter from './aerial/aerialRouter.js' import rescueRouter from './rescue/rescueRouter.js' import mapBaseRouter from './map-base/mapBaseRouter.js' +import monitorRouter from './monitor/monitorRouter.js' import { sanitizeBody, sanitizeQuery, @@ -170,6 +171,7 @@ app.use('/api/prediction', predictionRouter) app.use('/api/aerial', aerialRouter) app.use('/api/rescue', rescueRouter) app.use('/api/map-base', mapBaseRouter) +app.use('/api/monitor', monitorRouter) // 헬스 체크 app.get('/health', (_req, res) => { diff --git a/database/migration/028_pred_run_sn.sql b/database/migration/028_pred_run_sn.sql new file mode 100644 index 0000000..9ce7c67 --- /dev/null +++ b/database/migration/028_pred_run_sn.sql @@ -0,0 +1,25 @@ +-- Migration 028: PRED_EXEC에 실행 그룹 식별자(PRED_RUN_SN) 추가 +-- 같은 시점에 여러 모델로 실행된 PRED_EXEC 레코드를 하나의 "예측 실행"으로 묶는다. +-- 목록 화면에서 사고당 예측 실행 횟수만큼 행을 표시하기 위한 기반 구조. + +-- 1. 컬럼 추가 +ALTER TABLE wing.PRED_EXEC ADD COLUMN IF NOT EXISTS PRED_RUN_SN INTEGER; + +-- 2. 기존 데이터 마이그레이션 +-- 같은 ACDNT_SN + 시작 시각 60초 이내의 레코드를 동일 실행 그룹으로 묶는다. +-- MIN(PRED_EXEC_SN)을 그룹 대표 키로 사용한다. +UPDATE wing.PRED_EXEC pe1 +SET PRED_RUN_SN = ( + SELECT MIN(pe2.PRED_EXEC_SN) + FROM wing.PRED_EXEC pe2 + WHERE pe2.ACDNT_SN = pe1.ACDNT_SN + AND ABS(EXTRACT(EPOCH FROM ( + COALESCE(pe2.BGNG_DTM, NOW()) - COALESCE(pe1.BGNG_DTM, NOW()) + ))) < 60 +) +WHERE pe1.PRED_RUN_SN IS NULL; + +-- 3. 시퀀스 생성 (신규 실행용 — 기존 최대값보다 충분히 높은 값에서 시작) +CREATE SEQUENCE IF NOT EXISTS wing.PRED_RUN_SN_SEQ + START WITH 10000 + INCREMENT BY 1; diff --git a/frontend/src/tabs/admin/components/AdminView.tsx b/frontend/src/tabs/admin/components/AdminView.tsx index f39a80b..979471b 100755 --- a/frontend/src/tabs/admin/components/AdminView.tsx +++ b/frontend/src/tabs/admin/components/AdminView.tsx @@ -15,6 +15,7 @@ import LayerPanel from './LayerPanel'; import SensitiveLayerPanel from './SensitiveLayerPanel'; import DispersingZonePanel from './DispersingZonePanel'; import MonitorRealtimePanel from './MonitorRealtimePanel'; +import MonitorForecastPanel from './MonitorForecastPanel'; import VesselMaterialsPanel from './VesselMaterialsPanel'; /** 기존 패널이 있는 메뉴 ID 매핑 */ @@ -36,6 +37,7 @@ const PANEL_MAP: Record JSX.Element> = { 'dispersant-zone': () => , 'vessel-materials': () => , 'monitor-realtime': () => , + 'monitor-forecast': () => , }; export function AdminView() { diff --git a/frontend/src/tabs/admin/components/MonitorForecastPanel.tsx b/frontend/src/tabs/admin/components/MonitorForecastPanel.tsx new file mode 100644 index 0000000..47d4841 --- /dev/null +++ b/frontend/src/tabs/admin/components/MonitorForecastPanel.tsx @@ -0,0 +1,273 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + getNumericalDataStatus, + type NumericalDataStatus, +} from '../services/monitorApi'; + +type TabId = 'all' | 'ocean' | 'koast'; + +const TABS: { id: TabId; label: string }[] = [ + { id: 'all', label: '전체' }, + { id: 'ocean', label: '기상·해양 모델' }, + { id: 'koast', label: 'KOAST' }, +]; + +const OCEAN_MODELS = ['HYCOM', 'GFS', 'WW3']; +const KOAST_MODELS = ['KOAST POS_WIND', 'KOAST POS_HYDR', 'KOAST POS_WAVE']; + +function filterByTab(rows: NumericalDataStatus[], tab: TabId): NumericalDataStatus[] { + if (tab === 'ocean') return rows.filter((r) => OCEAN_MODELS.includes(r.modelName)); + if (tab === 'koast') return rows.filter((r) => KOAST_MODELS.includes(r.modelName)); + return rows; +} + +function formatDuration(sec: number | null): string { + if (sec == null) return '-'; + const m = Math.floor(sec / 60); + const s = sec % 60; + return m > 0 ? `${m}분 ${s}초` : `${s}초`; +} + +function formatDatetime(iso: string | null): string { + if (!iso) return '-'; + const d = new Date(iso); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + const hh = String(d.getHours()).padStart(2, '0'); + const min = String(d.getMinutes()).padStart(2, '0'); + return `${mm}-${dd} ${hh}:${min}`; +} + +function formatTime(iso: string | null): string { + if (!iso) return '-'; + const d = new Date(iso); + const hh = String(d.getHours()).padStart(2, '0'); + const min = String(d.getMinutes()).padStart(2, '0'); + return `${hh}:${min}`; +} + +function StatusCell({ row }: { row: NumericalDataStatus }) { + if (row.lastStatus === 'COMPLETED') { + return 정상; + } + if (row.lastStatus === 'FAILED') { + return ( + + 오류{row.consecutiveFailures > 0 ? ` (${row.consecutiveFailures}회 연속)` : ''} + + ); + } + if (row.lastStatus === 'STARTED') { + return ( + + + 실행 중 + + ); + } + return -; +} + +function StatusBadge({ + loading, + errorCount, + total, +}: { + loading: boolean; + errorCount: number; + total: number; +}) { + if (loading) { + return ( + + + 조회 중... + + ); + } + if (errorCount === total && total > 0) { + return ( + + + 연계 오류 + + ); + } + if (errorCount > 0) { + return ( + + + 일부 오류 ({errorCount}/{total}) + + ); + } + return ( + + + 정상 + + ); +} + +const TABLE_HEADERS = [ + '모델명', + '데이터 기준일', + '마지막 다운로드', + '상태', + '소요 시간', + '다음 예정', +]; + +function ForecastTable({ + rows, + loading, +}: { + rows: NumericalDataStatus[]; + loading: boolean; +}) { + return ( +
+ + + + {TABLE_HEADERS.map((h) => ( + + ))} + + + + {loading && rows.length === 0 + ? Array.from({ length: 6 }).map((_, i) => ( + + {TABLE_HEADERS.map((_, j) => ( + + ))} + + )) + : rows.map((row) => ( + + + + + + + + + ))} + +
+ {h} +
+
+
+ {row.modelName} + {row.lastDataDate ?? '-'}{formatDatetime(row.lastDownloadedAt)} + + {formatDuration(row.durationSec)}{formatTime(row.nextScheduledAt)}
+
+ ); +} + +export default function MonitorForecastPanel() { + const [activeTab, setActiveTab] = useState('all'); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [lastUpdate, setLastUpdate] = useState(null); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const data = await getNumericalDataStatus(); + setRows(data); + setLastUpdate(new Date()); + } catch { + // 오류 시 기존 데이터 유지 + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void fetchData(); + }, [fetchData]); + + const visibleRows = filterByTab(rows, activeTab); + const errorCount = visibleRows.filter( + (r) => r.lastStatus === 'FAILED' + ).length; + const totalCount = visibleRows.length; + + return ( +
+ {/* 헤더 */} +
+

수치예측자료 모니터링

+
+ {lastUpdate && ( + + 갱신:{' '} + {lastUpdate.toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + })} + + )} + +
+
+ + {/* 탭 */} +
+ {TABS.map((tab) => ( + + ))} +
+ + {/* 상태 표시줄 */} +
+ + {!loading && totalCount > 0 && ( + 모델 {totalCount}개 + )} +
+ + {/* 테이블 */} +
+ +
+
+ ); +} diff --git a/frontend/src/tabs/admin/services/monitorApi.ts b/frontend/src/tabs/admin/services/monitorApi.ts new file mode 100644 index 0000000..3ad9c23 --- /dev/null +++ b/frontend/src/tabs/admin/services/monitorApi.ts @@ -0,0 +1,17 @@ +import { api } from '@common/services/api'; + +export interface NumericalDataStatus { + modelName: string; + jobName: string; + lastStatus: 'COMPLETED' | 'FAILED' | 'STARTED' | 'UNKNOWN'; + lastDataDate: string | null; + lastDownloadedAt: string | null; + nextScheduledAt: string | null; + durationSec: number | null; + consecutiveFailures: number; +} + +export async function getNumericalDataStatus(): Promise { + const res = await api.get('/monitor/numerical'); + return res.data; +} diff --git a/frontend/src/tabs/prediction/components/AnalysisListTable.tsx b/frontend/src/tabs/prediction/components/AnalysisListTable.tsx index 2341958..5fb41c3 100755 --- a/frontend/src/tabs/prediction/components/AnalysisListTable.tsx +++ b/frontend/src/tabs/prediction/components/AnalysisListTable.tsx @@ -153,6 +153,7 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis 번호 사고명 사고일시 + 예측 실행 예측시간 유종 유출량 @@ -167,7 +168,7 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis {currentAnalyses.map((analysis) => ( {analysis.acdntSn} @@ -188,6 +189,7 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis {analysis.occurredAt ? new Date(analysis.occurredAt).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'} + {analysis.runDtm ? new Date(analysis.runDtm).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'} {analysis.duration} {analysis.oilType} diff --git a/frontend/src/tabs/prediction/components/OilSpillView.tsx b/frontend/src/tabs/prediction/components/OilSpillView.tsx index d8d5196..cc84007 100755 --- a/frontend/src/tabs/prediction/components/OilSpillView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx @@ -519,7 +519,7 @@ export function OilSpillView() { analysis.opendriftStatus === 'completed' || analysis.poseidonStatus === 'completed'; if (hasCompletedModel) { try { - const { trajectory, summary, centerPoints: cp, windDataByModel: wdByModel, hydrDataByModel: hdByModel, summaryByModel: sbModel, stepSummariesByModel: stepSbModel } = await fetchAnalysisTrajectory(analysis.acdntSn) + const { trajectory, summary, centerPoints: cp, windDataByModel: wdByModel, hydrDataByModel: hdByModel, summaryByModel: sbModel, stepSummariesByModel: stepSbModel } = await fetchAnalysisTrajectory(analysis.acdntSn, analysis.predRunSn ?? undefined) if (trajectory && trajectory.length > 0) { setOilTrajectory(trajectory) if (summary) setSimulationSummary(summary) @@ -1264,7 +1264,13 @@ export function OilSpillView() { {activeSubTab === 'analysis' && ( setRecalcModalOpen(true)} + onOpenRecalc={() => { + if (!selectedAnalysis) { + alert('선택된 사고가 없습니다.\n분석 목록에서 사고를 선택해주세요.'); + return; + } + setRecalcModalOpen(true); + }} onOpenReport={handleOpenReport} detail={analysisDetail} summary={stepSummariesByModel[windHydrModel]?.[currentStep] ?? summaryByModel[windHydrModel] ?? simulationSummary} @@ -1312,6 +1318,7 @@ export function OilSpillView() { setRecalcModalOpen(false)} + incidentName={selectedAnalysis?.acdntNm || incidentName} oilType={oilType} spillAmount={spillAmount} spillType={spillType} diff --git a/frontend/src/tabs/prediction/components/RecalcModal.tsx b/frontend/src/tabs/prediction/components/RecalcModal.tsx index 080079d..0b81783 100755 --- a/frontend/src/tabs/prediction/components/RecalcModal.tsx +++ b/frontend/src/tabs/prediction/components/RecalcModal.tsx @@ -4,6 +4,7 @@ import type { PredictionModel } from './OilSpillView' interface RecalcModalProps { isOpen: boolean onClose: () => void + incidentName: string oilType: string spillAmount: number spillType: string @@ -24,11 +25,14 @@ type RecalcPhase = 'editing' | 'running' | 'done' const OIL_TYPES = ['벙커C유', '원유(중질)', '원유(경질)', '디젤유(경유)', '휘발유', '등유', '윤활유', 'HFO 380', 'HFO 180'] const SPILL_TYPES = ['연속', '순간', '점진적'] -const PREDICTION_TIMES = [12, 24, 48, 72, 96, 120] +const PREDICTION_TIMES = [6, 12, 24, 48, 72, 96, 120] +const snapToValidTime = (t: number): number => + PREDICTION_TIMES.includes(t) ? t : (PREDICTION_TIMES.find(h => h >= t) ?? PREDICTION_TIMES[0]) export function RecalcModal({ isOpen, onClose, + incidentName, oilType: initOilType, spillAmount: initSpillAmount, spillType: initSpillType, @@ -43,7 +47,7 @@ export function RecalcModal({ const [spillAmount, setSpillAmount] = useState(initSpillAmount) const [spillUnit, setSpillUnit] = useState<'kl' | 'ton' | 'bbl'>('kl') const [spillType, setSpillType] = useState(initSpillType) - const [predictionTime, setPredictionTime] = useState(initPredictionTime) + const [predictionTime, setPredictionTime] = useState(() => snapToValidTime(initPredictionTime)) const [lat, setLat] = useState(initCoord.lat) const [lon, setLon] = useState(initCoord.lon) const [models, setModels] = useState>(new Set(initModels)) @@ -56,7 +60,7 @@ export function RecalcModal({ setOilType(initOilType) setSpillAmount(initSpillAmount) setSpillType(initSpillType) - setPredictionTime(initPredictionTime) + setPredictionTime(snapToValidTime(initPredictionTime)) setLat(initCoord.lat) setLon(initCoord.lon) setModels(new Set(initModels)) @@ -163,7 +167,7 @@ export function RecalcModal({ 현재 분석 정보
- + @@ -265,30 +269,26 @@ export function RecalcModal({
{([ - { model: 'KOSPS' as PredictionModel, color: '#3b82f6' }, - { model: 'POSEIDON' as PredictionModel, color: '#22c55e' }, - { model: 'OpenDrift' as PredictionModel, color: '#f97316' }, - ]).map(({ model, color }) => ( + { model: 'KOSPS' as PredictionModel, color: 'var(--cyan)', ready: false }, + { model: 'POSEIDON' as PredictionModel, color: 'var(--red)', ready: true }, + { model: 'OpenDrift' as PredictionModel, color: 'var(--blue)', ready: true }, + ]).map(({ model, color, ready }) => ( ))} -
diff --git a/frontend/src/tabs/prediction/components/RightPanel.tsx b/frontend/src/tabs/prediction/components/RightPanel.tsx index 2094c32..3f93d4d 100755 --- a/frontend/src/tabs/prediction/components/RightPanel.tsx +++ b/frontend/src/tabs/prediction/components/RightPanel.tsx @@ -63,10 +63,11 @@ export function RightPanel({ const [insuranceExpanded, setInsuranceExpanded] = useState(false) const weatheringStatus = useMemo(() => { - if (!summary) return null; + const zero = { surface: 0, evaporation: 0, dispersion: 0, boom: 0, beached: 0 }; + if (!summary) return zero; const total = summary.remainingVolume + summary.evaporationVolume + summary.dispersionVolume + summary.beachedVolume + boomBlockedVolume; - if (total <= 0) return null; + if (total <= 0) return zero; const pct = (v: number) => Math.round((v / total) * 100); return { surface: pct(summary.remainingVolume), @@ -288,17 +289,13 @@ export function RightPanel({ {/* 유출유 풍화 상태 */}
- {weatheringStatus ? ( - <> - - - - - - - ) : ( -

시뮬레이션 실행 후 표시됩니다

- )} + <> + + + + + +
diff --git a/frontend/src/tabs/prediction/services/predictionApi.ts b/frontend/src/tabs/prediction/services/predictionApi.ts index 0d1c12b..025c2a1 100644 --- a/frontend/src/tabs/prediction/services/predictionApi.ts +++ b/frontend/src/tabs/prediction/services/predictionApi.ts @@ -19,6 +19,8 @@ export interface PredictionAnalysis { analyst: string; officeName: string; acdntSttsCd: string; + predRunSn: number | null; + runDtm: string | null; } export interface PredictionDetail { @@ -216,8 +218,11 @@ export interface TrajectoryResponse { stepSummariesByModel?: Record; } -export const fetchAnalysisTrajectory = async (acdntSn: number): Promise => { - const response = await api.get(`/prediction/analyses/${acdntSn}/trajectory`); +export const fetchAnalysisTrajectory = async (acdntSn: number, predRunSn?: number): Promise => { + const response = await api.get( + `/prediction/analyses/${acdntSn}/trajectory`, + predRunSn != null ? { params: { predRunSn } } : undefined, + ); return response.data; }; diff --git a/frontend/src/tabs/reports/components/ReportGenerator.tsx b/frontend/src/tabs/reports/components/ReportGenerator.tsx index fb0cbc1..ca2eb70 100644 --- a/frontend/src/tabs/reports/components/ReportGenerator.tsx +++ b/frontend/src/tabs/reports/components/ReportGenerator.tsx @@ -68,7 +68,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) { const handleSave = async () => { const report = createEmptyReport() - report.reportType = activeCat === 0 ? '예측보고서' : activeCat === 1 ? '종합보고서' : '초기보고서' + report.reportType = activeCat === 0 ? '유출유 보고' : activeCat === 1 ? '종합보고서' : '초기보고서' report.analysisCategory = activeCat === 0 ? '유출유 확산예측' : activeCat === 1 ? 'HNS 대기확산' : '긴급구난' report.title = cat.reportName report.status = '완료' diff --git a/frontend/src/tabs/reports/components/ReportsView.tsx b/frontend/src/tabs/reports/components/ReportsView.tsx index 3a21559..7bd2296 100755 --- a/frontend/src/tabs/reports/components/ReportsView.tsx +++ b/frontend/src/tabs/reports/components/ReportsView.tsx @@ -21,6 +21,7 @@ import { import type { TemplateType } from './reportTypes'; import TemplateFormEditor from './TemplateFormEditor' import ReportGenerator from './ReportGenerator' +import TemplateEditPage from './TemplateEditPage' // ─── Main ReportsView ──────────────────────────────────── export function ReportsView() { @@ -216,8 +217,19 @@ export function ReportsView() { {/* ──── 수정 ──── */} {view.screen === 'edit' && ( -
- { setView({ screen: 'list' }); setActiveSubTab('report-list'); refreshList() }} /> +
+ {view.data.reportType === '유출유 보고' ? ( +
+ { setView({ screen: 'list' }); setActiveSubTab('report-list'); refreshList() }} /> +
+ ) : ( + { setView({ screen: 'list' }); setActiveSubTab('report-list'); refreshList() }} + /> + )}
)} @@ -283,7 +295,8 @@ export function ReportsView() {
문서 저장