kcg-ai-monitoring/frontend/src/features/admin/DataHub.tsx
htlee c51873ab85 fix(frontend): a11y/호환성 — backdrop-filter webkit prefix + Button/Input/Select 접근 이름
axe/forms/backdrop 에러 3종 모두 해결:

1) CSS: backdrop-filter Safari 호환성
   - design-system CSS에 -webkit-backdrop-filter 추가
   - trk-pulse 애니메이션을 outline-color → opacity로 변경
     (composite만 트리거, paint/layout 없음 → 더 나은 성능)

2) 아이콘 전용 <button> aria-label 추가 (9곳):
   - MainLayout 알림 버튼 → '알림'
   - UserRoleAssignDialog 닫기 → '닫기'
   - AIAssistant/MLOpsPage 전송 → '전송'
   - ChinaFishing 좌/우 네비 → '이전'/'다음'
   - 공통 컴포넌트 (PrintButton/ExcelExport/SaveButton) type=button 누락 보정

3) <input>/<textarea> 접근 이름 27곳 추가:
   - 로그인 폼, ParentReview/LabelSession/ParentExclusion 폼 (10)
   - NoticeManagement 제목/내용/시작일/종료일 (4)
   - SystemConfig/DataHub/PermissionsPanel 검색·역할 입력 (5)
   - VesselDetail 조회 시작/종료/MMSI (3)
   - GearIdentification InputField에 label prop 추가
   - AIAssistant/MLOpsPage 질의 input/textarea
   - MainLayout 페이지 내 검색
   - 공통 placeholder → aria-label 자동 복제 (3)

Button 컴포넌트에는 접근성 정책 JSDoc 명시 (타입 강제는 API 복잡도 대비
이득 낮아 문서 가이드 + 코드 리뷰로 대응).

검증:
- 실제 위반 지표: inaccessible button 0, inaccessible input 0, textarea 0
- tsc , eslint , vite build 
- dist CSS에 -webkit-backdrop-filter 확인됨
2026-04-08 13:04:23 +09:00

671 lines
39 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 { 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 { SaveButton } from '@shared/components/common/SaveButton';
import { getConnectionStatusHex } from '@shared/constants/connectionStatuses';
import type { BadgeIntent } from '@lib/theme/variants';
/** 수집/적재 작업 상태 → BadgeIntent 매핑 (DataHub 로컬 전용) */
function jobStatusIntent(s: string): BadgeIntent {
if (s === '수행중') return 'success';
if (s === '대기중') return 'warning';
if (s === '장애발생') return 'critical';
return 'muted';
}
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() },
];
// 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-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 intent="purple" size="sm">{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 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" 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: 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;
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 type="button" className="p-1 text-hint hover:text-green-400" title="시작"><Play className="w-3 h-3" /></button>
) : row.status !== '장애발생' ? (
<button type="button" className="p-1 text-hint hover:text-orange-400" title="정지"><Square className="w-3 h-3" /></button>
) : null}
<button type="button" className="p-1 text-hint hover:text-blue-400" title="편집"><Edit2 className="w-3 h-3" /></button>
<button type="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 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-blue-400" title="편집"><Edit2 className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-cyan-400" title="이력"><FileText className="w-3 h-3" /></button>
<button type="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 (
<PageContainer>
<PageHeader
icon={Database}
iconColor="text-cyan-400"
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-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>
{/* 탭 */}
<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="수신 현황 기준일"
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-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) => (
<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-blue-400" title="상태 상세"><Eye className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-yellow-400" title="이름 변경"><Edit2 className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-red-400" title="삭제"><Trash2 className="w-3 h-3" /></button>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
)}
</PageContainer>
);
}