kcg-ai-monitoring/frontend/src/features/ai-operations/MLOpsPage.tsx
htlee c1cc36b134 refactor(design-system): 하드코딩 색상 라이트/다크 대응 + raw button/input 공통 컴포넌트 치환
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 기존)
2026-04-16 17:09:14 +09:00

568 lines
38 KiB
TypeScript
Raw Blame 히스토리

This file contains ambiguous Unicode characters

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

import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { 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>
);
}