kcg-ai-monitoring/frontend/src/features/admin/DataHub.tsx
htlee 8af693a2df refactor(i18n): alert/confirm/aria-label 하드코딩 한글 제거
공통 번역 리소스 확장:
- common.json 에 aria / error / dialog / success / message 네임스페이스 추가
- ko/en 양쪽 동일 구조 유지 (aria 36 키 + error 7 키 + dialog 4 키 + message 5 키)

alert/confirm 11건 → t() 치환:
- parent-inference: ParentReview / LabelSession / ParentExclusion
- admin: PermissionsPanel / UserRoleAssignDialog / AccessControl

aria-label 한글 40+건 → t() 치환:
- parent-inference (group_key/sub_cluster/정답 parent MMSI/스코프 필터 등)
- admin (역할 코드/이름, 알림 제목/내용, 시작일/종료일, 코드 검색, 대분류 필터, 수신 현황 기준일)
- detection (그룹 유형/해역 필터, 관심영역, 필터 설정/초기화, 멤버 수, 미니맵/재생 닫기)
- enforcement (확인/선박 상세/단속 등록/오탐 처리)
- vessel/statistics/ai-operations (조회 시작/종료 시각, 업로드 패널 닫기, 전송, 예시 URL 복사)
- 공통 컴포넌트 (SearchInput, NotificationBanner)

MainLayout 언어 토글:
- title 삼항분기 → t('message.switchToEnglish'/'switchToKorean')
- aria-label="페이지 내 검색" → t('aria.searchInPage')
- 토글 버튼 자체에 aria-label={t('aria.languageToggle')} 추가
2026-04-16 16:32:37 +09:00

665 lines
38 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 } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { TabBar, TabButton } from '@shared/components/ui/tabs';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { getConnectionStatusHex } from '@shared/constants/connectionStatuses';
import type { BadgeIntent } from '@lib/theme/variants';
import {
Database, RefreshCw, Wifi, WifiOff, Radio,
Activity, Server, ArrowDownToLine, AlertTriangle,
CheckCircle, BarChart3, Layers, Plus, Play, Square,
Trash2, Edit2, Eye, FileText, HardDrive, FolderOpen,
Network,
} from 'lucide-react';
function jobStatusIntent(s: string): BadgeIntent {
if (s === '수행중') return 'success';
if (s === '대기중') return 'warning';
if (s === '장애발생') return 'critical';
return 'muted';
}
/*
* 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() },
];
// SIGNAL_COLORS는 connectionStatuses 카탈로그에서 가져옴 (getConnectionStatusHex)
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-label 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 intent="purple" size="sm">{v as string}</Badge>,
},
{ key: 'cycle', label: '수집주기', width: '80px', align: 'center',
render: (v) => {
const s = v as string;
return s === '수신대기중'
? <Badge intent="warning" size="xs">{s}</Badge>
: <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 intent={on ? 'info' : 'critical'} size="xs">
{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 text-label">{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: getConnectionStatusHex(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 intent: BadgeIntent = t === 'SQL' ? 'info' : t === 'FILE' ? 'success' : 'purple';
return <Badge intent={intent} size="xs">{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) => <Badge intent={jobStatusIntent(v as string)} size="xs">{v as string}</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;
if (n === 0) return <span className="font-bold text-[11px] text-hint">-</span>;
const intent: BadgeIntent = n >= 90 ? 'success' : n >= 70 ? 'warning' : 'critical';
return <Badge intent={intent} size="xs">{n}%</Badge>;
},
},
{ key: 'id', label: '', width: '70px', align: 'center', sortable: false,
render: (_v, row) => (
<div className="flex items-center gap-0.5">
{row.status === '정지' ? (
<button type="button" className="p-1 text-hint hover:text-heading" title="시작"><Play className="w-3 h-3" /></button>
) : row.status !== '장애발생' ? (
<button type="button" className="p-1 text-hint hover:text-heading" title="정지"><Square className="w-3 h-3" /></button>
) : null}
<button type="button" className="p-1 text-hint hover:text-label" title="편집"><Edit2 className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-label" 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 intent="cyan" size="sm">{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) => <Badge intent={jobStatusIntent(v as string)} size="xs">{v as string}</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 type="button" className="p-1 text-hint hover:text-label" title="편집"><Edit2 className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-label" title="이력"><FileText className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-heading" 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 { t: tc } = useTranslation('common');
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 (
<PageContainer>
<PageHeader
icon={Database}
iconColor="text-label"
title={t('dataHub.title')}
description={t('dataHub.desc')}
demo
actions={
<Button variant="secondary" size="sm" icon={<RefreshCw className="w-3 h-3" />}>
</Button>
}
/>
{/* 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-label', bg: 'bg-blue-500/10' },
{ label: 'OFF', value: offCount, icon: WifiOff, color: 'text-heading', bg: 'bg-red-500/10' },
{ label: '평균 수신율', value: '86.5%', icon: BarChart3, color: 'text-label', bg: 'bg-green-500/10' },
{ label: '데이터 소스', value: '5종', icon: Radio, color: 'text-heading', bg: 'bg-purple-500/10' },
{ label: '연계 방식', value: 'KAFKA', icon: Server, color: 'text-heading', 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>
{/* 탭 */}
<TabBar variant="pill">
{[
{ 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) => (
<TabButton
key={t.key}
variant="pill"
active={tab === t.key}
onClick={() => setTab(t.key)}
icon={<t.icon className="w-3.5 h-3.5" />}
>
{t.label}
</TabButton>
))}
</TabBar>
{/* ── ① 선박신호 수신 현황 ── */}
{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
aria-label={tc('aria.receiptDate')}
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 variant="secondary" size="sm" icon={<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-heading" />
) : (
<CheckCircle className="w-3.5 h-3.5 text-label" />
)}
<span className="text-[11px] font-bold text-heading">
{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) => (
<TabButton
key={f}
variant="pill"
active={statusFilter === f}
onClick={() => setStatusFilter(f)}
className="px-2.5 py-1 text-[10px]"
>
{f || '전체'}
</TabButton>
))}
</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) => (
<TabButton key={f} variant="pill" active={collectTypeFilter === f} onClick={() => setCollectTypeFilter(f)} className="px-2.5 py-1 text-[10px]">
{f || '전체'}
</TabButton>
))}
<span className="text-[10px] text-hint ml-3">:</span>
{(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => (
<TabButton key={f} variant="pill" active={collectStatusFilter === f} onClick={() => setCollectStatusFilter(f)} className="px-2.5 py-1 text-[10px]">
{f || '전체'}
</TabButton>
))}
<div className="ml-auto">
<Button variant="primary" size="sm" icon={<Plus className="w-3 h-3" />}>
</Button>
</div>
</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) => (
<TabButton key={f} variant="pill" active={loadStatusFilter === f} onClick={() => setLoadStatusFilter(f)} className="px-2.5 py-1 text-[10px]">
{f || '전체'}
</TabButton>
))}
<div className="ml-auto flex items-center gap-2">
<Button variant="secondary" size="sm" icon={<FolderOpen className="w-3 h-3" />}>
</Button>
<Button variant="primary" size="sm" icon={<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) => (
<TabButton key={f} variant="pill" active={agentRoleFilter === f} onClick={() => setAgentRoleFilter(f)} className="px-2.5 py-1 text-[10px]">
{f || '전체'}
</TabButton>
))}
<span className="text-[10px] text-hint ml-3">:</span>
{(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => (
<TabButton key={f} variant="pill" active={agentStatusFilter === f} onClick={() => setAgentStatusFilter(f)} className="px-2.5 py-1 text-[10px]">
{f || '전체'}
</TabButton>
))}
<div className="ml-auto">
<Button variant="secondary" size="sm" icon={<RefreshCw className="w-3 h-3" />}>
</Button>
</div>
</div>
{/* 연계서버 카드 그리드 */}
<div className="grid grid-cols-2 gap-3">
{filteredAgents.map((agent) => {
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 intent={jobStatusIntent(agent.status)} size="xs">{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 type="button" className="p-1 text-hint hover:text-label" title="상태 상세"><Eye className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-heading" title="이름 변경"><Edit2 className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-heading" title="삭제"><Trash2 className="w-3 h-3" /></button>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
)}
</PageContainer>
);
}