kcg-ai-monitoring/frontend/src/features/parent-inference/LabelSession.tsx
htlee bae2f33b86 feat: Phase 4 - 모선 워크플로우 + 관리자 화면 + 권한 라우트 가드
Phase 4-1: 운영자 워크플로우 백엔드 (자체 DB)
- ParentResolution / ParentReviewLog / CandidateExclusion / LabelSession 엔티티
- Repository 4종 + DTO 5종
- ParentInferenceWorkflowService (HYBRID 패턴):
  - review (CONFIRM/REJECT/RESET) - parent-inference-workflow:parent-review (UPDATE)
  - excludeForGroup - parent-inference-workflow:parent-exclusion (CREATE)
  - excludeGlobal - parent-inference-workflow:exclusion-management (CREATE) [admin]
  - releaseExclusion (UPDATE)
  - createLabelSession / cancelLabelSession (CREATE/UPDATE)
- ParentInferenceWorkflowController: @RequirePermission으로 권한 강제
- 모든 액션에 @Auditable AOP → audit_log + review_log 동시 기록

Phase 4-2: PermTreeController + AdminLogController
- GET /api/perm-tree (모든 사용자) - 메뉴/사이드바 구성용
- GET /api/roles (admin:role-management) - 역할+권한 매트릭스
- GET /api/admin/audit-logs / access-logs / login-history

Phase 4-3: iran 백엔드 프록시 (stub)
- IranBackendClient: RestClient 기반, 호출 실패 시 null 반환 (graceful)
- VesselAnalysisProxyController: serviceAvailable=false 응답
- PredictionProxyController: DISCONNECTED 응답
- Phase 5에서 iran 백엔드 실 연결 시 코드 변경 최소

Phase 4-4: 프론트엔드 services
- parentInferenceApi.ts: 모선 워크플로우 22개 함수
- adminApi.ts: 감사로그/접근이력/로그인이력/권한트리/역할 조회

Phase 4-5: 사이드바 권한 필터링 + ProtectedRoute 권한 가드
- AuthContext.PATH_TO_RESOURCE에 신규 경로 매핑 추가
- ProtectedRoute에 resource/operation prop 추가
  → 권한 거부 시 403 페이지 표시
- 모든 라우트에 권한 리소스 명시
- MainLayout 사이드바: parent-inference-workflow + admin 로그 메뉴 추가
- 사이드바 hasAccess 필터링 (이전부터 구현됨, 신규 메뉴에도 자동 적용)

Phase 4-6: 신규 페이지 3종
- ParentReview.tsx: 모선 확정/거부/리셋 + 신규 등록 폼
- ParentExclusion.tsx: GROUP/GLOBAL 제외 등록 + 해제
- LabelSession.tsx: 학습 세션 생성/취소
- AuditLogs.tsx: 감사 로그 조회
- AccessLogs.tsx: 접근 이력 조회
- LoginHistoryView.tsx: 로그인 이력 조회

Phase 4-7: i18n 키 + 라우터 등록
- 한국어/영어 nav.* + group.* 키 추가
- App.tsx에 12개 신규 라우트 등록 + 권한 가드 적용

Phase 4-8: 검증 완료
- 백엔드 컴파일/기동 성공
- 프론트엔드 빌드 성공 (475ms)
- E2E 시나리오:
  - operator 로그인 → CONFIRM 확정 → MANUAL_CONFIRMED 갱신
  - operator GROUP 제외 → 성공
  - operator GLOBAL 제외 → 403 FORBIDDEN (권한 없음)
  - operator 학습 세션 생성 → ACTIVE
  - admin GLOBAL 제외 → 성공
  - 감사 로그 자동 기록: REVIEW_PARENT/EXCLUDE_CANDIDATE_GROUP/
    LABEL_PARENT_CREATE/EXCLUDE_CANDIDATE_GLOBAL 등 14건
  - 권한 트리 RBAC + AOP 정상 동작 확인

설계 핵심:
- 운영자 의사결정만 자체 DB에 저장 (HYBRID)
- iran 백엔드 데이터는 향후 Phase 5에서 합쳐서 표시
- @RequirePermission + @Auditable로 모든 액션 권한 + 감사 자동화
- 데모 계정으로 완전한 워크플로우 시연 가능

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 09:44:43 +09:00

186 lines
8.1 KiB
TypeScript

import { useEffect, useState, useCallback } from 'react';
import { Tag, X, Loader2 } from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { useAuth } from '@/app/auth/AuthContext';
import {
fetchLabelSessions,
createLabelSession,
cancelLabelSession,
type LabelSession as LabelSessionType,
} from '@/services/parentInferenceApi';
/**
* 모선 추론 학습 세션 페이지.
* 운영자가 정답 라벨링 → prediction 모델 학습 데이터로 활용.
*
* 권한: parent-inference-workflow:label-session (READ + CREATE + UPDATE)
*/
const STATUS_COLORS: Record<string, string> = {
ACTIVE: 'bg-green-500/20 text-green-400',
CANCELLED: 'bg-gray-500/20 text-gray-400',
COMPLETED: 'bg-blue-500/20 text-blue-400',
};
export function LabelSession() {
const { hasPermission } = useAuth();
const canCreate = hasPermission('parent-inference-workflow:label-session', 'CREATE');
const canUpdate = hasPermission('parent-inference-workflow:label-session', 'UPDATE');
const [items, setItems] = useState<LabelSessionType[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [filter, setFilter] = useState<string>('');
const [busy, setBusy] = useState<number | null>(null);
// 신규 세션
const [groupKey, setGroupKey] = useState('');
const [subCluster, setSubCluster] = useState('1');
const [labelMmsi, setLabelMmsi] = useState('');
const load = useCallback(async () => {
setLoading(true); setError('');
try {
const res = await fetchLabelSessions(filter || undefined, 0, 50);
setItems(res.content);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'unknown');
} finally {
setLoading(false);
}
}, [filter]);
useEffect(() => { load(); }, [load]);
const handleCreate = async () => {
if (!canCreate || !groupKey || !labelMmsi) return;
setBusy(-1);
try {
await createLabelSession(groupKey, parseInt(subCluster, 10), {
labelParentMmsi: labelMmsi,
anchorSnapshot: { source: 'manual', timestamp: new Date().toISOString() },
});
setGroupKey(''); setLabelMmsi('');
await load();
} catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
} finally {
setBusy(null);
}
};
const handleCancel = async (id: number) => {
if (!canUpdate) return;
if (!confirm('세션을 취소하시겠습니까?')) return;
setBusy(id);
try {
await cancelLabelSession(id, '운영자 취소');
await load();
} catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
} finally {
setBusy(null);
}
};
return (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-heading"> </h1>
<p className="text-xs text-hint mt-1"> prediction </p>
</div>
<div className="flex items-center gap-2">
<select value={filter} onChange={(e) => setFilter(e.target.value)}
className="bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs text-heading">
<option value=""> </option>
<option value="ACTIVE">ACTIVE</option>
<option value="CANCELLED">CANCELLED</option>
<option value="COMPLETED">COMPLETED</option>
</select>
<button type="button" onClick={load} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded"></button>
</div>
</div>
<Card>
<CardContent className="p-4">
<div className="text-xs font-medium text-heading mb-2 flex items-center gap-2">
<Tag className="w-3.5 h-3.5" />
{!canCreate && <span className="text-yellow-400 text-[10px]"> </span>}
</div>
<div className="flex items-center gap-2">
<input value={groupKey} onChange={(e) => setGroupKey(e.target.value)} placeholder="group_key"
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} />
<input type="number" value={subCluster} onChange={(e) => setSubCluster(e.target.value)} placeholder="sub"
className="w-24 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} />
<input value={labelMmsi} onChange={(e) => setLabelMmsi(e.target.value)} placeholder="정답 parent MMSI"
className="w-48 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} />
<button type="button" onClick={handleCreate}
disabled={!canCreate || !groupKey || !labelMmsi || busy === -1}
className="px-3 py-1.5 bg-purple-600 hover:bg-purple-500 disabled:bg-purple-600/40 text-white text-xs rounded flex items-center gap-1">
{busy === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Tag className="w-3.5 h-3.5" />}
</button>
</div>
</CardContent>
</Card>
{error && <div className="text-xs text-red-400">: {error}</div>}
{loading && (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" />
</div>
)}
{!loading && (
<Card>
<CardContent className="p-0 overflow-x-auto">
<table className="w-full text-xs">
<thead className="bg-surface-overlay text-hint">
<tr>
<th className="px-3 py-2 text-left">ID</th>
<th className="px-3 py-2 text-left">Group Key</th>
<th className="px-3 py-2 text-center">Sub</th>
<th className="px-3 py-2 text-left"> MMSI</th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-center"></th>
</tr>
</thead>
<tbody>
{items.length === 0 && (
<tr><td colSpan={8} className="px-3 py-8 text-center text-hint"> .</td></tr>
)}
{items.map((it) => (
<tr key={it.id} className="border-t border-border hover:bg-surface-overlay/50">
<td className="px-3 py-2 text-hint font-mono">{it.id}</td>
<td className="px-3 py-2 text-heading font-medium">{it.groupKey}</td>
<td className="px-3 py-2 text-center text-muted-foreground">{it.subClusterId}</td>
<td className="px-3 py-2 text-cyan-400 font-mono">{it.labelParentMmsi}</td>
<td className="px-3 py-2">
<Badge className={`border-0 text-[9px] ${STATUS_COLORS[it.status] || ''}`}>{it.status}</Badge>
</td>
<td className="px-3 py-2 text-muted-foreground">{it.createdByAcnt || '-'}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{new Date(it.activeFrom).toLocaleString('ko-KR')}</td>
<td className="px-3 py-2 text-center">
{it.status === 'ACTIVE' && (
<button type="button" disabled={!canUpdate || busy === it.id} onClick={() => handleCancel(it.id)}
className="p-1 rounded hover:bg-red-500/20 disabled:opacity-30 text-red-400" title="취소">
<X className="w-3.5 h-3.5" />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
)}
</div>
);
}