kcg-ai-monitoring/src/features/admin/DataHub.tsx
htlee c0ce01eaf6 chore: 팀 워크플로우 기반 초기 프로젝트 구성
KCG AI 기반 불법조업 탐지·차단 플랫폼 프론트엔드.
React 19 + TypeScript 5.9 + Vite 8 + MapLibre + deck.gl + Zustand + Tailwind CSS.
SFR 20개 전체 UI 구현 완료, 백엔드 연동 대기.

- npm + Nexus 프록시 레지스트리 설정
- 팀 워크플로우 v1.6.1 부트스트랩 파일 배치
- .githooks (commit-msg, post-checkout)
- package.json name: kcg-ai-monitoring v0.1.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 14:11:29 +09:00

676 lines
41 KiB
TypeScript
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { SaveButton } from '@shared/components/common/SaveButton';
import {
Database, RefreshCw, Calendar, Wifi, WifiOff, Radio,
Activity, Server, ArrowDownToLine, Clock, AlertTriangle,
CheckCircle, XCircle, BarChart3, Layers, Plus, Play, Square,
Trash2, Edit2, Eye, FileText, HardDrive, Upload, FolderOpen,
Network, X, ChevronRight, Info,
} from 'lucide-react';
/*
* SFR-03: 통합데이터 허브 수집·연계 관리
* ① 선박신호 수신 현황 — 24시간 타임라인 히트맵
* ② 선박위치정보 모니터링 — 연계 채널 테이블
*/
// ─── ① 선박신호 수신 현황 데이터 ──────────────
type SignalStatus = 'ok' | 'warn' | 'error';
interface SignalSource {
name: string;
rate: number; // 수신율 %
timeline: SignalStatus[]; // 24시간 × 6 (10분 단위 = 144 슬롯)
}
function generateTimeline(): SignalStatus[] {
return Array.from({ length: 144 }, () => {
const r = Math.random();
return r < 0.75 ? 'ok' : r < 0.90 ? 'warn' : 'error';
});
}
const SIGNAL_SOURCES: SignalSource[] = [
{ name: 'VTS', rate: 88.9, timeline: generateTimeline() },
{ name: 'VTS-AIS', rate: 85.4, timeline: generateTimeline() },
{ name: 'V-PASS', rate: 84.0, timeline: generateTimeline() },
{ name: 'E-NAVI', rate: 88.9, timeline: generateTimeline() },
{ name: 'S&P AIS', rate: 85.4, timeline: generateTimeline() },
];
const SIGNAL_COLORS: Record<SignalStatus, string> = {
ok: '#22c55e',
warn: '#eab308',
error: '#ef4444',
};
const HOURS = Array.from({ length: 25 }, (_, i) => `${String(i).padStart(2, '0')}`);
// ─── ② 선박위치정보 모니터링 데이터 ──────────────
interface ChannelRecord {
no: number;
source: string; // 원천기관
code: string; // 기관코드
system: string; // 정보시스템명
linkInfo: string; // 연계정보
storage: string; // 저장장소
linkMethod: string; // 연계방식
cycle: string; // 수집주기
vesselCount: string; // 선박건수/신호건수
status: 'ON' | 'OFF'; // 연결상태
lastUpdate: string; // 최종갱신
[key: string]: unknown;
}
const CHANNELS: ChannelRecord[] = [
{ no: 1, source: '부산항', code: 'BS', system: 'VTS_AIS', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '00:00:00', vesselCount: '439 / 499', status: 'ON', lastUpdate: '2026-03-25 10:29:09' },
{ no: 2, source: '부산항', code: 'BS', system: 'VTS_RT', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '00:00:00', vesselCount: '133 / 463', status: 'ON', lastUpdate: '2026-03-25 10:29:09' },
{ no: 3, source: '부산신항', code: 'BSN', system: 'VTS_AIS', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '00:00:00', vesselCount: '255 / 278', status: 'ON', lastUpdate: '2026-03-25 10:29:09' },
{ no: 4, source: '부산신항', code: 'BSN', system: 'VTS_RT', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '00:00:00', vesselCount: '133 / 426', status: 'ON', lastUpdate: '2026-03-25 10:29:09' },
{ no: 5, source: '동해안', code: 'DH', system: 'VTS_AIS', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '수신대기중', vesselCount: '0', status: 'OFF', lastUpdate: '' },
{ no: 6, source: '동해안', code: 'DH', system: 'VTS_RT', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '수신대기중', vesselCount: '0', status: 'OFF', lastUpdate: '' },
{ no: 7, source: '대산항', code: 'DS', system: 'VTS_AIS', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '00:00:00', vesselCount: '0', status: 'OFF', lastUpdate: '2026-03-15 15:38:57' },
{ no: 8, source: '대산항', code: 'DS', system: 'VTS_RT', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '00:00:00', vesselCount: '0', status: 'OFF', lastUpdate: '2026-03-15 15:38:56' },
{ no: 9, source: '경인항', code: 'GI', system: 'VTS_AIS', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '00:00:00', vesselCount: '120 / 136', status: 'ON', lastUpdate: '2026-03-25 10:29:09' },
{ no: 10, source: '경인항', code: 'GI', system: 'VTS_RT', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '00:00:00', vesselCount: '55 / 467', status: 'ON', lastUpdate: '2026-03-25 10:29:09' },
{ no: 11, source: '경인연안', code: 'GIC', system: 'VTS_AIS', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '00:00:00', vesselCount: '180 / 216', status: 'ON', lastUpdate: '2026-03-25 10:29:09' },
{ no: 12, source: '경인연안', code: 'GIC', system: 'VTS_RT', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '수신대기중', vesselCount: '0', status: 'OFF', lastUpdate: '' },
{ no: 13, source: '군산항', code: 'GS', system: 'VTS_AIS', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '수신대기중', vesselCount: '0', status: 'OFF', lastUpdate: '' },
{ no: 14, source: '군산항', code: 'GS', system: 'VTS_RT', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '수신대기중', vesselCount: '0', status: 'OFF', lastUpdate: '' },
{ no: 15, source: '인천항', code: 'IC', system: 'VTS_AIS', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '00:00:00', vesselCount: '149 / 176', status: 'ON', lastUpdate: '2026-03-25 10:29:09' },
{ no: 16, source: '인천항', code: 'IC', system: 'VTS_RT', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '00:00:00', vesselCount: '55 / 503', status: 'ON', lastUpdate: '2026-03-25 10:29:09' },
{ no: 17, source: '진도연안', code: 'JDC', system: 'VTS_AIS', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '00:00:00', vesselCount: '433 / 524', status: 'ON', lastUpdate: '2026-03-25 10:29:09' },
{ no: 18, source: '진도연안', code: 'JDC', system: 'VTS_RT', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '00:00:00', vesselCount: '256 / 1619', status: 'ON', lastUpdate: '2026-03-25 10:29:09' },
{ no: 19, source: '제주항', code: 'JJ', system: 'VTS_AIS', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '00:00:00', vesselCount: '429 / 508', status: 'ON', lastUpdate: '2026-03-25 10:29:09' },
{ no: 20, source: '제주항', code: 'JJ', system: 'VTS_RT', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '00:00:00', vesselCount: '160 / 1592', status: 'ON', lastUpdate: '2026-03-25 10:29:09' },
{ no: 21, source: '목포항', code: 'MP', system: 'VTS_AIS', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '수신대기중', vesselCount: '0', status: 'OFF', lastUpdate: '' },
{ no: 22, source: '목포항', code: 'MP', system: 'VTS_RT', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '수신대기중', vesselCount: '0', status: 'OFF', lastUpdate: '' },
];
// ─── 채널 테이블 컬럼 정의 ───────────────────
const channelColumns: DataColumn<ChannelRecord>[] = [
{ key: 'no', label: '번호', width: '50px', align: 'center', sortable: true,
render: (v) => <span className="text-hint">{v as number}</span>,
},
{ key: 'source', label: '원천기관', width: '80px', sortable: true,
render: (v) => <span className="text-label">{v as string}</span>,
},
{ key: 'code', label: '기관코드', width: '65px', align: 'center',
render: (v) => <span className="text-hint font-mono">{v as string}</span>,
},
{ key: 'system', label: '정보시스템명', width: '100px', sortable: true,
render: (v) => <span className="text-cyan-400 font-medium">{v as string}</span>,
},
{ key: 'linkInfo', label: '연계정보', width: '65px' },
{ key: 'storage', label: '저장장소', render: (v) => <span className="text-hint font-mono text-[9px]">{v as string}</span> },
{ key: 'linkMethod', label: '연계방식', width: '70px', align: 'center',
render: (v) => <Badge className="bg-purple-500/20 text-purple-400 border-0 text-[9px]">{v as string}</Badge>,
},
{ key: 'cycle', label: '수집주기', width: '80px', align: 'center',
render: (v) => {
const s = v as string;
return s === '수신대기중'
? <span className="text-orange-400 text-[9px]">{s}</span>
: <span className="text-muted-foreground font-mono text-[10px]">{s}</span>;
},
},
{ key: 'vesselCount', label: '선박건수/신호건수', width: '120px', align: 'right', sortable: true,
render: (v) => <span className="text-heading font-mono">{v as string}</span>,
},
{ key: 'status', label: '연결상태', width: '80px', align: 'center', sortable: true,
render: (v, row) => {
const on = v === 'ON';
return (
<div className="flex flex-col items-center gap-0.5">
<Badge className={`border-0 text-[9px] font-bold px-3 ${on ? 'bg-blue-600 text-heading' : 'bg-red-500 text-heading'}`}>
{v as string}
</Badge>
{row.lastUpdate && (
<span className="text-[8px] text-hint">{row.lastUpdate as string}</span>
)}
</div>
);
},
},
];
// ─── 히트맵 컴포넌트 ──────────────────────
function SignalTimeline({ source }: { source: SignalSource }) {
return (
<div className="flex items-center gap-3">
{/* 라벨 */}
<div className="w-16 shrink-0 text-right">
<div className="text-[11px] font-bold" style={{
color: source.name === 'VTS' ? '#22c55e'
: source.name === 'VTS-AIS' ? '#3b82f6'
: source.name === 'V-PASS' ? '#a855f7'
: source.name === 'E-NAVI' ? '#ef4444'
: '#eab308',
}}>{source.name}</div>
<div className="text-[10px] text-hint">{source.rate}%</div>
</div>
{/* 타임라인 바 */}
<div className="flex-1 flex gap-px">
{source.timeline.map((status, i) => (
<div
key={i}
className="flex-1 h-5 rounded-[1px]"
style={{ backgroundColor: SIGNAL_COLORS[status], minWidth: '2px' }}
title={`${String(Math.floor(i / 6)).padStart(2, '0')}:${String((i % 6) * 10).padStart(2, '0')}${status === 'ok' ? '정상' : status === 'warn' ? '지연' : '장애'}`}
/>
))}
</div>
</div>
);
}
// ─── ③ 수집 작업 관리 데이터 ──────────────────
type JobStatus = '정지' | '대기중' | '수행중' | '장애발생';
type ServerType = 'SQL' | 'FILE' | 'FTP';
interface CollectJob {
id: string;
name: string;
serverType: ServerType;
serverName: string;
serverIp: string;
status: JobStatus;
schedule: string;
lastRun: string;
successRate: number;
[key: string]: unknown;
}
const COLLECT_JOBS: CollectJob[] = [
{ id: 'COL-001', name: '부산항 AIS 수집', serverType: 'SQL', serverName: 'vts-bs-db01', serverIp: '10.20.30.11', status: '수행중', schedule: '매 10분', lastRun: '2026-04-03 09:20:00', successRate: 98.5 },
{ id: 'COL-002', name: '인천항 VTS 수집', serverType: 'SQL', serverName: 'vts-ic-db01', serverIp: '10.20.30.12', status: '수행중', schedule: '매 10분', lastRun: '2026-04-03 09:20:00', successRate: 97.2 },
{ id: 'COL-003', name: 'V-PASS 파일 수집', serverType: 'FILE', serverName: 'vpass-ftp01', serverIp: '10.20.31.20', status: '수행중', schedule: '매 30분', lastRun: '2026-04-03 09:00:00', successRate: 94.1 },
{ id: 'COL-004', name: 'E-NAVI 로그 수집', serverType: 'FILE', serverName: 'enavi-nas01', serverIp: '10.20.31.30', status: '대기중', schedule: '매 1시간', lastRun: '2026-04-03 08:00:00', successRate: 91.8 },
{ id: 'COL-005', name: 'S&P AIS 해외 수집', serverType: 'FTP', serverName: 'sp-ais-ftp', serverIp: '203.45.67.89', status: '수행중', schedule: '매 15분', lastRun: '2026-04-03 09:15:00', successRate: 85.4 },
{ id: 'COL-006', name: '동해안 VTS 수집', serverType: 'SQL', serverName: 'vts-dh-db01', serverIp: '10.20.30.15', status: '장애발생', schedule: '매 10분', lastRun: '2026-04-02 22:10:00', successRate: 0 },
{ id: 'COL-007', name: '군산항 레이더 수집', serverType: 'SQL', serverName: 'vts-gs-db01', serverIp: '10.20.30.18', status: '장애발생', schedule: '매 10분', lastRun: '2026-04-01 14:30:00', successRate: 0 },
{ id: 'COL-008', name: '제주항 CCTV 수집', serverType: 'FTP', serverName: 'jj-cctv-ftp', serverIp: '10.20.32.50', status: '정지', schedule: '매 1시간', lastRun: '2026-03-28 12:00:00', successRate: 0 },
{ id: 'COL-009', name: '위성 SAR 수집', serverType: 'FTP', serverName: 'sat-sar-ftp', serverIp: '10.20.40.10', status: '수행중', schedule: '매 6시간', lastRun: '2026-04-03 06:00:00', successRate: 99.0 },
{ id: 'COL-010', name: '해수부 VMS 연동', serverType: 'SQL', serverName: 'mof-vms-db', serverIp: '10.20.50.11', status: '수행중', schedule: '매 5분', lastRun: '2026-04-03 09:20:00', successRate: 96.3 },
];
const collectColumns: DataColumn<CollectJob>[] = [
{ key: 'id', label: 'ID', width: '80px', render: (v) => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'name', label: '작업명', sortable: true, render: (v) => <span className="text-heading font-medium">{v as string}</span> },
{ key: 'serverType', label: '타입', width: '60px', align: 'center', sortable: true,
render: (v) => {
const t = v as string;
const c = t === 'SQL' ? 'bg-blue-500/20 text-blue-400' : t === 'FILE' ? 'bg-green-500/20 text-green-400' : 'bg-purple-500/20 text-purple-400';
return <Badge className={`border-0 text-[9px] ${c}`}>{t}</Badge>;
},
},
{ key: 'serverName', label: '서버명', width: '120px', render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
{ key: 'serverIp', label: 'IP', width: '120px', render: (v) => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'status', label: '상태', width: '80px', align: 'center', sortable: true,
render: (v) => {
const s = v as JobStatus;
const c = s === '수행중' ? 'bg-green-500/20 text-green-400' : s === '대기중' ? 'bg-yellow-500/20 text-yellow-400' : s === '장애발생' ? 'bg-red-500/20 text-red-400' : 'bg-muted text-muted-foreground';
return <Badge className={`border-0 text-[9px] ${c}`}>{s}</Badge>;
},
},
{ key: 'schedule', label: '스케줄', width: '80px' },
{ key: 'lastRun', label: '최종 수행', width: '140px', sortable: true, render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
{ key: 'successRate', label: '성공률', width: '70px', align: 'right', sortable: true,
render: (v) => {
const n = v as number;
const c = n >= 90 ? 'text-green-400' : n >= 70 ? 'text-yellow-400' : n > 0 ? 'text-red-400' : 'text-hint';
return <span className={`font-bold text-[11px] ${c}`}>{n > 0 ? `${n}%` : '-'}</span>;
},
},
{ key: 'id', label: '', width: '70px', align: 'center', sortable: false,
render: (_v, row) => (
<div className="flex items-center gap-0.5">
{row.status === '정지' ? (
<button className="p-1 text-hint hover:text-green-400" title="시작"><Play className="w-3 h-3" /></button>
) : row.status !== '장애발생' ? (
<button className="p-1 text-hint hover:text-orange-400" title="정지"><Square className="w-3 h-3" /></button>
) : null}
<button className="p-1 text-hint hover:text-blue-400" title="편집"><Edit2 className="w-3 h-3" /></button>
<button className="p-1 text-hint hover:text-cyan-400" title="이력"><FileText className="w-3 h-3" /></button>
</div>
),
},
];
// ─── ④ 적재 작업 관리 데이터 ──────────────────
interface LoadJob {
id: string;
name: string;
sourceJob: string;
targetTable: string;
targetDb: string;
status: JobStatus;
schedule: string;
lastRun: string;
recordCount: string;
[key: string]: unknown;
}
const LOAD_JOBS: LoadJob[] = [
{ id: 'LOD-001', name: 'AIS 동적정보 적재', sourceJob: 'COL-001', targetTable: 'tb_ais_dynamic', targetDb: 'MDA_DB', status: '수행중', schedule: '매 10분', lastRun: '2026-04-03 09:20:00', recordCount: '12,450' },
{ id: 'LOD-002', name: 'VTS 레이더 적재', sourceJob: 'COL-002', targetTable: 'tb_vts_radar', targetDb: 'MDA_DB', status: '수행중', schedule: '매 10분', lastRun: '2026-04-03 09:20:00', recordCount: '8,320' },
{ id: 'LOD-003', name: 'V-PASS 위치 적재', sourceJob: 'COL-003', targetTable: 'tb_vpass_position', targetDb: 'MDA_DB', status: '수행중', schedule: '매 30분', lastRun: '2026-04-03 09:00:00', recordCount: '3,210' },
{ id: 'LOD-004', name: 'VMS 데이터 적재', sourceJob: 'COL-010', targetTable: 'tb_vms_track', targetDb: 'MDA_DB', status: '수행중', schedule: '매 5분', lastRun: '2026-04-03 09:20:00', recordCount: '5,677' },
{ id: 'LOD-005', name: 'SAR 위성 적재', sourceJob: 'COL-009', targetTable: 'tb_sat_imagery', targetDb: 'SAT_DB', status: '대기중', schedule: '매 6시간', lastRun: '2026-04-03 06:00:00', recordCount: '24' },
{ id: 'LOD-006', name: '해외 AIS 적재', sourceJob: 'COL-005', targetTable: 'tb_sais_global', targetDb: 'MDA_DB', status: '수행중', schedule: '매 15분', lastRun: '2026-04-03 09:15:00', recordCount: '45,200' },
{ id: 'LOD-007', name: '동해안 VTS 적재', sourceJob: 'COL-006', targetTable: 'tb_vts_dh', targetDb: 'MDA_DB', status: '장애발생', schedule: '매 10분', lastRun: '2026-04-02 22:10:00', recordCount: '0' },
];
const loadColumns: DataColumn<LoadJob>[] = [
{ key: 'id', label: 'ID', width: '80px', render: (v) => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'name', label: '작업명', sortable: true, render: (v) => <span className="text-heading font-medium">{v as string}</span> },
{ key: 'sourceJob', label: '수집원', width: '80px', render: (v) => <Badge className="bg-cyan-500/15 text-cyan-400 border-0 text-[9px]">{v as string}</Badge> },
{ key: 'targetTable', label: '대상 테이블', width: '140px', render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
{ key: 'targetDb', label: 'DB', width: '70px', align: 'center' },
{ key: 'status', label: '상태', width: '80px', align: 'center', sortable: true,
render: (v) => {
const s = v as JobStatus;
const c = s === '수행중' ? 'bg-green-500/20 text-green-400' : s === '대기중' ? 'bg-yellow-500/20 text-yellow-400' : s === '장애발생' ? 'bg-red-500/20 text-red-400' : 'bg-muted text-muted-foreground';
return <Badge className={`border-0 text-[9px] ${c}`}>{s}</Badge>;
},
},
{ key: 'schedule', label: '스케줄', width: '80px' },
{ key: 'lastRun', label: '최종 적재', width: '140px', sortable: true, render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
{ key: 'recordCount', label: '적재건수', width: '80px', align: 'right', render: (v) => <span className="text-heading font-bold">{v as string}</span> },
{ key: 'id', label: '', width: '70px', align: 'center', sortable: false,
render: () => (
<div className="flex items-center gap-0.5">
<button className="p-1 text-hint hover:text-blue-400" title="편집"><Edit2 className="w-3 h-3" /></button>
<button className="p-1 text-hint hover:text-cyan-400" title="이력"><FileText className="w-3 h-3" /></button>
<button className="p-1 text-hint hover:text-orange-400" title="스토리지"><HardDrive className="w-3 h-3" /></button>
</div>
),
},
];
// ─── ⑤ 연계서버 모니터링 데이터 ────────────────
type AgentRole = '수집' | '적재';
interface AgentServer {
id: string;
name: string;
role: AgentRole;
hostname: string;
ip: string;
port: string;
mac: string;
status: JobStatus;
taskCount: number;
cpuUsage: number;
memUsage: number;
diskUsage: number;
lastHeartbeat: string;
[key: string]: unknown;
}
const AGENTS: AgentServer[] = [
{ id: 'AGT-1001', name: '부산항 AIS 수집Agent', role: '수집', hostname: 'vts-bs-col01', ip: '10.20.30.11', port: '8081', mac: '00:1A:2B:3C:4D:01', status: '수행중', taskCount: 3, cpuUsage: 42, memUsage: 65, diskUsage: 38, lastHeartbeat: '2026-04-03 09:20:05' },
{ id: 'AGT-1002', name: '인천항 VTS 수집Agent', role: '수집', hostname: 'vts-ic-col01', ip: '10.20.30.12', port: '8081', mac: '00:1A:2B:3C:4D:02', status: '수행중', taskCount: 2, cpuUsage: 35, memUsage: 52, diskUsage: 41, lastHeartbeat: '2026-04-03 09:20:03' },
{ id: 'AGT-1003', name: '제주항 수집Agent', role: '수집', hostname: 'vts-jj-col01', ip: '10.20.30.19', port: '8081', mac: '00:1A:2B:3C:4D:03', status: '수행중', taskCount: 2, cpuUsage: 28, memUsage: 45, diskUsage: 55, lastHeartbeat: '2026-04-03 09:20:02' },
{ id: 'AGT-1004', name: '동해안 수집Agent', role: '수집', hostname: 'vts-dh-col01', ip: '10.20.30.15', port: '8081', mac: '00:1A:2B:3C:4D:04', status: '장애발생', taskCount: 0, cpuUsage: 0, memUsage: 0, diskUsage: 72, lastHeartbeat: '2026-04-02 22:10:15' },
{ id: 'AGT-1005', name: 'V-PASS 수집Agent', role: '수집', hostname: 'vpass-col01', ip: '10.20.31.20', port: '8082', mac: '00:1A:2B:3C:4D:05', status: '수행중', taskCount: 1, cpuUsage: 18, memUsage: 30, diskUsage: 25, lastHeartbeat: '2026-04-03 09:20:01' },
{ id: 'AGT-2001', name: 'MDA DB 적재Agent', role: '적재', hostname: 'mda-lod01', ip: '10.20.40.11', port: '9091', mac: '00:1A:2B:3C:4D:11', status: '수행중', taskCount: 5, cpuUsage: 55, memUsage: 72, diskUsage: 48, lastHeartbeat: '2026-04-03 09:20:04' },
{ id: 'AGT-2002', name: 'SAT DB 적재Agent', role: '적재', hostname: 'sat-lod01', ip: '10.20.40.12', port: '9091', mac: '00:1A:2B:3C:4D:12', status: '대기중', taskCount: 1, cpuUsage: 5, memUsage: 22, diskUsage: 33, lastHeartbeat: '2026-04-03 09:20:00' },
{ id: 'AGT-2003', name: '백업 적재Agent', role: '적재', hostname: 'bak-lod01', ip: '10.20.40.13', port: '9091', mac: '00:1A:2B:3C:4D:13', status: '정지', taskCount: 0, cpuUsage: 0, memUsage: 12, diskUsage: 15, lastHeartbeat: '2026-03-30 18:00:00' },
];
function UsageBar({ value, color }: { value: number; color: string }) {
return (
<div className="flex items-center gap-1.5">
<div className="w-14 h-1.5 bg-switch-background/60 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${color}`} style={{ width: `${value}%` }} />
</div>
<span className="text-[9px] text-muted-foreground w-7 text-right">{value}%</span>
</div>
);
}
// ─── 메인 컴포넌트 ──────────────────────
type Tab = 'signal' | 'monitor' | 'collect' | 'load' | 'agents';
export function DataHub() {
const { t } = useTranslation('admin');
const [tab, setTab] = useState<Tab>('signal');
const [selectedDate, setSelectedDate] = useState('2026-04-02');
const [statusFilter, setStatusFilter] = useState<'' | 'ON' | 'OFF'>('');
// 수집 작업 필터
const [collectTypeFilter, setCollectTypeFilter] = useState<'' | ServerType>('');
const [collectStatusFilter, setCollectStatusFilter] = useState<'' | JobStatus>('');
// 적재 작업 필터
const [loadStatusFilter, setLoadStatusFilter] = useState<'' | JobStatus>('');
// 연계서버 필터
const [agentRoleFilter, setAgentRoleFilter] = useState<'' | AgentRole>('');
const [agentStatusFilter, setAgentStatusFilter] = useState<'' | JobStatus>('');
const onCount = CHANNELS.filter((c) => c.status === 'ON').length;
const offCount = CHANNELS.filter((c) => c.status === 'OFF').length;
const hasPartialOff = offCount > 0;
const filteredChannels = statusFilter
? CHANNELS.filter((c) => c.status === statusFilter)
: CHANNELS;
const filteredCollectJobs = COLLECT_JOBS.filter((j) =>
(!collectTypeFilter || j.serverType === collectTypeFilter) &&
(!collectStatusFilter || j.status === collectStatusFilter)
);
const filteredLoadJobs = LOAD_JOBS.filter((j) =>
(!loadStatusFilter || j.status === loadStatusFilter)
);
const filteredAgents = AGENTS.filter((a) =>
(!agentRoleFilter || a.role === agentRoleFilter) &&
(!agentStatusFilter || a.status === agentStatusFilter)
);
return (
<div className="p-5 space-y-4">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Database className="w-5 h-5 text-cyan-400" />
{t('dataHub.title')}
</h2>
<p className="text-[10px] text-hint mt-0.5">
{t('dataHub.desc')}
</p>
</div>
<div className="flex items-center gap-2">
<button className="flex items-center gap-1.5 px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading transition-colors">
<RefreshCw className="w-3 h-3" />
</button>
</div>
</div>
{/* KPI */}
<div className="flex gap-2">
{[
{ label: '전체 채널', value: CHANNELS.length, icon: Layers, color: 'text-label', bg: 'bg-muted' },
{ label: 'ON', value: onCount, icon: Wifi, color: 'text-blue-400', bg: 'bg-blue-500/10' },
{ label: 'OFF', value: offCount, icon: WifiOff, color: 'text-red-400', bg: 'bg-red-500/10' },
{ label: '평균 수신율', value: '86.5%', icon: BarChart3, color: 'text-green-400', bg: 'bg-green-500/10' },
{ label: '데이터 소스', value: '5종', icon: Radio, color: 'text-purple-400', bg: 'bg-purple-500/10' },
{ label: '연계 방식', value: 'KAFKA', icon: Server, color: 'text-orange-400', bg: 'bg-orange-500/10' },
].map((kpi) => (
<div key={kpi.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
<div className={`p-1.5 rounded-lg ${kpi.bg}`}>
<kpi.icon className={`w-3.5 h-3.5 ${kpi.color}`} />
</div>
<span className={`text-base font-bold ${kpi.color}`}>{kpi.value}</span>
<span className="text-[9px] text-hint">{kpi.label}</span>
</div>
))}
</div>
{/* 탭 */}
<div className="flex gap-1">
{[
{ key: 'signal' as Tab, icon: Activity, label: '선박신호 수신 현황' },
{ key: 'monitor' as Tab, icon: Server, label: '선박위치정보 모니터링' },
{ key: 'collect' as Tab, icon: ArrowDownToLine, label: '수집 작업 관리' },
{ key: 'load' as Tab, icon: HardDrive, label: '적재 작업 관리' },
{ key: 'agents' as Tab, icon: Network, label: '연계서버 모니터링' },
].map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs transition-colors ${
tab === t.key ? 'bg-cyan-600 text-heading' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'
}`}
>
<t.icon className="w-3.5 h-3.5" />
{t.label}
</button>
))}
</div>
{/* ── ① 선박신호 수신 현황 ── */}
{tab === 'signal' && (
<Card>
<CardContent className="p-4">
{/* 상단: 제목 + 날짜 */}
<div className="flex items-center justify-between mb-4">
<div className="text-sm font-bold text-heading"> </div>
<div className="flex items-center gap-2">
<div className="relative">
<input
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
className="bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-1.5 text-[11px] text-heading focus:outline-none focus:border-cyan-500/50"
/>
</div>
<button className="flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-slate-700/50 rounded-lg text-[10px] text-muted-foreground hover:text-heading transition-colors">
<RefreshCw className="w-3 h-3" />
</button>
</div>
</div>
{/* 시간축 헤더 */}
<div className="flex items-center gap-3 mb-2">
<div className="w-16 shrink-0" />
<div className="flex-1 flex">
{HOURS.map((h, i) => (
<div key={i} className="flex-1 text-center text-[8px] text-hint" style={{ minWidth: 0 }}>
{i % 2 === 0 ? h : ''}
</div>
))}
</div>
</div>
{/* 타임라인 히트맵 */}
<div className="space-y-2">
{SIGNAL_SOURCES.map((src) => (
<SignalTimeline key={src.name} source={src} />
))}
</div>
{/* 범례 */}
<div className="flex items-center gap-4 mt-4 pt-3 border-t border-border">
<div className="text-[9px] text-hint">:</div>
{[
{ label: '정상 수신', color: '#22c55e' },
{ label: '지연/경고', color: '#eab308' },
{ label: '장애/미수신', color: '#ef4444' },
].map((item) => (
<div key={item.label} className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: item.color }} />
<span className="text-[9px] text-muted-foreground">{item.label}</span>
</div>
))}
<div className="ml-auto text-[9px] text-hint">
10 · 24 (144 )
</div>
</div>
</CardContent>
</Card>
)}
{/* ── ② 선박위치정보 모니터링 ── */}
{tab === 'monitor' && (
<div className="space-y-3">
{/* 상태 요약 바 */}
<div className="flex items-center gap-3 px-4 py-2 rounded-xl border border-border bg-card">
<div className="flex items-center gap-1.5">
{hasPartialOff ? (
<AlertTriangle className="w-3.5 h-3.5 text-orange-400" />
) : (
<CheckCircle className="w-3.5 h-3.5 text-green-400" />
)}
<span className={`text-[11px] font-bold ${hasPartialOff ? 'text-orange-400' : 'text-green-400'}`}>
{hasPartialOff ? `일부 OFF (${offCount}/${CHANNELS.length})` : '전체 정상'}
</span>
</div>
<span className="text-[10px] text-hint">
{CHANNELS.length} (ON: {onCount} / OFF: {offCount})
</span>
<span className="text-[10px] text-hint ml-1">
갱신: 오후 06:57:33
</span>
{/* 상태 필터 */}
<div className="ml-auto flex items-center gap-1">
{(['', 'ON', 'OFF'] as const).map((f) => (
<button
key={f}
onClick={() => setStatusFilter(f)}
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${
statusFilter === f
? 'bg-cyan-600 text-heading font-bold'
: 'text-hint hover:bg-surface-overlay hover:text-label'
}`}
>
{f || '전체'}
</button>
))}
</div>
</div>
{/* DataTable */}
<DataTable
data={filteredChannels}
columns={channelColumns}
pageSize={12}
searchPlaceholder="기관명, 코드, 시스템명 검색..."
searchKeys={['source', 'code', 'system', 'linkMethod']}
exportFilename="선박위치정보_모니터링"
/>
</div>
)}
{/* ── ③ 수집 작업 관리 ── */}
{tab === 'collect' && (
<div className="space-y-3">
<div className="flex items-center gap-3 px-4 py-2 rounded-xl border border-border bg-card">
<span className="text-[10px] text-hint"> :</span>
{(['', 'SQL', 'FILE', 'FTP'] as const).map((f) => (
<button key={f} onClick={() => setCollectTypeFilter(f)}
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${collectTypeFilter === f ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`}
>{f || '전체'}</button>
))}
<span className="text-[10px] text-hint ml-3">:</span>
{(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => (
<button key={f} onClick={() => setCollectStatusFilter(f)}
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${collectStatusFilter === f ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`}
>{f || '전체'}</button>
))}
<button className="ml-auto flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-heading text-[10px] font-bold rounded-lg transition-colors">
<Plus className="w-3 h-3" />
</button>
</div>
<DataTable data={filteredCollectJobs} columns={collectColumns} pageSize={10}
searchPlaceholder="작업명, 서버명, IP 검색..." searchKeys={['name', 'serverName', 'serverIp']} exportFilename="수집작업목록" />
</div>
)}
{/* ── ④ 적재 작업 관리 ── */}
{tab === 'load' && (
<div className="space-y-3">
<div className="flex items-center gap-3 px-4 py-2 rounded-xl border border-border bg-card">
<span className="text-[10px] text-hint">:</span>
{(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => (
<button key={f} onClick={() => setLoadStatusFilter(f)}
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${loadStatusFilter === f ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`}
>{f || '전체'}</button>
))}
<div className="ml-auto flex items-center gap-2">
<button className="flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading transition-colors">
<FolderOpen className="w-3 h-3" />
</button>
<button className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-heading text-[10px] font-bold rounded-lg transition-colors">
<Plus className="w-3 h-3" />
</button>
</div>
</div>
<DataTable data={filteredLoadJobs} columns={loadColumns} pageSize={10}
searchPlaceholder="작업명, 테이블명, DB 검색..." searchKeys={['name', 'targetTable', 'targetDb', 'sourceJob']} exportFilename="적재작업목록" />
</div>
)}
{/* ── ⑤ 연계서버 모니터링 ── */}
{tab === 'agents' && (
<div className="space-y-3">
<div className="flex items-center gap-3 px-4 py-2 rounded-xl border border-border bg-card">
<span className="text-[10px] text-hint">:</span>
{(['', '수집', '적재'] as const).map((f) => (
<button key={f} onClick={() => setAgentRoleFilter(f)}
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${agentRoleFilter === f ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`}
>{f || '전체'}</button>
))}
<span className="text-[10px] text-hint ml-3">:</span>
{(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => (
<button key={f} onClick={() => setAgentStatusFilter(f)}
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${agentStatusFilter === f ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`}
>{f || '전체'}</button>
))}
<button className="ml-auto flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading transition-colors">
<RefreshCw className="w-3 h-3" />
</button>
</div>
{/* 연계서버 카드 그리드 */}
<div className="grid grid-cols-2 gap-3">
{filteredAgents.map((agent) => {
const stColor = agent.status === '수행중' ? 'text-green-400 bg-green-500/15' : agent.status === '대기중' ? 'text-yellow-400 bg-yellow-500/15' : agent.status === '장애발생' ? 'text-red-400 bg-red-500/15' : 'text-muted-foreground bg-muted';
return (
<Card key={agent.id} className="bg-surface-raised border-border">
<CardContent className="p-4">
<div className="flex items-start justify-between mb-3">
<div>
<div className="text-[12px] font-bold text-heading">{agent.name}</div>
<div className="text-[10px] text-hint">{agent.id} · {agent.role}</div>
</div>
<Badge className={`border-0 text-[9px] ${stColor}`}>{agent.status}</Badge>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-[10px] mb-3">
<div className="flex justify-between"><span className="text-hint">Hostname</span><span className="text-label font-mono">{agent.hostname}</span></div>
<div className="flex justify-between"><span className="text-hint">IP</span><span className="text-label font-mono">{agent.ip}</span></div>
<div className="flex justify-between"><span className="text-hint">Port</span><span className="text-label font-mono">{agent.port}</span></div>
<div className="flex justify-between"><span className="text-hint">MAC</span><span className="text-muted-foreground font-mono text-[9px]">{agent.mac}</span></div>
</div>
<div className="space-y-1 pt-2 border-t border-border">
<div className="flex items-center justify-between"><span className="text-[9px] text-hint w-10">CPU</span><UsageBar value={agent.cpuUsage} color={agent.cpuUsage > 80 ? 'bg-red-500' : agent.cpuUsage > 50 ? 'bg-yellow-500' : 'bg-green-500'} /></div>
<div className="flex items-center justify-between"><span className="text-[9px] text-hint w-10">MEM</span><UsageBar value={agent.memUsage} color={agent.memUsage > 80 ? 'bg-red-500' : agent.memUsage > 50 ? 'bg-yellow-500' : 'bg-green-500'} /></div>
<div className="flex items-center justify-between"><span className="text-[9px] text-hint w-10">DISK</span><UsageBar value={agent.diskUsage} color={agent.diskUsage > 80 ? 'bg-red-500' : agent.diskUsage > 50 ? 'bg-yellow-500' : 'bg-green-500'} /></div>
</div>
<div className="flex items-center justify-between mt-3 pt-2 border-t border-border">
<span className="text-[9px] text-hint"> {agent.taskCount} · heartbeat {agent.lastHeartbeat.slice(11)}</span>
<div className="flex gap-0.5">
<button className="p-1 text-hint hover:text-blue-400" title="상태 상세"><Eye className="w-3 h-3" /></button>
<button className="p-1 text-hint hover:text-yellow-400" title="이름 변경"><Edit2 className="w-3 h-3" /></button>
<button className="p-1 text-hint hover:text-red-400" title="삭제"><Trash2 className="w-3 h-3" /></button>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
)}
</div>
);
}