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>
676 lines
41 KiB
TypeScript
676 lines
41 KiB
TypeScript
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>
|
||
);
|
||
}
|