30개 파일 전 영역에 동일한 패턴으로 SSOT 준수:
**StatBox 재설계 (2파일)**:
- RealGearGroups, RealVesselAnalysis 의 `color: string` prop 제거
- `intent: BadgeIntent` prop + `INTENT_TEXT_CLASS` 매핑 도입
**raw `<button>` → Button 컴포넌트 (다수)**:
- `bg-blue-600 hover:bg-blue-500 text-on-vivid ...` → `<Button variant="primary">`
- `bg-orange-600 ...` / `bg-green-600 ...` → `<Button variant="primary">`
- `bg-red-600 ...` → `<Button variant="destructive">`
- 아이콘 전용 → `<Button variant="ghost" aria-label=".." icon={...} />`
- detection/enforcement/admin/parent-inference/statistics/ai-operations/auth 전영역
**raw `<input>` → Input 컴포넌트**:
- parent-inference (ParentReview, ParentExclusion, LabelSession)
- admin (PermissionsPanel, UserRoleAssignDialog)
- ai-operations (AIAssistant)
- auth (LoginPage)
**raw `<select>` → Select 컴포넌트**:
- detection (RealGearGroups, RealVesselAnalysis, ChinaFishing)
**커스텀 탭 → TabBar/TabButton (segmented/underline)**:
- ChinaFishing: 모드 탭 + 선박 탭 + 통계 탭
**raw `<input type="checkbox">` → Checkbox**:
- GearDetection FilterCheckGroup
**하드코딩 Tailwind 색상 라이트/다크 쌍 변환 (전영역)**:
- `text-red-400` → `text-red-600 dark:text-red-400`
- `text-green-400` → `text-green-600 dark:text-green-400`
- blue/cyan/orange/yellow/purple/amber 동일 패턴
- `text-*-500` 아이콘도 `text-*-600 dark:text-*-500` 로 라이트 모드 대응
- 상태 dot (bg-red-500 animate-pulse 등)은 의도적 시각 구분이므로 유지
**에러 메시지 한글 → t('error.errorPrefix') 통일**:
- detection/parent-inference/admin 에서 `에러: {error}` 패턴 → `t('error.errorPrefix', { msg: error })`
**결과**: tsc 0 errors / eslint 0 errors (84 warnings 기존)
568 lines
38 KiB
TypeScript
568 lines
38 KiB
TypeScript
import { useState } from 'react';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { Button } from '@shared/components/ui/button';
|
||
import { Card, CardContent } from '@shared/components/ui/card';
|
||
import { Badge } from '@shared/components/ui/badge';
|
||
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||
import { getModelStatusIntent, getQualityGateIntent, getExperimentIntent, MODEL_STATUSES, QUALITY_GATE_STATUSES, EXPERIMENT_STATUSES } from '@shared/constants/modelDeploymentStatuses';
|
||
import { getStatusIntent } from '@shared/constants/statusIntent';
|
||
import {
|
||
Cpu, Brain, Database, GitBranch, Activity, RefreshCw, Server, Shield,
|
||
FileText, Settings, Layers, Globe, Lock, BarChart3, Code, Play, Square,
|
||
Rocket, Zap, FlaskConical, Search, ChevronRight, CheckCircle, XCircle,
|
||
AlertTriangle, Eye, Terminal, MessageSquare, Send, Bot, User,
|
||
} from 'lucide-react';
|
||
|
||
/*
|
||
* SFR-18: 기계학습 운영 기능 (MLOps)
|
||
* SFR-19: 대규모 언어모델(LLM) 운영 기능 (LLMOps)
|
||
*
|
||
* WING AI 플랫폼 운영 대시보드 반영:
|
||
* ① 운영 대시보드 ② Experiment Studio ③ Model Registry
|
||
* ④ Deploy Center ⑤ API Playground ⑥ LLMOps (학습/HPS/로그/워커/LLM테스트)
|
||
* ⑦ 플랫폼 관리
|
||
*/
|
||
|
||
type Tab = 'dashboard' | 'experiment' | 'registry' | 'deploy' | 'api' | 'llmops' | 'platform';
|
||
type LLMSubTab = 'train' | 'hps' | 'log' | 'worker' | 'llmtest';
|
||
|
||
// ─── 대시보드 KPI ──────────────────
|
||
const DASH_KPI = [
|
||
{ label: '고위험 (HIGH)', value: 8, sub: '↑3 전일비', color: '#ef4444', icon: AlertTriangle },
|
||
{ label: '중위험 (MED)', value: 16, sub: '↓2 전일비', color: '#f59e0b', icon: Eye },
|
||
{ label: '배포 중 모델', value: 3, sub: '최신 v2.1.0', color: '#10b981', icon: Rocket },
|
||
{ label: '진행 중 실험', value: 5, sub: '2건 완료 대기', color: '#3b82f6', icon: FlaskConical },
|
||
{ label: '등록 모델', value: 12, sub: '2건 승인 대기', color: '#8b5cf6', icon: GitBranch },
|
||
];
|
||
|
||
// ─── 실험 데이터 ──────────────────
|
||
const EXPERIMENTS = [
|
||
{ id: 'EXP-042', name: 'LSTM 서해A1 야간', template: 'AIS 위험도 예측', status: 'running', progress: 67, epoch: '34/50', f1: 0.891, time: '2h 14m' },
|
||
{ id: 'EXP-041', name: 'GNN 환적탐지 v3', template: '불법환적 네트워크', status: 'running', progress: 45, epoch: '23/50', f1: 0.823, time: '1h 50m' },
|
||
{ id: 'EXP-040', name: 'Transformer 궤적', template: '궤적 이상탐지', status: 'done', progress: 100, epoch: '50/50', f1: 0.912, time: '4h 30m' },
|
||
{ id: 'EXP-039', name: 'RF 어구분류 v2', template: '어구 자동분류', status: 'done', progress: 100, epoch: '100/100', f1: 0.876, time: '1h 12m' },
|
||
{ id: 'EXP-038', name: 'CNN 위성영상', template: '선박 탐지(SAR)', status: 'fail', progress: 23, epoch: '12/50', f1: 0, time: '0h 45m' },
|
||
];
|
||
|
||
const TEMPLATES = [
|
||
{ name: 'AIS 위험도 예측', icon: AlertTriangle, desc: 'LSTM+CNN 시계열 위험도' },
|
||
{ name: '궤적 이상탐지', icon: Activity, desc: 'Transformer 기반 경로 예측' },
|
||
{ name: '불법환적 네트워크', icon: Layers, desc: 'GNN 관계 분석' },
|
||
{ name: '어구 자동분류', icon: Search, desc: 'RF/XGBoost 앙상블' },
|
||
{ name: '선박 탐지(SAR)', icon: Globe, desc: 'CNN 위성영상 분석' },
|
||
{ name: 'Dark Vessel', icon: Eye, desc: 'SAR+RF 융합 탐지' },
|
||
];
|
||
|
||
// ─── 모델 레지스트리 ──────────────────
|
||
const MODELS = [
|
||
{ name: '불법조업 위험도 예측', ver: 'v2.1.0', status: 'DEPLOYED', accuracy: 93.2, f1: 92.3, recall: 91.5, precision: 93.1, falseAlarm: 7.8, gates: ['데이터검증', '성능검증', '편향검사', 'A/B테스트', '보안감사'], gateStatus: ['pass', 'pass', 'pass', 'pass', 'pass'] },
|
||
{ name: '경비함정 경로추천', ver: 'v1.5.2', status: 'DEPLOYED', accuracy: 89.7, f1: 88.4, recall: 87.2, precision: 89.6, falseAlarm: 10.3, gates: ['데이터검증', '성능검증', '편향검사', 'A/B테스트', '보안감사'], gateStatus: ['pass', 'pass', 'pass', 'pass', 'pass'] },
|
||
{ name: '불법어선 어망탐지', ver: 'v1.2.0', status: 'DEPLOYED', accuracy: 87.5, f1: 86.1, recall: 85.0, precision: 87.2, falseAlarm: 12.5, gates: ['데이터검증', '성능검증', '편향검사', 'A/B테스트', '보안감사'], gateStatus: ['pass', 'pass', 'pass', 'run', 'pend'] },
|
||
{ name: 'Transformer 궤적', ver: 'v0.9.0', status: 'APPROVED', accuracy: 91.2, f1: 90.5, recall: 89.8, precision: 91.2, falseAlarm: 8.8, gates: ['데이터검증', '성능검증', '편향검사', 'A/B테스트', '보안감사'], gateStatus: ['pass', 'pass', 'pass', 'pend', 'pend'] },
|
||
{ name: 'GNN 환적탐지 v3', ver: 'v0.3.0', status: 'TESTING', accuracy: 82.3, f1: 80.1, recall: 78.5, precision: 81.8, falseAlarm: 17.7, gates: ['데이터검증', '성능검증', '편향검사', 'A/B테스트', '보안감사'], gateStatus: ['pass', 'run', 'pend', 'pend', 'pend'] },
|
||
{ name: 'CNN 위성영상', ver: 'v0.1.0', status: 'DRAFT', accuracy: 0, f1: 0, recall: 0, precision: 0, falseAlarm: 0, gates: ['데이터검증', '성능검증', '편향검사', 'A/B테스트', '보안감사'], gateStatus: ['pend', 'pend', 'pend', 'pend', 'pend'] },
|
||
];
|
||
|
||
// ─── 배포 센터 ──────────────────
|
||
const DEPLOYS = [
|
||
{ model: '불법조업 위험도', ver: 'v2.1.0', endpoint: '/v1/infer/risk', traffic: 80, latency: '23ms', falseAlarm: '7.8%', rps: 142, status: '정상', date: '04-01' },
|
||
{ model: '불법조업 위험도', ver: 'v2.0.3', endpoint: '/v1/infer/risk', traffic: 20, latency: '25ms', falseAlarm: '9.9%', rps: 36, status: '카나리', date: '03-28' },
|
||
{ model: '경비함정 경로추천', ver: 'v1.5.2', endpoint: '/v1/infer/patrol', traffic: 100, latency: '45ms', falseAlarm: '10.3%', rps: 28, status: '정상', date: '03-15' },
|
||
{ model: '불법어선 어망탐지', ver: 'v1.2.0', endpoint: '/v1/infer/gear', traffic: 100, latency: '31ms', falseAlarm: '12.5%', rps: 15, status: '정상', date: '03-01' },
|
||
];
|
||
|
||
// ─── LLMOps 데이터 ──────────────────
|
||
const LLM_MODELS = [
|
||
{ name: 'Llama-3-8B', icon: Brain, sub: 'Meta 오픈소스' },
|
||
{ name: 'Qwen-2-7B', icon: Brain, sub: 'Alibaba' },
|
||
{ name: 'SOLAR-10.7B', icon: Brain, sub: 'Upstage' },
|
||
{ name: 'Gemma-2-9B', icon: Brain, sub: 'Google' },
|
||
{ name: 'Phi-3-mini', icon: Brain, sub: 'Microsoft 3.8B' },
|
||
{ name: 'Custom Upload', icon: GitBranch, sub: '직접 업로드' },
|
||
];
|
||
|
||
const TRAIN_JOBS = [
|
||
{ id: 'TRN-018', model: 'Llama-3-8B', status: 'running', progress: 72, elapsed: '3h 28m' },
|
||
{ id: 'TRN-017', model: 'Qwen-2-7B', status: 'done', progress: 100, elapsed: '5h 12m' },
|
||
{ id: 'TRN-016', model: 'SOLAR-10.7B', status: 'done', progress: 100, elapsed: '8h 45m' },
|
||
{ id: 'TRN-015', model: 'Llama-3-8B', status: 'fail', progress: 34, elapsed: '1h 20m' },
|
||
];
|
||
|
||
const HPS_TRIALS = [
|
||
{ trial: 1, lr: '3.2e-4', batch: 64, dropout: 0.2, hidden: 256, f1: 0.891, best: false },
|
||
{ trial: 2, lr: '1.0e-3', batch: 32, dropout: 0.3, hidden: 128, f1: 0.867, best: false },
|
||
{ trial: 3, lr: '5.5e-4', batch: 64, dropout: 0.1, hidden: 256, f1: 0.912, best: true },
|
||
{ trial: 4, lr: '2.1e-4', batch: 128, dropout: 0.2, hidden: 512, f1: 0.903, best: false },
|
||
{ trial: 5, lr: '8.0e-4', batch: 32, dropout: 0.4, hidden: 128, f1: 0.845, best: false },
|
||
];
|
||
|
||
const WORKERS = [
|
||
{ name: 'infer-risk-prod-01', model: '위험도 v2.1.0', gpu: 'Blackwell x2', status: 'ok', rps: 142, latency: '23ms', vram: '78%' },
|
||
{ name: 'infer-risk-canary-01', model: '위험도 v2.0.3', gpu: 'Blackwell x1', status: 'ok', rps: 36, latency: '25ms', vram: '45%' },
|
||
{ name: 'infer-patrol-01', model: '경로추천 v1.5.2', gpu: 'Blackwell x1', status: 'ok', rps: 28, latency: '45ms', vram: '52%' },
|
||
{ name: 'llm-serving-01', model: '해경LLM v1.0', gpu: 'H200 x2', status: 'ok', rps: 12, latency: '820ms', vram: '85%' },
|
||
{ name: 'llm-serving-02', model: '법령QA v1.0', gpu: 'Blackwell x1', status: 'warn', rps: 8, latency: '1.2s', vram: '92%' },
|
||
];
|
||
|
||
// ─── 메인 컴포넌트 ──────────────────
|
||
|
||
export function MLOpsPage() {
|
||
const { t } = useTranslation('ai');
|
||
const { t: tc } = useTranslation('common');
|
||
const [tab, setTab] = useState<Tab>('dashboard');
|
||
const [llmSub, setLlmSub] = useState<LLMSubTab>('train');
|
||
const [selectedTmpl, setSelectedTmpl] = useState(0);
|
||
const [selectedLLM, setSelectedLLM] = useState(0);
|
||
|
||
return (
|
||
<PageContainer>
|
||
<PageHeader
|
||
icon={Cpu}
|
||
iconColor="text-purple-600 dark:text-purple-400"
|
||
title={t('mlops.title')}
|
||
description={t('mlops.desc')}
|
||
demo
|
||
/>
|
||
|
||
{/* 탭 */}
|
||
<div className="flex gap-0 border-b border-border">
|
||
{([
|
||
{ key: 'dashboard' as Tab, icon: BarChart3, label: '대시보드' },
|
||
{ key: 'experiment' as Tab, icon: FlaskConical, label: 'Experiment Studio' },
|
||
{ key: 'registry' as Tab, icon: GitBranch, label: 'Model Registry' },
|
||
{ key: 'deploy' as Tab, icon: Rocket, label: 'Deploy Center' },
|
||
{ key: 'api' as Tab, icon: Zap, label: 'API Playground' },
|
||
{ key: 'llmops' as Tab, icon: Brain, label: 'LLMOps' },
|
||
{ key: 'platform' as Tab, icon: Settings, label: '플랫폼 관리' },
|
||
]).map(t => (
|
||
<button type="button" key={t.key} onClick={() => setTab(t.key)}
|
||
className={`flex items-center gap-1.5 px-4 py-2.5 text-[11px] font-medium border-b-2 transition-colors ${tab === t.key ? 'text-blue-600 dark:text-blue-400 border-blue-400' : 'text-hint border-transparent hover:text-label'}`}>
|
||
<t.icon className="w-3.5 h-3.5" />{t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* ── ① 대시보드 ── */}
|
||
{tab === 'dashboard' && (
|
||
<div className="space-y-3">
|
||
<div className="flex gap-2">
|
||
{DASH_KPI.map(k => (
|
||
<div key={k.label} className="flex-1 flex items-center gap-3 px-4 py-3 rounded-xl border border-border bg-card" style={{ borderLeftColor: k.color, borderLeftWidth: 3 }}>
|
||
<k.icon className="w-5 h-5" style={{ color: k.color }} />
|
||
<div><div className="text-xl font-bold" style={{ color: k.color }}>{k.value}</div><div className="text-[9px] text-hint">{k.label}</div><div className="text-[8px] text-hint">{k.sub}</div></div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Card><CardContent className="p-4">
|
||
<div className="text-[12px] font-bold text-label mb-3">배포 모델 현황</div>
|
||
<div className="space-y-2">{MODELS.filter(m => m.status === 'DEPLOYED').map(m => (
|
||
<div key={m.name} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
|
||
<Badge intent="success" size="sm">DEPLOYED</Badge>
|
||
<span className="text-[11px] text-heading font-medium flex-1">{m.name}</span>
|
||
<span className="text-[10px] text-hint">{m.ver}</span>
|
||
<span className="text-[10px] text-green-600 dark:text-green-400 font-bold">F1 {m.f1}%</span>
|
||
</div>
|
||
))}</div>
|
||
</CardContent></Card>
|
||
<Card><CardContent className="p-4">
|
||
<div className="text-[12px] font-bold text-label mb-3">진행 중 실험</div>
|
||
<div className="space-y-2">{EXPERIMENTS.filter(e => e.status === 'running').map(e => (
|
||
<div key={e.id} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
|
||
<Badge intent="info" size="sm" className="animate-pulse">실행중</Badge>
|
||
<span className="text-[11px] text-heading font-medium flex-1">{e.name}</span>
|
||
<div className="w-20 h-1.5 bg-switch-background rounded-full overflow-hidden"><div className="h-full bg-blue-500 rounded-full" style={{ width: `${e.progress}%` }} /></div>
|
||
<span className="text-[10px] text-muted-foreground">{e.progress}%</span>
|
||
</div>
|
||
))}</div>
|
||
</CardContent></Card>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── ② Experiment Studio ── */}
|
||
{tab === 'experiment' && (
|
||
<div className="space-y-3">
|
||
<Card><CardContent className="p-4">
|
||
<div className="text-[12px] font-bold text-heading mb-3">실험 템플릿 선택</div>
|
||
<div className="grid grid-cols-6 gap-2">
|
||
{TEMPLATES.map((t, i) => (
|
||
<div key={t.name} onClick={() => setSelectedTmpl(i)}
|
||
className={`p-3 rounded-lg text-center cursor-pointer transition-colors ${selectedTmpl === i ? 'bg-blue-600/15 border border-blue-500/30' : 'bg-surface-overlay border border-transparent hover:border-border'}`}>
|
||
<t.icon className="w-6 h-6 mx-auto mb-2 text-blue-600 dark:text-blue-400" />
|
||
<div className="text-[10px] font-bold text-heading">{t.name}</div>
|
||
<div className="text-[8px] text-hint mt-0.5">{t.desc}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent></Card>
|
||
<Card><CardContent className="p-4">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="text-[12px] font-bold text-heading">실험 목록</div>
|
||
<Button variant="primary" size="sm" icon={<Play className="w-3 h-3" />}>새 실험</Button>
|
||
</div>
|
||
<div className="space-y-2">
|
||
{EXPERIMENTS.map(e => (
|
||
<div key={e.id} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||
<span className="text-[10px] text-hint font-mono w-16">{e.id}</span>
|
||
<span className="text-[11px] text-heading font-medium w-40 truncate">{e.name}</span>
|
||
<Badge intent={getExperimentIntent(e.status)} size="sm" className={`w-14 ${EXPERIMENT_STATUSES[e.status as keyof typeof EXPERIMENT_STATUSES]?.pulse ? 'animate-pulse' : ''}`}>{e.status}</Badge>
|
||
<div className="w-24 h-1.5 bg-switch-background rounded-full overflow-hidden"><div className={`h-full rounded-full ${e.status === 'done' ? 'bg-green-500' : e.status === 'fail' ? 'bg-red-500' : 'bg-blue-500'}`} style={{ width: `${e.progress}%` }} /></div>
|
||
<span className="text-[10px] text-muted-foreground w-12">{e.epoch}</span>
|
||
<span className="text-[10px] text-muted-foreground w-16">{e.time}</span>
|
||
{e.f1 > 0 && <span className="text-[10px] text-cyan-600 dark:text-cyan-400 font-bold">F1 {e.f1}</span>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent></Card>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── ③ Model Registry ── */}
|
||
{tab === 'registry' && (
|
||
<div className="grid grid-cols-2 gap-3">
|
||
{MODELS.map(m => (
|
||
<Card key={m.name + m.ver} className="bg-surface-raised border-border"><CardContent className="p-4">
|
||
<div className="flex items-start justify-between mb-2">
|
||
<div><div className="text-[13px] font-bold text-heading">{m.name}</div><div className="text-[9px] text-hint mt-0.5">{m.ver}</div></div>
|
||
<Badge intent={getModelStatusIntent(m.status)} size="md" className="font-bold">{MODEL_STATUSES[m.status as keyof typeof MODEL_STATUSES]?.fallback.ko ?? m.status}</Badge>
|
||
</div>
|
||
{/* 성능 지표 */}
|
||
{m.accuracy > 0 && (
|
||
<div className="grid grid-cols-5 gap-1 mb-3">
|
||
{[['Acc', m.accuracy], ['F1', m.f1], ['Recall', m.recall], ['Prec', m.precision], ['FPR', m.falseAlarm]].map(([l, v]) => (
|
||
<div key={l as string} className="bg-background rounded px-2 py-1.5 text-center">
|
||
<div className="text-[11px] font-bold text-heading">{v as number}%</div>
|
||
<div className="text-[7px] text-hint">{l as string}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{/* 게이트 */}
|
||
<div className="text-[9px] text-hint mb-1.5 font-bold">Quality Gates</div>
|
||
<div className="flex gap-1">
|
||
{m.gates.map((g, i) => (
|
||
<Badge key={g} intent={getQualityGateIntent(m.gateStatus[i])} size="xs" className={QUALITY_GATE_STATUSES[m.gateStatus[i] as keyof typeof QUALITY_GATE_STATUSES]?.pulse ? 'animate-pulse' : ''}>{g}</Badge>
|
||
))}
|
||
</div>
|
||
</CardContent></Card>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── ④ Deploy Center ── */}
|
||
{tab === 'deploy' && (
|
||
<div className="space-y-3">
|
||
<Card><CardContent className="p-0">
|
||
<table className="w-full text-[11px]">
|
||
<thead><tr className="border-b border-border text-hint">
|
||
{['모델명', '버전', 'Endpoint', '트래픽%', '지연p95', '오탐율', 'RPS', '상태', '배포일'].map(h => (
|
||
<th key={h} className="px-3 py-2 text-left font-medium">{h}</th>
|
||
))}
|
||
</tr></thead>
|
||
<tbody>{DEPLOYS.map((d, i) => (
|
||
<tr key={i} className="border-b border-border hover:bg-surface-overlay">
|
||
<td className="px-3 py-2 text-heading font-medium">{d.model}</td>
|
||
<td className="px-3 py-2 text-cyan-600 dark:text-cyan-400 font-mono">{d.ver}</td>
|
||
<td className="px-3 py-2 text-muted-foreground font-mono text-[10px]">{d.endpoint}</td>
|
||
<td className="px-3 py-2"><div className="flex items-center gap-1.5"><div className="w-16 h-2 bg-switch-background rounded-full overflow-hidden"><div className="h-full bg-blue-500 rounded-full" style={{ width: `${d.traffic}%` }} /></div><span className="text-heading font-bold">{d.traffic}%</span></div></td>
|
||
<td className="px-3 py-2 text-label">{d.latency}</td>
|
||
<td className="px-3 py-2 text-label">{d.falseAlarm}</td>
|
||
<td className="px-3 py-2 text-heading font-bold">{d.rps}</td>
|
||
<td className="px-3 py-2"><Badge intent={getStatusIntent(d.status)} size="xs">{d.status}</Badge></td>
|
||
<td className="px-3 py-2 text-hint">{d.date}</td>
|
||
</tr>
|
||
))}</tbody>
|
||
</table>
|
||
</CardContent></Card>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Card><CardContent className="p-4">
|
||
<div className="text-[12px] font-bold text-heading mb-2">카나리 / A·B 테스트</div>
|
||
<div className="text-[10px] text-muted-foreground mb-3">위험도 v2.1.0 (80%) ↔ v2.0.3 (20%)</div>
|
||
<div className="h-5 bg-background rounded-lg overflow-hidden flex">
|
||
<div className="bg-blue-600 flex items-center justify-center text-[9px] text-on-vivid font-bold" style={{ width: '80%' }}>v2.1.0 80%</div>
|
||
<div className="bg-yellow-600 flex items-center justify-center text-[9px] text-on-vivid font-bold" style={{ width: '20%' }}>v2.0.3 20%</div>
|
||
</div>
|
||
</CardContent></Card>
|
||
<Card><CardContent className="p-4">
|
||
<div className="text-[12px] font-bold text-heading mb-2">승인 대기 → 배포 가능</div>
|
||
<div className="space-y-2">
|
||
{MODELS.filter(m => m.status === 'APPROVED').map(m => (
|
||
<div key={m.name} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
|
||
<span className="text-[11px] text-heading font-medium flex-1">{m.name} {m.ver}</span>
|
||
<Button variant="primary" size="sm" icon={<Rocket className="w-3 h-3" />}>배포</Button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent></Card>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── ⑤ API Playground ── */}
|
||
{tab === 'api' && (
|
||
<div className="grid grid-cols-2 gap-3" style={{ height: 'calc(100vh - 240px)' }}>
|
||
<Card className="bg-surface-raised border-border flex flex-col"><CardContent className="p-4 flex-1 flex flex-col">
|
||
<div className="text-[9px] font-bold text-hint mb-2">REQUEST BODY (JSON)</div>
|
||
<textarea aria-label="API 요청 본문 JSON" className="flex-1 bg-background border border-border rounded-lg p-3 text-[10px] text-cyan-300 font-mono resize-none focus:outline-none focus:border-blue-500/40" defaultValue={`{
|
||
"mmsi": "412345678",
|
||
"lat": 37.12,
|
||
"lon": 124.63,
|
||
"speed": 3.2,
|
||
"course": 225,
|
||
"timestamp": "2026-04-03T09:00:00Z",
|
||
"model": "fishing_illegal_risk",
|
||
"version": "v2.1.0"
|
||
}`} />
|
||
<div className="flex gap-2 mt-2">
|
||
<Button variant="primary" size="sm" icon={<Zap className="w-3 h-3" />}>실행</Button>
|
||
<button type="button" className="px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground">초기화</button>
|
||
</div>
|
||
</CardContent></Card>
|
||
<Card className="bg-surface-raised border-border flex flex-col"><CardContent className="p-4 flex-1 flex flex-col">
|
||
<div className="text-[9px] font-bold text-hint mb-2">RESPONSE</div>
|
||
<div className="flex gap-4 text-[10px] px-2 py-1.5 bg-green-500/10 rounded mb-2">
|
||
<span className="text-muted-foreground">상태 <span className="text-green-600 dark:text-green-400 font-bold">200 OK</span></span>
|
||
<span className="text-muted-foreground">지연 <span className="text-green-600 dark:text-green-400 font-bold">23ms</span></span>
|
||
</div>
|
||
<pre className="flex-1 bg-background border border-border rounded-lg p-3 text-[10px] text-green-300 font-mono overflow-auto">{`{
|
||
"risk_score": 87.5,
|
||
"risk_level": "HIGH",
|
||
"illegal_prob": 0.92,
|
||
"codes": [
|
||
{"code": "EEZ_VIOLATION", "weight": 0.35, "desc": "배타적경제수역 침범"},
|
||
{"code": "DARK_VESSEL", "weight": 0.28, "desc": "AIS 신호 비정상"},
|
||
{"code": "MMSI_SPOOF", "weight": 0.18, "desc": "MMSI 변조 이력"}
|
||
],
|
||
"model": "fishing_illegal_risk",
|
||
"version": "v2.1.0",
|
||
"inference_time_ms": 23,
|
||
"trace_id": "trc-a4f8c2e1"
|
||
}`}</pre>
|
||
</CardContent></Card>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── ⑥ LLMOps ── */}
|
||
{tab === 'llmops' && (
|
||
<div className="space-y-3">
|
||
<div className="flex gap-0 border-b border-border mb-2">
|
||
{([
|
||
{ key: 'train' as LLMSubTab, label: '학습 생성' },
|
||
{ key: 'hps' as LLMSubTab, label: '하이퍼파라미터 검색' },
|
||
{ key: 'log' as LLMSubTab, label: '학습 로그' },
|
||
{ key: 'worker' as LLMSubTab, label: '배포 워커' },
|
||
{ key: 'llmtest' as LLMSubTab, label: 'LLM 테스트' },
|
||
]).map(t => (
|
||
<button type="button" key={t.key} onClick={() => setLlmSub(t.key)}
|
||
className={`px-4 py-2 text-[11px] font-medium border-b-2 ${llmSub === t.key ? 'text-blue-600 dark:text-blue-400 border-blue-400' : 'text-hint border-transparent hover:text-label'}`}>{t.label}</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* 학습 생성 */}
|
||
{llmSub === 'train' && (
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="space-y-3">
|
||
<Card><CardContent className="p-4">
|
||
<div className="text-[12px] font-bold text-heading mb-3">Built-in 모델 선택</div>
|
||
<div className="grid grid-cols-3 gap-2">
|
||
{LLM_MODELS.map((m, i) => (
|
||
<div key={m.name} onClick={() => setSelectedLLM(i)}
|
||
className={`p-3 rounded-lg text-center cursor-pointer ${selectedLLM === i ? 'bg-blue-600/15 border border-blue-500/30' : 'bg-surface-overlay border border-transparent'}`}>
|
||
<m.icon className="w-5 h-5 mx-auto mb-1 text-purple-600 dark:text-purple-400" />
|
||
<div className="text-[10px] font-bold text-heading">{m.name}</div>
|
||
<div className="text-[8px] text-hint">{m.sub}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent></Card>
|
||
<Card><CardContent className="p-4">
|
||
<div className="text-[12px] font-bold text-heading mb-3">학습 파라미터</div>
|
||
<div className="grid grid-cols-2 gap-2 text-[10px]">
|
||
{[['데이터셋', 'AIS_서해A1_30D (128,450행)'], ['GPU', '2 × Blackwell'], ['Epochs', '50'], ['Batch', '64'], ['Learning Rate', '0.001'], ['Early Stop', '5 에포크']].map(([k, v]) => (
|
||
<div key={k} className="flex flex-col gap-1"><span className="text-[9px] text-hint">{k}</span><div className="bg-background border border-border rounded px-2.5 py-1.5 text-label">{v}</div></div>
|
||
))}
|
||
</div>
|
||
<Button variant="primary" size="sm" className="mt-3 w-full" icon={<Play className="w-3 h-3" />}>학습 시작</Button>
|
||
</CardContent></Card>
|
||
</div>
|
||
<Card><CardContent className="p-4">
|
||
<div className="text-[12px] font-bold text-heading mb-3">학습 작업 현황</div>
|
||
<div className="space-y-2">
|
||
{TRAIN_JOBS.map(j => (
|
||
<div key={j.id} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||
<span className="text-[10px] text-hint font-mono w-16">{j.id}</span>
|
||
<span className="text-[11px] text-heading w-24">{j.model}</span>
|
||
<Badge intent={j.status === 'running' ? 'info' : j.status === 'done' ? 'success' : 'critical'} size="xs" className="w-14 text-center">{j.status}</Badge>
|
||
<div className="flex-1 h-1.5 bg-switch-background rounded-full overflow-hidden"><div className={`h-full rounded-full ${j.status === 'done' ? 'bg-green-500' : j.status === 'fail' ? 'bg-red-500' : 'bg-blue-500'}`} style={{ width: `${j.progress}%` }} /></div>
|
||
<span className="text-[10px] text-muted-foreground w-16">{j.elapsed}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent></Card>
|
||
</div>
|
||
)}
|
||
|
||
{/* 하이퍼파라미터 검색 */}
|
||
{llmSub === 'hps' && (
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<Card><CardContent className="p-4">
|
||
<div className="text-[12px] font-bold text-heading mb-3">검색 설정</div>
|
||
<div className="space-y-2 text-[10px]">
|
||
{[['검색 방법', 'Bayesian Optimization'], ['Target Metric', 'val_f1'], ['최대 시도', '20']].map(([k, v]) => (
|
||
<div key={k}><span className="text-[9px] text-hint block mb-1">{k}</span><div className="bg-background border border-border rounded px-2.5 py-1.5 text-label">{v}</div></div>
|
||
))}
|
||
</div>
|
||
<div className="mt-3 text-[9px] font-bold text-hint mb-2">파라미터 범위</div>
|
||
<div className="space-y-1 text-[9px]">
|
||
{[['learning_rate', '1e-4 ~ 1e-2'], ['batch_size', '16 ~ 128'], ['dropout', '0.1 ~ 0.5'], ['hidden_dim', '64 ~ 512']].map(([k, v]) => (
|
||
<div key={k} className="flex justify-between px-2 py-1 bg-surface-overlay rounded"><span className="text-hint font-mono">{k}</span><span className="text-label">{v}</span></div>
|
||
))}
|
||
</div>
|
||
<Button variant="primary" size="sm" className="mt-3 w-full" icon={<Search className="w-3 h-3" />}>검색 시작</Button>
|
||
</CardContent></Card>
|
||
<Card className="col-span-2 bg-surface-raised border-border"><CardContent className="p-4">
|
||
<div className="flex justify-between mb-3"><div className="text-[12px] font-bold text-heading">HPS 시도 결과</div><span className="text-[10px] text-green-600 dark:text-green-400 font-bold">Best: Trial #3 (F1=0.912)</span></div>
|
||
<table className="w-full text-[10px]">
|
||
<thead><tr className="border-b border-border text-hint">{['Trial', 'LR', 'Batch', 'Dropout', 'Hidden', 'F1 Score', ''].map(h => <th key={h} className="py-2 px-2 text-left font-medium">{h}</th>)}</tr></thead>
|
||
<tbody>{HPS_TRIALS.map(t => (
|
||
<tr key={t.trial} className={`border-b border-border ${t.best ? 'bg-green-500/5' : ''}`}>
|
||
<td className="py-2 px-2 text-heading font-bold">#{t.trial}</td>
|
||
<td className="py-2 px-2 text-muted-foreground font-mono">{t.lr}</td>
|
||
<td className="py-2 px-2 text-muted-foreground">{t.batch}</td>
|
||
<td className="py-2 px-2 text-muted-foreground">{t.dropout}</td>
|
||
<td className="py-2 px-2 text-muted-foreground">{t.hidden}</td>
|
||
<td className="py-2 px-2 text-heading font-bold">{t.f1.toFixed(3)}</td>
|
||
<td className="py-2 px-2">{t.best && <Badge intent="success" size="xs">BEST</Badge>}</td>
|
||
</tr>
|
||
))}</tbody>
|
||
</table>
|
||
</CardContent></Card>
|
||
</div>
|
||
)}
|
||
|
||
{/* 학습 로그 */}
|
||
{llmSub === 'log' && (
|
||
<Card><CardContent className="p-4">
|
||
<div className="text-[12px] font-bold text-heading mb-3">학습 로그 (TRN-018 Llama-3-8B)</div>
|
||
<pre className="bg-background border border-border rounded-lg p-3 text-[9px] text-cyan-300 font-mono h-64 overflow-auto leading-relaxed">{`[09:20:01] Loading model: Llama-3-8B (8B params)
|
||
[09:20:15] Dataset: AIS_서해A1_30D — 128,450 rows loaded
|
||
[09:20:16] Train/Val/Test split: 80% / 10% / 10%
|
||
[09:20:18] GPU: 2 × NVIDIA Blackwell (48GB VRAM each)
|
||
[09:20:19] Training started — Epochs: 50, Batch: 64, LR: 0.001
|
||
[09:20:19] ──────────────────────────────────────
|
||
[09:21:42] Epoch 1/50 | loss: 2.341 | val_loss: 2.218 | val_f1: 0.312 | 83s
|
||
[09:23:05] Epoch 2/50 | loss: 1.876 | val_loss: 1.745 | val_f1: 0.456 | 83s
|
||
[09:24:28] Epoch 3/50 | loss: 1.523 | val_loss: 1.402 | val_f1: 0.578 | 83s
|
||
...
|
||
[12:15:33] Epoch 35/50 | loss: 0.087 | val_loss: 0.092 | val_f1: 0.891 | 83s
|
||
[12:16:56] Epoch 36/50 | loss: 0.084 | val_loss: 0.089 | val_f1: 0.894 | 83s ★ Best
|
||
[12:18:19] ⏳ Training in progress... (72% complete)`}</pre>
|
||
</CardContent></Card>
|
||
)}
|
||
|
||
{/* 배포 워커 */}
|
||
{llmSub === 'worker' && (
|
||
<div className="space-y-2">
|
||
{WORKERS.map(w => (
|
||
<Card key={w.name} className="bg-surface-raised border-border"><CardContent className="p-3 flex items-center gap-4">
|
||
<div className={`w-2.5 h-2.5 rounded-full ${w.status === 'ok' ? 'bg-green-500 shadow-[0_0_6px_#22c55e]' : 'bg-yellow-500 shadow-[0_0_6px_#eab308]'}`} />
|
||
<div className="w-44"><div className="text-[11px] font-bold text-heading">{w.name}</div><div className="text-[9px] text-hint">{w.model}</div></div>
|
||
<div className="flex gap-3 text-[9px]">
|
||
{[['GPU', w.gpu], ['RPS', String(w.rps)], ['Latency', w.latency], ['VRAM', w.vram]].map(([k, v]) => (
|
||
<span key={k} className="px-2 py-1 bg-surface-overlay border border-border rounded text-muted-foreground"><span className="text-hint">{k}</span> <span className="text-heading font-bold">{v}</span></span>
|
||
))}
|
||
</div>
|
||
</CardContent></Card>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* LLM 테스트 */}
|
||
{llmSub === 'llmtest' && (
|
||
<div className="flex gap-3" style={{ height: 'calc(100vh - 280px)' }}>
|
||
<Card className="w-52 shrink-0 bg-surface-raised border-border"><CardContent className="p-3">
|
||
<div className="text-[10px] font-bold text-muted-foreground mb-2">모델 선택</div>
|
||
{['해경 LLM v1.0', '법령 QA v1.0'].map(m => (
|
||
<div key={m} className="px-2 py-1.5 rounded text-[10px] text-label hover:bg-surface-overlay cursor-pointer">{m}</div>
|
||
))}
|
||
<div className="text-[10px] font-bold text-muted-foreground mt-3 mb-2">프롬프트 프리셋</div>
|
||
{['불법조업 판단', '법령 해석', '단속 보고서'].map(p => (
|
||
<div key={p} className="px-2 py-1.5 rounded text-[10px] text-muted-foreground hover:bg-surface-overlay cursor-pointer">{p}</div>
|
||
))}
|
||
</CardContent></Card>
|
||
<Card className="flex-1 bg-surface-raised border-border flex flex-col"><CardContent className="p-4 flex-1 flex flex-col">
|
||
<div className="flex-1 space-y-3 overflow-auto mb-3">
|
||
<div className="flex justify-end"><div className="bg-blue-600/15 border border-blue-500/20 rounded-xl rounded-br-sm px-4 py-2.5 max-w-[70%] text-[11px] text-foreground">서해 NLL 인근에서 AIS 신호가 소실된 중국어선의 불법조업 판별 기준은?</div></div>
|
||
<div className="flex"><div className="bg-surface-overlay border border-border rounded-xl rounded-bl-sm px-4 py-2.5 max-w-[70%] text-[11px] text-foreground leading-relaxed">
|
||
<p className="mb-2">AIS 신호 소실 중국어선의 불법조업 판별은 다음 기준으로 수행됩니다:</p>
|
||
<p className="text-[10px] text-muted-foreground">1. **AIS 소실 시간**: 6시간 이상 미수신 시 Dark Vessel로 분류</p>
|
||
<p className="text-[10px] text-muted-foreground">2. **최종 위치**: EEZ/NLL 경계 5NM 이내 여부</p>
|
||
<p className="text-[10px] text-muted-foreground">3. **과거 이력**: MMSI 변조, 이전 단속 기록 확인</p>
|
||
<div className="mt-2 pt-2 border-t border-border flex gap-1">
|
||
<Badge intent="success" size="xs">배타적경제수역법 §5</Badge>
|
||
<Badge intent="success" size="xs">한중어업협정 §6</Badge>
|
||
</div>
|
||
</div></div>
|
||
</div>
|
||
<div className="flex gap-2 shrink-0">
|
||
<input aria-label="LLM 질의 입력" className="flex-1 bg-background border border-border rounded-xl px-4 py-2 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/40" placeholder="질의를 입력하세요..." />
|
||
<Button variant="primary" size="md" aria-label={tc('aria.send')} icon={<Send className="w-4 h-4" />} />
|
||
</div>
|
||
</CardContent></Card>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── ⑦ 플랫폼 관리 ── */}
|
||
{tab === 'platform' && (
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Card><CardContent className="p-4">
|
||
<div className="text-[12px] font-bold text-heading mb-3">GPU 리소스 현황</div>
|
||
<div className="space-y-2">
|
||
{[{ name: 'Blackwell #1', usage: 78, mem: '38/48GB', temp: '62°C' }, { name: 'Blackwell #2', usage: 52, mem: '25/48GB', temp: '55°C' }, { name: 'H200 #1', usage: 85, mem: '68/80GB', temp: '71°C' }, { name: 'H200 #2', usage: 45, mem: '36/80GB', temp: '48°C' }].map(g => (
|
||
<div key={g.name} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
|
||
<span className="text-[10px] text-heading font-medium w-24">{g.name}</span>
|
||
<div className="flex-1 h-2 bg-switch-background rounded-full overflow-hidden"><div className={`h-full rounded-full ${g.usage > 80 ? 'bg-red-500' : g.usage > 60 ? 'bg-yellow-500' : 'bg-green-500'}`} style={{ width: `${g.usage}%` }} /></div>
|
||
<span className="text-[10px] text-heading font-bold w-8">{g.usage}%</span>
|
||
<span className="text-[9px] text-hint">{g.mem}</span>
|
||
<span className="text-[9px] text-hint">{g.temp}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent></Card>
|
||
<Card><CardContent className="p-4">
|
||
<div className="text-[12px] font-bold text-heading mb-3">서비스 상태</div>
|
||
<div className="space-y-2">
|
||
{[{ name: 'API Gateway', status: 'ok', rps: 221 }, { name: 'Model Serving', status: 'ok', rps: 186 }, { name: 'Feature Store', status: 'ok', rps: 45 }, { name: 'Vector DB (Milvus)', status: 'ok', rps: 32 }, { name: 'Kafka Cluster', status: 'warn', rps: 1250 }, { name: 'PostgreSQL', status: 'ok', rps: 890 }].map(s => (
|
||
<div key={s.name} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
|
||
<div className={`w-2 h-2 rounded-full ${s.status === 'ok' ? 'bg-green-500 shadow-[0_0_4px_#22c55e]' : 'bg-yellow-500 shadow-[0_0_4px_#eab308]'}`} />
|
||
<span className="text-[10px] text-heading font-medium flex-1">{s.name}</span>
|
||
<span className="text-[10px] text-muted-foreground">{s.rps} req/s</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent></Card>
|
||
<Card><CardContent className="p-4">
|
||
<div className="text-[12px] font-bold text-heading mb-3">보안 및 접근제어</div>
|
||
<div className="space-y-1.5 text-[10px]">
|
||
{[['인증 방식', 'API Key + JWT 토큰'], ['접근제어', 'RBAC (역할기반)'], ['민감정보', '마스킹 적용'], ['Rate Limiting', '100 req/min'], ['감사 로그', 'User→LLM→MCP 전체 추적'], ['Kill Switch', '활성 (긴급 차단 가능)']].map(([k, v]) => (
|
||
<div key={k} className="flex justify-between px-2 py-1.5 bg-surface-overlay rounded"><span className="text-hint">{k}</span><span className="text-label">{v}</span></div>
|
||
))}
|
||
</div>
|
||
</CardContent></Card>
|
||
<Card><CardContent className="p-4">
|
||
<div className="text-[12px] font-bold text-heading mb-3">데이터 파이프라인</div>
|
||
<div className="space-y-1.5 text-[10px]">
|
||
{[['Feature Store', 'v3.2 (20개 피처) · 2.4TB'], ['학습 데이터', '1,456,200건 (04-03 갱신)'], ['벡터 DB', '1.2M 문서 · 3.6M 벡터'], ['CI/CD', '마지막 빌드 성공 (09:15)'], ['드리프트 점수', '0.012 (정상)'], ['재학습 트리거', '비활성']].map(([k, v]) => (
|
||
<div key={k} className="flex justify-between px-2 py-1.5 bg-surface-overlay rounded"><span className="text-hint">{k}</span><span className="text-label">{v}</span></div>
|
||
))}
|
||
</div>
|
||
</CardContent></Card>
|
||
</div>
|
||
)}
|
||
</PageContainer>
|
||
);
|
||
}
|