Merge pull request 'fix(ui): 모니터링/디자인시스템 런타임 에러 해소' (#88) from fix/runtime-errors-monitoring-designsystem into develop
This commit is contained in:
커밋
afde36480d
@ -4,6 +4,9 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 수정
|
||||
- **모니터링/디자인시스템 런타임 에러 해소** — `/monitoring` 의 `SystemStatusPanel` 에서 `stats.total.toLocaleString()` 호출이 백엔드 응답 shape 이슈로 `stats.total` 이 undefined 일 때 Uncaught TypeError 로 크래시하던 문제 null-safe 로 해소(`stats?.total != null`). `/design-system.html` 의 `CatalogBadges` 가 `PERFORMANCE_STATUS_META` 의 `label: {ko, en}` 객체를 그대로 Badge children 으로 주입해 "Objects are not valid as a React child" 를 던지고 `code` 필드 부재로 key 중복 경고가 함께 뜨던 문제 해소 — `Object.entries` 순회 + `AnyMeta.label` 을 `string | {ko,en}` 로 확장 + getKoLabel/getEnLabel 에 label 객체 케이스 추가
|
||||
|
||||
### 추가
|
||||
- **환적 의심 전용 탐지 페이지 신설 (Phase 0-3)** — `/transshipment` 경로에 READ 전용 대시보드 추가. prediction `algorithms/transshipment.py` 의 5단계 필터 파이프라인 결과(is_transship_suspect=true)를 전체 목록·집계·상세 수준으로 조회. KPI 5장(Total + Transship tier CRITICAL/HIGH/MEDIUM + 종합 위험 CRITICAL) + DataTable 8컬럼 + 필터(hours/level/mmsi) + features JSON 상세. 기존 `/api/analysis/transship` + `getTransshipSuspects` 재사용해 backend 변경 없음. V033 마이그레이션으로 `detection:transshipment` 권한 트리 + 전 역할 READ 부여. (docs/prediction-analysis.md P1 UI 미노출 탐지 해소 — 2/2)
|
||||
- **불법 조업 이벤트 전용 페이지 신설 (Phase 0-2)** — `/illegal-fishing` 경로에 READ 전용 대시보드 추가. event_generator 가 생산하는 `GEAR_ILLEGAL`(G-01/G-05/G-06) + `EEZ_INTRUSION`(영해·접속수역) + `ZONE_DEPARTURE`(특정수역 진입) 3 카테고리를 한 화면에서 통합 조회. 심각도 KPI 5장 + 카테고리별 3장 + DataTable(7컬럼) + 필터(category/level/mmsi) + JSON features 상세 패널 + EventList 네비게이션. 기존 `/api/events` 를 category 다중 병렬 조회로 래핑하여 backend 변경 없이 구현. V032 마이그레이션으로 `detection:illegal-fishing` 권한 트리 + 전 역할 READ 부여 (운영자 처리 액션은 EventList 경유)
|
||||
|
||||
@ -11,19 +11,30 @@ import { CATALOG_REGISTRY, type CatalogEntry } from '@shared/constants/catalogRe
|
||||
*/
|
||||
|
||||
interface AnyMeta {
|
||||
code: string;
|
||||
/** 일부 카탈로그는 code 없이 Record key 만 사용 (예: PERFORMANCE_STATUS_META) */
|
||||
code?: string;
|
||||
intent?: BadgeIntent;
|
||||
fallback?: { ko: string; en: string };
|
||||
classes?: string | { bg?: string; text?: string; border?: string };
|
||||
label?: string;
|
||||
/** 문자열 라벨 또는 { ko, en } 객체 라벨 양쪽 지원 */
|
||||
label?: string | { ko: string; en: string };
|
||||
}
|
||||
|
||||
function getKoLabel(meta: AnyMeta): string {
|
||||
return meta.fallback?.ko ?? meta.label ?? meta.code;
|
||||
function getKoLabel(meta: AnyMeta, fallbackKey: string): string {
|
||||
if (meta.fallback?.ko) return meta.fallback.ko;
|
||||
if (meta.label && typeof meta.label === 'object' && 'ko' in meta.label) {
|
||||
return meta.label.ko;
|
||||
}
|
||||
if (typeof meta.label === 'string') return meta.label;
|
||||
return meta.code ?? fallbackKey;
|
||||
}
|
||||
|
||||
function getEnLabel(meta: AnyMeta): string | undefined {
|
||||
return meta.fallback?.en;
|
||||
if (meta.fallback?.en) return meta.fallback.en;
|
||||
if (meta.label && typeof meta.label === 'object' && 'en' in meta.label) {
|
||||
return meta.label.en;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getFallbackClasses(meta: AnyMeta): string | undefined {
|
||||
@ -55,17 +66,19 @@ function renderBadge(meta: AnyMeta, label: string): ReactNode {
|
||||
}
|
||||
|
||||
function CatalogBadges({ entry }: { entry: CatalogEntry }) {
|
||||
const items = Object.values(entry.items) as AnyMeta[];
|
||||
// Record key 를 안정적 식별자로 사용 (일부 카탈로그는 meta.code 없음)
|
||||
const items = Object.entries(entry.items) as [string, AnyMeta][];
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{items.map((meta) => {
|
||||
const koLabel = getKoLabel(meta);
|
||||
{items.map(([key, meta]) => {
|
||||
const displayCode = meta.code ?? key;
|
||||
const koLabel = getKoLabel(meta, key);
|
||||
const enLabel = getEnLabel(meta);
|
||||
const trkId = `${entry.showcaseId}-${meta.code}`;
|
||||
const trkId = `${entry.showcaseId}-${displayCode}`;
|
||||
return (
|
||||
<Trk key={meta.code} id={trkId} className="flex items-center gap-3 rounded-sm">
|
||||
<Trk key={key} id={trkId} className="flex items-center gap-3 rounded-sm">
|
||||
<code className="text-[10px] text-hint font-mono whitespace-nowrap w-32 shrink-0 truncate">
|
||||
{meta.code}
|
||||
{displayCode}
|
||||
</code>
|
||||
<div className="flex-1">{renderBadge(meta, koLabel)}</div>
|
||||
<div className="flex-1">
|
||||
|
||||
@ -101,9 +101,9 @@ export function SystemStatusPanel() {
|
||||
status={stats ? 'CONNECTED' : 'DISCONNECTED'}
|
||||
statusIntent={stats ? 'success' : 'critical'}
|
||||
details={[
|
||||
['선박 분석', stats ? `${stats.total.toLocaleString()}건` : '-'],
|
||||
['클러스터', stats ? `${stats.clusterCount}` : '-'],
|
||||
['어구 그룹', stats ? `${stats.gearGroups}` : '-'],
|
||||
['선박 분석', stats?.total != null ? `${stats.total.toLocaleString()}건` : '-'],
|
||||
['클러스터', stats?.clusterCount != null ? `${stats.clusterCount}` : '-'],
|
||||
['어구 그룹', stats?.gearGroups != null ? `${stats.gearGroups}` : '-'],
|
||||
]}
|
||||
/>
|
||||
|
||||
@ -124,10 +124,10 @@ export function SystemStatusPanel() {
|
||||
{/* 위험도 분포 */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<RiskBox label="CRITICAL" value={stats.critical} color="text-red-400" />
|
||||
<RiskBox label="HIGH" value={stats.high} color="text-orange-400" />
|
||||
<RiskBox label="MEDIUM" value={stats.medium} color="text-yellow-400" />
|
||||
<RiskBox label="LOW" value={stats.low} color="text-blue-400" />
|
||||
<RiskBox label="CRITICAL" value={stats.critical ?? 0} color="text-red-400" />
|
||||
<RiskBox label="HIGH" value={stats.high ?? 0} color="text-orange-400" />
|
||||
<RiskBox label="MEDIUM" value={stats.medium ?? 0} color="text-yellow-400" />
|
||||
<RiskBox label="LOW" value={stats.low ?? 0} color="text-blue-400" />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user