wing-ops/frontend/src/tabs/admin/components/RndHnsAtmosPanel.tsx

631 lines
21 KiB
TypeScript

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>
);
}