merge: origin/gbpark-node → hjjeong (60 commits, 5 conflicts resolved)

충돌 해결 5개 파일:
- .gitignore: .envrc/.direnv (hjjeong direnv 셋업) + .omc/ (gbpark) 양쪽 보존
- docs/MULTI_TENANCY_ARCHITECTURE.md: *.localhost dev 분기 + *.invyone.com/solution.invyone.com 통합
- frontend/lib/api/client.ts: 1-b *.localhost:8081 dev + 1-c DEV_TENANT_HOST(nip.io):8083 + invyone.com 신 도메인
- frontend/lib/tenant/subdomain.ts: IPv4 차단 + *.invyone.com + DEV_TENANT_HOST + *.localhost 모두 처리
- frontend/app/(auth)/login/page.tsx: B안 채택 — buttons 항상 렌더, className 만 mounted 가드 (next-themes 표준 패턴)

검증:
- backend: ./gradlew compileJava 성공 (Java 21)
- frontend: 머지된 4개 파일 관련 타입 에러 0개

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-07 16:51:06 +09:00
400 changed files with 23432 additions and 33311 deletions
@@ -70,7 +70,7 @@ config.setAllowedOriginPatterns(List.of("*"));
config.setAllowCredentials(true);
```
`*` + `allowCredentials=true` 조합은 모든 출처에서 쿠키 포함 요청을 허용.
**조치:** 운영용 화이트리스트 (`v1.invion.com`, 사내 IP 등) 로 좁힐 것.
**조치:** 운영용 화이트리스트 (`v1.invyone.com`, 사내 IP 등) 로 좁힐 것.
### 6. JWT 를 localStorage 저장 + 비-HttpOnly 쿠키 중복
**파일:** `frontend/lib/api/client.ts:83-87`, `frontend/hooks/useAuth.ts:58-64`
@@ -154,7 +154,7 @@ const strictProtectedPaths = ["/admin"];
## 🟢 자잘한 정리
- **`frontend/lib/api/client.ts:14-36`** — API base URL 결정 로직이 환경변수 + hostname 분기 + 포트 분기로 길어짐. hostname 하드코딩 (`v1.invion.com`) 은 환경변수로 분리 권장.
- **`frontend/lib/api/client.ts:14-36`** — API base URL 결정 로직이 환경변수 + hostname 분기 + 포트 분기로 길어짐. hostname 하드코딩 (`v1.invyone.com`) 은 환경변수로 분리 권장.
- **`useAuth.ts:196`, `AuthService.java:273`** — `"plm_admin" === userId` 식의 매직 ID 가 프론트/백 양쪽에 박힘. role 기반으로 일원화.
- **`JwtAuthenticationFilter`** — 토큰 만료/위조 시 401 응답 없이 그냥 다음 필터로 넘김 (#4 결함과 맞물려 동작 불일치).
- **`frontend/hooks/useLogin.ts:81-95`** — `checkExistingAuth` 가 401 받으면 인터셉터의 자동 redirectToLogin 과 충돌 여지. 현재는 `pathname === "/login"` 가드로 막혀있긴 함.
+1 -1
View File
@@ -92,7 +92,7 @@ WHERE table_name = 'user_info' AND column_name = 'user_password';
- 현재는 **dev 도커** (`docker/dev/.env`) 만 작성
- 운영 도커 컴포즈 (`docker/prod/...`) 가 있다면 거기에도 동일한 패턴으로 `.env` + secret + CORS 화이트리스트 적용 필요
- 운영 CORS 화이트리스트: `https://v1.invion.com,https://api.invion.com` 식으로 변경
- 운영 CORS 화이트리스트: `https://v1.invyone.com,https://api.invyone.com` 식으로 변경
### D. 마스터 패스워드 의존성 확인
@@ -799,7 +799,7 @@ html.dark .v5-cm-tg{background:var(--v5-cm-sunk);}
</div>
<div class="v5-cm-form-row">
<label>도메인</label>
<input class="v5-cm-inp" value="v1.invion.com" />
<input class="v5-cm-inp" value="v1.invyone.com" />
</div>
</div>
<div class="v5-cm-form-row">
@@ -0,0 +1,237 @@
# 다음 세션 — ConfigPanel 리팩토링 + 잔여 마이그 todos
날짜: 2026-04-28
컨텍스트: 직전 세션에서 좌측 팔레트 11 컴포넌트 cp 톤 마이그 + Inv* 일괄 네이밍 통합 완료. 안전 복귀점: commit 이후 (git log 확인).
---
## 1순위: 리팩토링 본체 (Codex 권장)
### 1.1 useDbTables() hook 추출 ★ 가장 안전 — ✅ 완료 (2026-04-29)
**현재 중복 위치**:
- `lib/registry/components/search/InvSearchConfigPanel.tsx`
- `lib/registry/components/table/InvTableConfigPanel.tsx`
- `lib/registry/components/stats/InvStatsConfigPanel.tsx`
- `lib/registry/components/input/InvInputConfigPanel.tsx` (필요 시)
**중복 패턴**:
```tsx
const [allDbTables, setAllDbTables] = useState<any[]>([]);
useEffect(() => {
let cancelled = false;
(async () => {
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const res = await tableManagementApi.getTableList();
if (!cancelled && res.success && res.data) setAllDbTables(res.data);
})();
return () => { cancelled = true; };
}, []);
const tableOptions = useMemo(() => allDbTables.map(...), [allDbTables]);
```
**추출 위치**: `frontend/lib/registry/components/common/hooks/useDbTables.ts`
**hook 시그니처 (Codex 권장)**:
```ts
export function useDbTables(opts?: {
fallback?: any[]; // search/table 의 props.tables fallback
normalize?: (t: any) => { value: string; label: string }; // 기본 normalize
}): {
tables: any[];
options: { value: string; label: string }[];
loading: boolean;
};
```
**주의 (Codex)**:
- `tableName/table_name`, `display_name/tableLabel/table_label` normalization 을 hook 안에 고정
- table 의 connected column loading / autoload 로직은 **hook 에 섞지 말 것** (별개)
**적용 결과 (2026-04-29)**:
- 신규 파일: `frontend/lib/registry/components/common/useDbTables.ts`
- 적용 패널: search / table / stats (3개)
- input 은 패턴 없어서 적용 안 함
- normalize 는 hook 안 default + `opts.normalize` override 가능 (Codex 권장 그대로)
- stats 만 value 우선순위 살짝 달랐으나 (`table_name || tableName`) functional 동일이라 default 통일
- TS 검증: 변경 4 파일 새 에러 0건
### 1.2 TableConnectSection 추출 (1.1 다음) — ✅ 완료 (2026-04-29)
**현재 중복**:
- search / table 의 "테이블 연결 + 자동 로드 버튼" 섹션
- stats 는 자동 로드 X (테이블 선택만)
**추출 시 주의**:
- search: `[자동 로드]` = 검색 필드 8개 자동 추출
- table: `[자동 로드]` = 컬럼 N개 자동 추출
- → autoload 콜백을 prop 으로 받음. label 도 prop.
**적용 결과 (2026-04-29)**:
- 신규 파일: `frontend/lib/registry/components/common/TableConnectSection.tsx`
- 두 컴포넌트 export:
1. `TableConnectSection` — CPSection + CPRow "테이블" + CPSelect (children 슬롯)
2. `AutoLoadButton` — 38줄 동일 inline 스타일 버튼 (label / onClick / iconSize prop)
- Codex 의 ★ 가이드 그대로: autoload 로직 (callback / 조건 / 컬럼 fetch) 호출처에 그대로 둠
- search 호출처는 `tableColumns.length > 0 && <AutoLoadButton ... />` + `!connectedTable && <Hint>` 직접 children 으로 주입
- table 호출처는 `<Hint>` (loading 상태) + `<AutoLoadButton ... />` 직접 children
- TS 검증: 변경 파일 새 에러 0건
- 변경 stat: 4 files, +81/-199 (호출처 순감 118줄, 신규 모듈 ~186줄, 총 net +68 — 가독성/유지보수 큰 이득)
### 1.3 DenseListRow / ExpandableRow 추출 (1.1, 1.2 다음) — ✅ helper-only 완료 (2026-04-29)
**현재 중복**:
- search 의 SearchFieldRow
- table 의 ColumnEditRow (펼침 있음)
- stats 의 ItemEditRow (펼침 있음)
- input 의 select options row (단순)
→ 패턴 안정화 후 추출. 너무 일찍 하면 over-abstraction.
**적용 결과 (2026-04-29) — Codex 가이드 따라 helper-only 좁게 추출**:
- 신규 파일: `frontend/lib/registry/components/common/row-helpers.tsx`
- 3 helper export:
1. `RowNumberBadge` (n) — search/table/stats 의 #번호 (4 패널 중 3)
2. `RowExpandChevron` (expanded, onToggle, size?) — table/stats 의 ▸/▾ 펼침 버튼 (2 패널)
3. `RowDeleteBtn` (onClick, visible?, size?, children?, title?) — 4 패널 모두 사용
- ★ wrapper (DenseListRow / ExpandableRow) 는 **보류** — Codex 판단: row 별 expanded state 위치 / hover 표시 / row padding 회귀 위험 중간 이상
- silent visual change 1건: input 의 옵션 ×버튼 hover bg 추가됨 (이전 X) — Codex Y2 권고로 `hoverBg?: boolean` 옵션 추가 (기본 true), input 만 `hoverBg={false}` 로 원복
- Y1 ★ 경고 주석 반영: `RowDeleteBtn``visible` 기본값 true 위험 (호출처가 prop 생략 시 항상 노출) — 주석에 "dense list row 는 반드시 `visible={hover}` 명시" 명시
- 적용 패널: search (RowNumberBadge + RowDeleteBtn) / table + stats (3 helper 다) / input (RowDeleteBtn 만)
- TS 검증: 새 에러 0건
- 변경 stat 누적 (1.1+1.2+1.3): 5 files, +123 / -376 (호출처 -253 / 신규 3 모듈 +296 / net +43 — 가독성·유지보수 이득)
---
## 2순위: 잔여 V2* cp 마이그 (좌측 팔레트 외)
### 2.1 V2DateConfigPanel cp 마이그 (좌측 더보기 9)
- 위치: `frontend/components/v2/config-panels/V2DateConfigPanel.tsx`
### 2.2 V2BomTreeConfigPanel / V2BomItemEditorConfigPanel (좌측 BOM 트리/편집)
### 2.3 InvDataConfigPanel 의 list/table 분기
- 현재: V2ListConfigPanel / V2TableListConfigPanel(1497줄) 옛 디자인 그대로 위임
- 신규 배치는 v2-repeater 만 (InvRepeaterConfigPanel) — 옛 화면 호환용
- 우선순위 낮음 (사용자 신규 배치 X)
---
## 3순위: dead code 정리 (Codex 권장 방식)
`frontend/components/v2/config-panels/V2*ConfigPanel.tsx` 33개 중 실제 사용/미사용 식별:
- basename-driven `rg` (ripgrep) 스캔: `rg -l "V2XxxConfigPanel" frontend/`
- register/barrel 노이즈 구분 (config_panel: 매핑이 살아있는 것 = 사용 중)
- 파일명만으로 삭제 금지 — 호출처 확인 필수
---
## 4순위: IconPicker 공용 cp 톤 마이그 (사용자 지적) — ✅ 완료 (2026-04-29)
**문제**: button / stats 등 패널의 "아이콘 선택" 셀렉트가 다른 select 와 사이즈 다름 (Image #9 참고). IconPicker 컴포넌트 자체 디자인이 cp 톤 아님.
**위치**: `frontend/lib/registry/components/common/IconPicker.tsx`
**한 번 마이그하면 button / stats / 기타 IconPicker 사용 패널 일관성 회복.**
**적용 결과 (2026-04-29) — Codex Y4 권고 따라**:
- 위치: `frontend/lib/registry/components/common/IconPicker.tsx`
- 시그니처 / 동작 (외부 클릭 닫기 없음 등) **완전 동일**, 시각 톤만 마이그
- 트리거 버튼: 28px height, 12px font, cp-surface bg, cp-border + focus glow (primary 0.5/0.12), 6px radius
- 팝오버: cp-surface bg, cp-border, 6px radius, shadow
- 검색 input: cp-bg-subtle, cp-border, 4px radius, 11px font
- 그리드 버튼: cp hover (cp-surface-hover), primary 활성 tint (rgba(primary-rgb, 0.10) bg + primary text)
- shadcn 클래스 (`border-border bg-background text-xs` 등) 0건 → cp 변수 + inline style 전환
**영향 범위 검증**:
- 사용처 grep 결과 — IconPicker 본체는 button/stats 2 cp 패널만 사용
- 외부 7곳 (layout/admin/dash) 은 별개 `MenuIconPicker` 사용 → **영향 없음**
**검증**:
- TS 새 에러 0건
- diff stat: 1 file, +152 / -18 (cp inline style verbosity 로 size 증가, 시각 일관성 큰 이득)
**E3 후속 — popover Portal 도입 (2026-04-29)**:
- Codex 검증 시 ★ 발견: stats 의 list wrapper `overflow: hidden` (cp 표준 디자인) 안에서 IconPicker popover (absolute) 가 잘릴 위험
- search/table 도 동일 hidden 패턴 — cp 표준 깨면 일관성 깨짐
- 해결: IconPicker 의 popover 만 `React.createPortal(document.body)` + `position: fixed` + 좌표 (트리거 `getBoundingClientRect()`) 로 변경
- 부모 overflow 와 무관하게 항상 화면 위에 표시
- scroll/resize 발생 시 popover 자동 닫음 (좌표 안 따라감 — 단순)
- z-index 9999 로 최상위
- 다른 cp 패널 일관성 유지 (wrapper hidden 그대로)
**IconPicker 미완 후속 사항 (다음에 손볼 때 한꺼번에)** ★:
1. **viewport 하단 잘림 보정 (auto-flip)** — 트리거 아래 공간 부족 시 popover 를 트리거 위로 flip. 좌표 계산 시 `viewport.height - rect.bottom` 비교 후 결정.
2. **외부 클릭 닫기** — 현재 트리거 재클릭 / 그리드 선택 / 검색 후 선택 시만 닫힘. cp 표준 CPSelect 처럼 `mousedown` capture + Escape 키 처리 추가 (단 portal 안에서 처리 시 popover 자체 클릭은 닫지 않게 ref 비교).
3. **z-index 9999 표준화** — 향후 앱 모달/오버레이 (예: shadcn `Dialog`, `Sheet` 등) 와 충돌 가능. cp 표준 z-index 토큰 (`--cp-z-popover`) 정의 후 사용.
4. **viewport 우측 잘림 보정** — popover width 가 트리거 right 보다 클 때 좌측으로 shift. 현재 trigger.width 그대로 사용해 우측 잘림 가능성 낮지만, 동적 width 일 때 대비.
5. **키보드 네비** — ↑↓ 화살표 / Enter / Esc 등. 현재 마우스만 지원.
6. **카테고리 분류 / 더보기** — 현재 80개 cap. 검색 시에만 전체 매치. 카테고리 (액션/내비/IO 등) 추가 후보.
위 1~6 은 즉시 깨지는 결함은 아님. 다음에 IconPicker 본체 손볼 때 (예: button/stats 외 다른 패널 추가 또는 신규 기능 추가) 함께 처리.
---
## 안전 가이드
- **Codex 검토 필수**: 리팩토링 / 큰 rename 은 Codex 에게 검증받음 (silent breakage 방지)
- **commit 자주**: 각 1.x / 2.x 단계마다 commit (안전 복귀점)
- **dev 서버 재시작**: rename 후 ssh park@100.126.230.80 'docker restart invyone-frontend'
- **smoke test**: 좌측 팔레트 11 컴포넌트 + 옛 hidden 화면 (있다면) 1개씩 클릭 확인
---
## 진행 상황 요약 (2026-04-28 직전 세션 기준)
### 완료
- ✅ 좌측 팔레트 11 컴포넌트 cp 톤 마이그
- ✅ Inv* 일괄 네이밍 통합 (11 패널)
- ✅ 옛 호환 3 → InvLegacy* 분리
- ✅ getComponentConfigPanel.tsx stats key 중복 버그 수정
- ✅ ALIAS 충돌 수정 (옛 hidden 컴포넌트 → InvLegacy)
- ✅ input 통합 cp 톤 신규 (InvInputConfigPanel)
-**useDbTables hook 추출 (2026-04-29)**
-**TableConnectSection + AutoLoadButton 추출 (2026-04-29)**
-**Row helpers 추출 (RowNumberBadge / RowExpandChevron / RowDeleteBtn) — 2026-04-29**
-**IconPicker cp 톤 마이그 (2026-04-29)** — Codex Y4 권고
### 미완 (위 1~4 순위)
- ❌ V2DateConfigPanel / V2BomTree / V2BomItemEditor cp
- ❌ InvDataConfigPanel 의 list/table 분기 cp (옛 화면 호환용)
- ❌ V2* 33개 dead code 정리
---
## Codex 검토 (2026-04-29) 핵심 반영
| 질문 | 판정 | 시급 액션 |
|---|---|---|
| Q1 todos 우선순위 | 수정필요 | useDbTables 만 먼저 (✅ 완료) + dead-code 스캔 기준 보강 |
| Q2 InvRepeater 신규 | 수정필요 | `import` type 보류, resolver/writer + 보존 키 목록 확정 |
| Q3 InvData 통합 | 수정필요 | taxonomy 하위 축 추가 + round-trip DoD |
| Q4 Inv* 네이밍 | OK | 옵션 B 유지, legacy 사용량 0 검증 전 삭제 금지 |
### ★ silent breakage 위험 (Codex 경고)
1. **TableConnectSection 추출 시** — search/table 의 자동 로드 의미 다름 (search: 검색 필드 8개 / table: 컬럼 N개). UI 만 공통화 + autoload 는 prop callback
2. **DenseListRow 추출 시** — row 별 key/펼침/onChange shape 다름. 너무 빨리 공통화하면 UI 는 떠도 저장 config 틀어질 수 있음
3. **dead code 33개 식별**`rg "V2XxxConfigPanel"` 만으로 부족
- `getComponentConfigPanel.tsx``CONFIG_PANEL_MAP` + `CONFIG_PANEL_ALIAS` (alias 가 먼저 해석됨, 예: `"v2-table-list": "table"`)
- `V2PropertiesPanel.tsx:215` hardcoded require
- dynamic import map 확인 필수
4. **InvRepeater config 보존 키**
- `source_detail_config` (table_name/foreign_key/parent_key 외 use_entity_join, column_mapping, additional_join_columns 도 보존)
- `column_mappings[]`, `calculation_rules[]`, `entity_joins[]` (modal 전용처럼 보여도 보존)
- `no_duplicate_pick` (현재 코드 L1335) 호환 체크 추가
5. **V2DateConfigPanel** — V2FieldConfigPanel 의 `input.date.*` 와 분리 결정 필요
6. **excludeFilter / linkedFilters[] / source_detail_config** — 통합 InvData 시 read/write round-trip 테스트 필수 (참조 테이블 변경 시 별도 컬럼 API 호출)
### 추가 권장사항
- **InvData 통합 패널 분리**: 3760줄 단일 파일 비추천. orchestrator / resolveAxis-applyAxis / read sections / write sections / API hooks / column editors 로 경계
- **InvRepeater resolver/writer 도입**: V2Field 의 resolveTriple/applyTriple 패턴 그대로 (InvRepeaterConfigPanel L124, L141 이미 같은 형태 사용 중)
- **InvRepeater type=import 보류**: 별도 reserved 또는 후속 컴포넌트 후보로
@@ -0,0 +1,457 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>SCADA Component Library</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0; padding: 24px;
background: #050a18;
color: #fff;
font-family: 'Segoe UI', sans-serif;
}
h1 {
font-size: 18px; margin: 0 0 4px;
color: #5af; letter-spacing: 1px;
}
.subtitle {
font-size: 12px; color: #8aa; margin-bottom: 20px;
}
.lib {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
.card {
background: #0a1428;
border: 1px solid #1e3060;
border-radius: 6px;
padding: 12px;
display: flex;
flex-direction: column;
}
.card h3 {
font-size: 13px;
margin: 0 0 8px;
color: #5af;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.badge {
font-size: 9px;
padding: 2px 6px;
border-radius: 10px;
font-weight: 600;
letter-spacing: 0.5px;
}
.badge.ok { background: #1a5a2a; color: #7cff3a; border: 1px solid #2a8b3a; }
.badge.fail { background: #5a1a1a; color: #ff5a5a; border: 1px solid #8b2a2a; }
.stage {
background: #050a18;
border: 1px solid #1e3060;
padding: 12px;
min-height: 80px;
display: flex;
align-items: center;
justify-content: center;
overflow: visible;
border-radius: 4px;
}
.meta {
margin-top: 8px;
font-size: 10px;
color: #8aa;
font-family: 'Consolas', monospace;
border-top: 1px dashed #1e3060;
padding-top: 6px;
}
.meta .key { color: #5af; }
.meta div { margin: 2px 0; word-break: break-all; }
.ctrl {
margin-top: 8px;
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.ctrl button {
background: #0a1830;
border: 1px solid #1e3060;
color: #cfd3d8;
font-size: 10px;
padding: 4px 8px;
cursor: pointer;
border-radius: 3px;
font-family: inherit;
}
.ctrl button.active {
background: #1a5a2a;
border-color: #5af04a;
color: #fff;
}
.ctrl button:hover { filter: brightness(1.3); }
.empty-card {
background: #0a1428;
border: 1px dashed #1e3060;
border-radius: 6px;
padding: 24px;
color: #4a6a8a;
text-align: center;
font-size: 12px;
}
</style>
</head>
<body>
<h1>SCADA Component Library</h1>
<div class="subtitle">검증 통과한 SVG 컴포넌트 카탈로그 — 인스턴스 prefix 적용 전 디자인/동작 확인용</div>
<div class="lib" id="lib"></div>
<script>
//==============================================================
// 컴포넌트 등록 — 검증 통과한 SVG 만 추가
// 다음 컴포넌트 받으면 LIBRARY 배열에 push
//==============================================================
const LIBRARY = [
{
id: 'pipe-straight',
title: '#18 Pipe Straight (horizontal)',
viewBox: '0 0 100 20',
displayWidth: 240,
displayHeight: 48,
requiredIds: ['pipe-outer', 'pipe-inner', 'pipe-flow'],
notes: 'CSS rotate(90deg) 로 수직 파이프 재사용 가능',
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 20" overflow="visible" role="img" aria-label="SCADA straight pipe" style="--scada-stroke:#aaaaaa;--scada-stroke-strong:#ffffff;--scada-flow:#5af9ff;--scada-active:#7cff3a;--scada-warning:#ff8a3a;--scada-alarm:#ff4f9a;--scada-idle:#2a3f5a">
<defs>
<linearGradient id="pipe-metal-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#d9d9d9"/>
<stop offset="0.18" stop-color="#f4f4f4"/>
<stop offset="0.5" stop-color="#8f8f8f"/>
<stop offset="0.82" stop-color="#d7d7d7"/>
<stop offset="1" stop-color="#6f6f6f"/>
</linearGradient>
</defs>
<style>
.scada-stroke{stroke:var(--scada-stroke);stroke-width:1;vector-effect:non-scaling-stroke}
.scada-strong{stroke:var(--scada-stroke-strong);stroke-width:1;vector-effect:non-scaling-stroke}
.scada-fill-idle{fill:var(--scada-idle)}
.scada-fill-metal{fill:url(#pipe-metal-grad)}
.flow-line{stroke:var(--scada-flow);stroke-width:2;stroke-linecap:round;stroke-dasharray:4 4;vector-effect:non-scaling-stroke}
</style>
<rect x="2" y="4" width="96" height="12" rx="2" ry="2" id="pipe-outer" class="scada-fill-metal scada-stroke"/>
<rect x="6" y="7" width="88" height="6" rx="1.5" ry="1.5" id="pipe-inner" class="scada-fill-idle scada-stroke"/>
<line x1="8" y1="5" x2="8" y2="15" class="scada-strong"/>
<line x1="12" y1="5" x2="12" y2="15" class="scada-stroke"/>
<line x1="88" y1="5" x2="88" y2="15" class="scada-stroke"/>
<line x1="92" y1="5" x2="92" y2="15" class="scada-strong"/>
<line x1="12" y1="10" x2="88" y2="10" id="pipe-flow" class="flow-line"/>
</svg>`,
},
{
id: 'pipe-elbow',
title: '#19 Pipe Elbow 90° (┐ base)',
viewBox: '0 0 50 50',
displayWidth: 120,
displayHeight: 120,
requiredIds: ['pipe-outer', 'pipe-inner', 'pipe-flow'],
notes: 'CSS rotate(90/180/270) 로 ┘/└/┌ 재사용. stroke 방식이라 곡선 깔끔',
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" overflow="visible" role="img" aria-label="SCADA pipe elbow 90 degrees" style="--scada-stroke:#aaaaaa;--scada-stroke-strong:#ffffff;--scada-flow:#5af9ff;--scada-active:#7cff3a;--scada-warning:#ff8a3a;--scada-alarm:#ff4f9a;--scada-idle:#2a3f5a">
<defs>
<linearGradient id="pipe-metal-grad-19" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#d9d9d9"/>
<stop offset="0.18" stop-color="#f4f4f4"/>
<stop offset="0.5" stop-color="#8f8f8f"/>
<stop offset="0.82" stop-color="#d7d7d7"/>
<stop offset="1" stop-color="#6f6f6f"/>
</linearGradient>
</defs>
<style>
.e19-stroke{stroke:var(--scada-stroke);stroke-width:1;vector-effect:non-scaling-stroke}
.e19-outer{fill:none;stroke:url(#pipe-metal-grad-19);stroke-width:12;stroke-linecap:butt;stroke-linejoin:round;vector-effect:non-scaling-stroke}
.e19-inner{fill:none;stroke:var(--scada-idle);stroke-width:6;stroke-linecap:butt;stroke-linejoin:round;vector-effect:non-scaling-stroke}
.e19-flow{fill:none;stroke:var(--scada-flow);stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:4 4;vector-effect:non-scaling-stroke}
</style>
<path id="pipe-outer" class="e19-outer e19-stroke" d="M2 14 H24 A12 12 0 0 1 36 26 V48"/>
<path id="pipe-inner" class="e19-inner e19-stroke" d="M2 14 H24 A12 12 0 0 1 36 26 V48"/>
<path id="pipe-flow" class="e19-flow" d="M4 14 H24 A12 12 0 0 1 36 26 V46"/>
</svg>`,
},
{
id: 'pipe-tjunction',
title: '#20 Pipe T-junction (┬ base)',
viewBox: '0 0 60 40',
displayWidth: 180,
displayHeight: 120,
requiredIds: ['pipe-main-outer', 'pipe-main-inner', 'pipe-main-flow', 'pipe-branch-outer', 'pipe-branch-inner', 'pipe-branch-flow'],
notes: '한 덩어리 path + 좌/우/아래 평평한 단면 + flange 3쌍. branch-outer/inner 는 hidden helper (실 외곽은 main path 가 T자 전체 그림). CSS rotate 로 ┤/┴/├',
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 40" overflow="visible" role="img" aria-label="SCADA pipe T-junction" style="--scada-stroke:#aaaaaa;--scada-stroke-strong:#ffffff;--scada-flow:#5af9ff;--scada-active:#7cff3a;--scada-warning:#ff8a3a;--scada-alarm:#ff4f9a;--scada-idle:#2a3f5a">
<defs>
<linearGradient id="pipe-metal-grad-20" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#d9d9d9"/>
<stop offset="0.18" stop-color="#f4f4f4"/>
<stop offset="0.5" stop-color="#8f8f8f"/>
<stop offset="0.82" stop-color="#d7d7d7"/>
<stop offset="1" stop-color="#6f6f6f"/>
</linearGradient>
</defs>
<style>
.t20-stroke{stroke:var(--scada-stroke);stroke-width:1;vector-effect:non-scaling-stroke}
.t20-strong{stroke:var(--scada-stroke-strong);stroke-width:1;vector-effect:non-scaling-stroke}
.t20-metal{fill:url(#pipe-metal-grad-20)}
.t20-idle{fill:var(--scada-idle)}
.t20-flow{fill:none;stroke:var(--scada-flow);stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:4 4;vector-effect:non-scaling-stroke}
.t20-hidden{fill:none;stroke:none}
</style>
<path id="pipe-main-outer" d="M2 4H58V16H38Q36 16 36 18V38H24V18Q24 16 22 16H2Z" class="t20-metal t20-stroke"/>
<path id="pipe-main-inner" d="M6 7H54V13H34Q33 13 33 14V34H27V14Q27 13 26 13H6Z" class="t20-idle t20-stroke"/>
<path id="pipe-branch-outer" d="M24 16H36V38H24Z" class="t20-hidden"/>
<path id="pipe-branch-inner" d="M27 14H33V34H27Z" class="t20-hidden"/>
<line x1="8" y1="5" x2="8" y2="15" class="t20-strong"/>
<line x1="12" y1="5" x2="12" y2="15" class="t20-stroke"/>
<line x1="48" y1="5" x2="48" y2="15" class="t20-stroke"/>
<line x1="52" y1="5" x2="52" y2="15" class="t20-strong"/>
<line x1="25" y1="28" x2="35" y2="28" class="t20-stroke"/>
<line x1="25" y1="32" x2="35" y2="32" class="t20-strong"/>
<line id="pipe-main-flow" x1="12" y1="10" x2="48" y2="10" class="t20-flow"/>
<line id="pipe-branch-flow" x1="30" y1="16" x2="30" y2="32" class="t20-flow"/>
</svg>`,
},
{
id: 'pipe-dynamic',
title: '#18b Pipe Straight — Dynamic Length Demo',
viewBox: '동적 (length 슬라이더로 가변)',
displayWidth: null,
displayHeight: null,
requiredIds: ['pipe-outer', 'pipe-inner', 'pipe-flow'],
notes: 'JS 가 length px 받아서 양 끝 flange 고정 + 가운데만 늘어남. 흐름량 슬라이더로 흐르는 속도(L/min) 도 가변',
dynamic: 'length',
minLength: 80,
maxLength: 800,
initialLength: 300,
},
{ id: 'placeholder-11', title: '#11 Centrifugal Pump', placeholder: true },
{ id: 'placeholder-14', title: '#14 Gate Valve', placeholder: true },
];
//==============================================================
// 동적 SVG 생성 — pipe straight 의 어떤 길이도 자동 생성
// 양 끝 flange 좌표는 절대값, 가운데 outer/inner/flow 만 length 따라 늘어남
//==============================================================
function createPipeStraightSVG(lengthPx) {
const W = lengthPx;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} 20" width="${W}" height="20" overflow="visible" role="img" style="--scada-stroke:#aaaaaa;--scada-stroke-strong:#ffffff;--scada-flow:#5af9ff;--scada-idle:#2a3f5a">
<defs>
<linearGradient id="pipe-metal-grad-dyn" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#d9d9d9"/>
<stop offset="0.18" stop-color="#f4f4f4"/>
<stop offset="0.5" stop-color="#8f8f8f"/>
<stop offset="0.82" stop-color="#d7d7d7"/>
<stop offset="1" stop-color="#6f6f6f"/>
</linearGradient>
</defs>
<style>
.pdyn-stroke{stroke:var(--scada-stroke);stroke-width:1;vector-effect:non-scaling-stroke}
.pdyn-strong{stroke:var(--scada-stroke-strong);stroke-width:1;vector-effect:non-scaling-stroke}
.pdyn-metal{fill:url(#pipe-metal-grad-dyn)}
.pdyn-idle{fill:var(--scada-idle)}
.pdyn-flow{stroke:var(--scada-flow);stroke-width:2;stroke-linecap:round;stroke-dasharray:4 4;vector-effect:non-scaling-stroke}
</style>
<rect id="pipe-outer" x="2" y="4" width="${W - 4}" height="12" rx="2" ry="2" class="pdyn-metal pdyn-stroke"/>
<rect id="pipe-inner" x="6" y="7" width="${W - 12}" height="6" rx="1.5" ry="1.5" class="pdyn-idle pdyn-stroke"/>
<line x1="8" y1="5" x2="8" y2="15" class="pdyn-strong"/>
<line x1="12" y1="5" x2="12" y2="15" class="pdyn-stroke"/>
<line x1="${W - 12}" y1="5" x2="${W - 12}" y2="15" class="pdyn-stroke"/>
<line x1="${W - 8}" y1="5" x2="${W - 8}" y2="15" class="pdyn-strong"/>
<line id="pipe-flow" x1="12" y1="10" x2="${W - 12}" y2="10" class="pdyn-flow"/>
</svg>`;
}
//==============================================================
// 카드 렌더
//==============================================================
const STATE_COLORS = {
active: '#7cff3a',
warning: '#ff8a3a',
alarm: '#ff4f9a',
idle: '#2a3f5a',
};
const lib = document.getElementById('lib');
LIBRARY.forEach(comp => {
if (comp.placeholder) {
const ph = document.createElement('div');
ph.className = 'empty-card';
ph.textContent = '⌛ ' + comp.title + ' — 대기 중';
lib.appendChild(ph);
return;
}
const card = document.createElement('div');
card.className = 'card';
const header = document.createElement('h3');
header.innerHTML = `<span>${comp.title}</span>`;
card.appendChild(header);
const stage = document.createElement('div');
stage.className = 'stage';
card.appendChild(stage);
const meta = document.createElement('div');
meta.className = 'meta';
card.appendChild(meta);
const ctrl = document.createElement('div');
ctrl.className = 'ctrl';
card.appendChild(ctrl);
const flowCtrl = document.createElement('div');
flowCtrl.style.marginTop = '8px';
card.appendChild(flowCtrl);
let lengthCtrl = null;
if (comp.dynamic === 'length') {
lengthCtrl = document.createElement('div');
lengthCtrl.style.marginTop = '6px';
card.appendChild(lengthCtrl);
}
card._flowActive = true;
card._flowRate = 50;
card._flowOffset = 0;
let currentRotation = 0;
function paintSvg(svgHtml) {
stage.innerHTML = svgHtml;
const svgEl = stage.querySelector('svg');
if (comp.displayWidth) svgEl.setAttribute('width', comp.displayWidth);
if (comp.displayHeight) svgEl.setAttribute('height', comp.displayHeight);
if (currentRotation) svgEl.style.transform = `rotate(${currentRotation}deg)`;
// 검증
const allIds = Array.from(svgEl.querySelectorAll('[id]')).map(el => el.id);
const missing = comp.requiredIds.filter(id => !allIds.includes(id));
const ok = missing.length === 0;
const oldBadge = header.querySelector('.badge');
if (oldBadge) oldBadge.remove();
const badge = document.createElement('span');
badge.className = 'badge ' + (ok ? 'ok' : 'fail');
badge.textContent = ok ? '✓ VERIFIED' : '✗ MISSING ' + missing.length;
header.appendChild(badge);
meta.innerHTML = `
<div><span class="key">viewBox</span>: ${svgEl.getAttribute('viewBox') || comp.viewBox}</div>
<div><span class="key">IDs</span> (${allIds.length}): ${allIds.join(', ')}</div>
${comp.notes ? `<div><span class="key">notes</span>: ${comp.notes}</div>` : ''}
`;
return svgEl;
}
// 초기 SVG
let svgEl = comp.dynamic === 'length'
? paintSvg(createPipeStraightSVG(comp.initialLength))
: paintSvg(comp.svg);
const hasFlow = svgEl.querySelectorAll('[id$="-flow"], [id="pipe-flow"]').length > 0;
if (hasFlow) {
// 상태 토글
['active', 'warning', 'alarm', 'idle'].forEach(state => {
const btn = document.createElement('button');
btn.dataset.state = state;
btn.textContent = state;
btn.addEventListener('click', () => {
ctrl.querySelectorAll('button[data-state]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const cur = stage.querySelector('svg');
cur.style.setProperty('--scada-flow', STATE_COLORS[state]);
card._flowActive = (state !== 'idle');
});
ctrl.appendChild(btn);
});
// 회전 버튼
[0, 90, 180, 270].forEach(deg => {
const btn = document.createElement('button');
btn.textContent = deg + '°';
if (deg === 0) btn.style.marginLeft = '12px';
btn.addEventListener('click', () => {
currentRotation = deg;
const cur = stage.querySelector('svg');
cur.style.transform = `rotate(${deg}deg)`;
});
ctrl.appendChild(btn);
});
// flow rate 슬라이더 (유량)
flowCtrl.innerHTML = `
<label style="display:flex;justify-content:space-between;font-size:10px;color:#8aa;font-family:Consolas">
<span>유량 (Flow rate)</span>
<span><b data-out>50</b> L/min</span>
</label>
<input type="range" min="0" max="100" value="50" step="1" style="width:100%;margin-top:2px">
`;
const flowSlider = flowCtrl.querySelector('input');
const flowOut = flowCtrl.querySelector('[data-out]');
flowSlider.addEventListener('input', () => {
card._flowRate = +flowSlider.value;
flowOut.textContent = card._flowRate;
card._flowActive = card._flowRate > 0;
flowOut.style.color = card._flowRate === 0 ? '#666' : card._flowRate > 70 ? '#7cff3a' : '#5af9ff';
});
// 기본: active
ctrl.querySelector('[data-state="active"]').click();
// 애니메이션 — flowRate 가 속도 결정 (0=정지, 100=최대)
setInterval(() => {
if (!card._flowActive) return;
const speed = card._flowRate / 100 * 1.8;
card._flowOffset -= speed;
const cur = stage.querySelector('svg');
if (cur) {
cur.querySelectorAll('[id$="-flow"], [id="pipe-flow"]').forEach(el => {
el.setAttribute('stroke-dashoffset', card._flowOffset);
});
}
}, 30);
}
// dynamic length: 슬라이더로 SVG 재생성
if (comp.dynamic === 'length' && lengthCtrl) {
lengthCtrl.innerHTML = `
<label style="display:flex;justify-content:space-between;font-size:10px;color:#8aa;font-family:Consolas">
<span>파이프 길이</span>
<span><b data-out>${comp.initialLength}</b> px</span>
</label>
<input type="range" min="${comp.minLength}" max="${comp.maxLength}" value="${comp.initialLength}" step="10" style="width:100%;margin-top:2px">
`;
const lenSlider = lengthCtrl.querySelector('input');
const lenOut = lengthCtrl.querySelector('[data-out]');
lenSlider.addEventListener('input', () => {
const len = +lenSlider.value;
lenOut.textContent = len;
paintSvg(createPipeStraightSVG(len));
// 현재 활성 상태 색 다시 입히기
const activeBtn = ctrl.querySelector('button[data-state].active');
if (activeBtn) {
const state = activeBtn.dataset.state;
const cur = stage.querySelector('svg');
cur.style.setProperty('--scada-flow', STATE_COLORS[state]);
}
});
}
lib.appendChild(card);
});
</script>
</body>
</html>
@@ -0,0 +1,555 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>AUTO CHEMICAL SUPPLY MONITORING WEB SYSTEM</title>
<style>
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0; background: #050a18; color: #fff;
font-family: 'Segoe UI', sans-serif;
}
.header {
background: linear-gradient(180deg, #1a3870 0%, #0c1f4a 100%);
padding: 8px 20px; display: flex; justify-content: space-between;
align-items: center; border-bottom: 1px solid #1e4080;
}
.header h1 { font-size: 18px; margin: 0; letter-spacing: 2px; font-weight: 700; }
.header .clock { font-size: 14px; font-family: 'Consolas', monospace; }
.main {
display: grid; grid-template-columns: 1fr 380px; gap: 12px;
padding: 12px;
}
.tanks-area {
display: grid; grid-template-columns: repeat(4, 1fr) 1.4fr;
grid-template-rows: 1fr 1fr;
gap: 12px;
}
.tank-cell {
background: #050a18;
border: 1px solid #1e3060;
padding: 6px;
display: flex; flex-direction: column; align-items: center;
border-radius: 4px;
}
.tank-cell .label {
font-size: 13px; font-weight: 600; margin-bottom: 4px; color: #fff;
}
.hno3-group {
grid-column: 5 / 6; grid-row: 1 / 2;
background: #050a18;
border: 1px dashed #5a8;
padding: 8px;
display: flex; flex-direction: column;
}
.hno3-group .group-title {
text-align: center; font-size: 13px; font-weight: 600;
color: #5af; margin-bottom: 4px;
}
.hno3-group .group-tanks {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px;
flex: 1;
}
.hno3-group .tank-cell { border: none; padding: 2px; }
.h2so4-cell { grid-column: 1 / 3; grid-row: 2 / 3; }
.h2o2-cell { grid-column: 3 / 5; grid-row: 2 / 3; }
.gauge-row {
margin-top: 4px;
background: #0a1830; border: 1px solid #1a2f50;
padding: 4px 6px; width: 100%;
display: flex; flex-direction: column; align-items: center;
}
.gauge-row .gauge-label {
font-size: 9px; color: #8aa; margin-bottom: 2px;
}
.gauge-bars {
display: flex; gap: 2px; height: 10px; width: 100%;
}
.gauge-bars > div {
flex: 1; transition: background 0.3s;
}
.right-panel {
display: flex; flex-direction: column; gap: 8px;
}
.panel-box {
border: 1px solid #2a4070;
background: #0a1830;
border-radius: 4px;
}
.panel-box .panel-title {
background: linear-gradient(180deg, #1a3870, #0c1f4a);
color: #5af; text-align: center; padding: 4px;
font-size: 12px; font-weight: 600;
}
.panel-grid {
display: grid; gap: 4px; padding: 6px;
}
.panel-grid > div {
padding: 6px 4px; text-align: center; font-size: 11px; font-weight: 600;
border-radius: 3px; cursor: pointer; transition: filter 0.2s;
}
.panel-grid > div:hover { filter: brightness(1.3); }
.grid-5 { grid-template-columns: repeat(5, 1fr); }
.grid-2 { grid-template-columns: repeat(2, 1fr); }
.grid-3 { grid-template-columns: repeat(3, 1fr); }
.cell-green { background: #2a8b3a; color: #fff; }
.cell-pink { background: #d04880; color: #fff; }
.data-table {
border: 1px solid #2a4070;
background: #050a18;
border-radius: 4px;
overflow: hidden;
}
.data-table .panel-title {
background: linear-gradient(180deg, #1a3870, #0c1f4a);
color: #5af; text-align: center; padding: 4px;
font-size: 12px; font-weight: 600;
}
.data-table table {
width: 100%; border-collapse: collapse; font-size: 10px;
font-family: 'Consolas', monospace;
}
.data-table th, .data-table td {
border: 1px solid #1a2f50;
padding: 2px 4px; text-align: center;
}
.data-table thead { background: #0c1f4a; }
.data-table .group-th {
background: #142850; color: #5af; font-weight: 600;
}
.data-table tbody tr.fresh { animation: flash 1s ease-out; }
@keyframes flash {
0% { background: rgba(90, 255, 200, 0.4); }
100% { background: transparent; }
}
.footer {
background: #1a3a1a; color: #5af04a;
padding: 4px 20px; font-size: 12px; font-weight: 600;
border-top: 1px solid #1e6020;
}
/* 뚜껑 — 우측 끝 (89, 35) 을 경첩으로 -90도 회전 */
.tank-lid {
transform-origin: 89px 35px;
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
}
.tank-lid:hover { filter: brightness(1.15); }
.tank-lid.open { transform: rotate(90deg); }
.tank-cell { overflow: visible; }
</style>
</head>
<body>
<div class="header">
<h1>AUTO CHEMICAL SUPPLY MONITORING WEB SYSTEM</h1>
<div class="clock" id="clock">--:--:-- --/--/----</div>
</div>
<div class="main">
<!-- LEFT: 탱크 영역 -->
<div class="tanks-area" id="tanksArea">
<div class="tank-cell" id="cell-HCL"></div>
<div class="tank-cell" id="cell-CuCl2"></div>
<div class="tank-cell" id="cell-OXA"></div>
<!-- 4번째 자리는 비고 (HNO3 그룹이 5열 1행에 들어감) -->
<div></div>
<div class="hno3-group">
<div class="group-title">HNO3 - AU PLATING</div>
<div class="group-tanks">
<div class="tank-cell" id="cell-HNO3"></div>
<div class="tank-cell" id="cell-AU"></div>
<div class="tank-cell" id="cell-WHNO3"></div>
</div>
</div>
<div class="tank-cell h2so4-cell" id="cell-H2SO4"></div>
<div class="tank-cell h2o2-cell" id="cell-H2O2"></div>
</div>
<!-- RIGHT: 패널 + 테이블 -->
<div class="right-panel">
<div class="panel-box">
<div class="panel-title">SUPPLY CHEMICAL TO STORAGE TANK</div>
<div class="panel-grid grid-5">
<div class="cell-green">HCL</div>
<div class="cell-green">OXA</div>
<div class="cell-green">HNO3</div>
<div class="cell-green">H2O2</div>
<div class="cell-green">H2SO4</div>
</div>
</div>
<div class="panel-box">
<div class="panel-title">WASTE CHEMICAL</div>
<div class="panel-grid grid-2">
<div class="cell-green">CuCl2</div>
<div class="cell-green">HNO3 WASTE</div>
</div>
</div>
<div class="panel-box">
<div class="panel-title">SUPPLY CHEMICAL TO PRODUCTION</div>
<div class="panel-grid grid-3">
<div class="cell-green">CuCl2 DES#1</div>
<div class="cell-green">HCL DES#1</div>
<div class="cell-green">HCL CF2</div>
<div class="cell-green">OXA DES#1</div>
<div class="cell-pink">HNO3 AU</div>
<div class="cell-pink">H2SO4 COPPER</div>
</div>
</div>
<div class="data-table">
<div class="panel-title">SUPPLY AMOUNT OF COPPER PLATING ROOM</div>
<table>
<thead>
<tr>
<th rowspan="2">TIME</th>
<th colspan="2" class="group-th">HCL (L)</th>
<th colspan="2" class="group-th">CuCl2 (L)</th>
<th colspan="2" class="group-th">OXA (L)</th>
<th colspan="2" class="group-th">HNO3 (L)</th>
</tr>
<tr>
<th>FLOW</th><th>TOTAL</th>
<th>FLOW</th><th>TOTAL</th>
<th>FLOW</th><th>TOTAL</th>
<th>FLOW</th><th>TOTAL</th>
</tr>
</thead>
<tbody id="dataBody"></tbody>
</table>
</div>
</div>
</div>
<div class="footer">● System connected normally</div>
<script>
//==============================================================
// SVG 템플릿 (ChatGPT GPT-5 가 생성한 산업용 탱크)
// — id 와 url(#) 은 인스턴스마다 prefix 치환됨
//==============================================================
const TANK_SVG_TEMPLATE = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 180" overflow="visible" role="img" aria-label="Industrial chemical storage tank">
<defs>
<linearGradient id="metal-grad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="var(--metal-dark)"/>
<stop offset="0.18" stop-color="var(--metal-light)"/>
<stop offset="0.42" stop-color="#ffffff"/>
<stop offset="0.72" stop-color="var(--metal-dark)"/>
<stop offset="1" stop-color="var(--metal-light)"/>
</linearGradient>
<linearGradient id="glass-grad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.18"/>
<stop offset="0.12" stop-color="#ffffff" stop-opacity="0.04"/>
<stop offset="0.5" stop-color="#0b1528" stop-opacity="0.35"/>
<stop offset="0.88" stop-color="#ffffff" stop-opacity="0.06"/>
<stop offset="1" stop-color="#ffffff" stop-opacity="0.16"/>
</linearGradient>
<clipPath id="tank-clip">
<rect x="31" y="40" width="56" height="113"/>
</clipPath>
</defs>
<style>
.tank-num{fill:#cfd3d8;font-family:Arial,Helvetica,sans-serif;font-size:7px;text-anchor:end}
.tank-mono{fill:#ffffff;font-family:Consolas,'Courier New',monospace;font-size:13px;text-anchor:middle;dominant-baseline:middle}
</style>
<rect x="0" y="0" width="110" height="180" fill="#050a18"/>
<!-- Scale ruler -->
<g>
<line x1="22" y1="40" x2="22" y2="155" stroke="#ffffff" stroke-width="1" vector-effect="non-scaling-stroke"/>
<g stroke="#ffffff" stroke-width="1" vector-effect="non-scaling-stroke">
<line x1="17" y1="155" x2="22" y2="155"/>
<line x1="18.5" y1="149.25" x2="22" y2="149.25"/>
<line x1="18.5" y1="143.5" x2="22" y2="143.5"/>
<line x1="18.5" y1="137.75" x2="22" y2="137.75"/>
<line x1="17" y1="132" x2="22" y2="132"/>
<line x1="18.5" y1="126.25" x2="22" y2="126.25"/>
<line x1="18.5" y1="120.5" x2="22" y2="120.5"/>
<line x1="18.5" y1="114.75" x2="22" y2="114.75"/>
<line x1="17" y1="109" x2="22" y2="109"/>
<line x1="18.5" y1="103.25" x2="22" y2="103.25"/>
<line x1="18.5" y1="97.5" x2="22" y2="97.5"/>
<line x1="18.5" y1="91.75" x2="22" y2="91.75"/>
<line x1="17" y1="86" x2="22" y2="86"/>
<line x1="18.5" y1="80.25" x2="22" y2="80.25"/>
<line x1="18.5" y1="74.5" x2="22" y2="74.5"/>
<line x1="18.5" y1="68.75" x2="22" y2="68.75"/>
<line x1="17" y1="63" x2="22" y2="63"/>
<line x1="18.5" y1="57.25" x2="22" y2="57.25"/>
<line x1="18.5" y1="51.5" x2="22" y2="51.5"/>
<line x1="18.5" y1="45.75" x2="22" y2="45.75"/>
<line x1="17" y1="40" x2="22" y2="40"/>
</g>
<text x="15" y="157" class="tank-num">0</text>
<text x="15" y="134" class="tank-num">20</text>
<text x="15" y="111" class="tank-num">40</text>
<text x="15" y="88" class="tank-num">60</text>
<text x="15" y="65" class="tank-num">80</text>
<text x="15" y="42" class="tank-num">100</text>
</g>
<!-- 뚜껑 (클릭하면 -90도 회전: ㅡ → ㅣ) -->
<g class="tank-lid">
<!-- 클릭 영역을 살짝 넓혀주는 투명 hit-box -->
<rect x="25" y="8" width="68" height="32" fill="transparent"/>
<!-- Top nozzle -->
<rect x="51" y="10" width="16" height="4" rx="1" fill="url(#metal-grad)" stroke="#ffffff" stroke-width="1"/>
<rect x="54" y="14" width="10" height="6" fill="url(#metal-grad)" stroke="var(--tank-stroke)" stroke-width="1"/>
<!-- Dome cap -->
<path d="M29 35 C29 22 43 18 59 18 C75 18 89 22 89 35 L89 39 L29 39 Z" fill="url(#metal-grad)" stroke="#ffffff" stroke-width="1"/>
<rect x="27" y="35" width="64" height="5" fill="url(#metal-grad)" stroke="#ffffff" stroke-width="1"/>
<line x1="29" y1="38" x2="89" y2="38" stroke="#333333" stroke-width="0.8"/>
</g>
<!-- Tank glass body -->
<rect x="29" y="39" width="60" height="115" fill="#081326" stroke="#ffffff" stroke-width="1"/>
<rect x="31" y="40" width="56" height="113" fill="url(#glass-grad)" stroke="var(--tank-stroke)" stroke-width="1"/>
<!-- Liquid -->
<rect id="tank-liquid" x="31" y="155" width="56" height="0" fill="var(--liquid-color)" clip-path="url(#tank-clip)"/>
<ellipse id="tank-surface" cx="59" cy="155" rx="28" ry="2.2" fill="var(--liquid-color)" opacity="0.85" clip-path="url(#tank-clip)"/>
<!-- Glass highlight -->
<path d="M35 43 L43 43 L38 146 L34 153 Z" fill="#ffffff" opacity="0.16"/>
<rect x="82" y="42" width="3" height="111" fill="#ffffff" opacity="0.08"/>
<!-- Level readout -->
<rect id="level-bg" x="40" y="83" width="38" height="18" rx="4" fill="#000000" stroke="#ffffff" stroke-width="1"/>
<text id="level-text" x="59" y="92" class="tank-mono">0.0%</text>
<!-- Base -->
<rect x="28" y="153" width="62" height="7" fill="url(#metal-grad)" stroke="#ffffff" stroke-width="1"/>
<line x1="30" y1="154.5" x2="88" y2="154.5" stroke="#ffffff" stroke-width="0.6" opacity="0.65"/>
<!-- Outlet pipe + pump -->
<rect x="55" y="160" width="8" height="12" fill="url(#metal-grad)" stroke="#ffffff" stroke-width="1"/>
<circle cx="59" cy="166" r="8.5" fill="#081326" stroke="#ffffff" stroke-width="1"/>
<circle cx="59" cy="166" r="6.6" fill="none" stroke="var(--tank-stroke)" stroke-width="1"/>
<path d="M56 161.8 L56 170.2 L63.5 166 Z" fill="#ffffff"/>
<rect x="56" y="173" width="7" height="7" fill="url(#metal-grad)" stroke="#ffffff" stroke-width="1"/>
<rect x="53" y="178" width="13" height="2" fill="url(#metal-grad)" stroke="#ffffff" stroke-width="1"/>
</svg>`;
//==============================================================
// 탱크 SVG 컴포넌트 — 재사용 가능
//==============================================================
class TankGauge {
/**
* opts:
* id 고유 식별자 (SVG 내부 id 충돌 방지용 prefix)
* label 표시 라벨 (HCL, CuCl2 등)
* capacityText "10m3" 같은 부가 텍스트 (없으면 표시 안함)
* color 액체 색상 (CSS color)
* level 초기 레벨 (0~100)
* width/height SVG 크기 (생략시 110 x 180)
*/
constructor(opts) {
Object.assign(this, opts);
this.level = opts.level ?? 0;
this.element = this._render();
}
_render() {
const wrapper = document.createElement('div');
wrapper.style.cssText = 'display:flex;flex-direction:column;align-items:center;width:100%;';
// 라벨
const labelEl = document.createElement('div');
labelEl.style.cssText = 'font-size:13px;font-weight:600;color:#fff;margin-bottom:2px;';
labelEl.innerHTML = `${this.label}${this.capacityText ? ` <span style="color:#8af;font-size:11px;">(${this.capacityText})</span>` : ''}`;
wrapper.appendChild(labelEl);
// SVG: id 와 url(#) 모두 instance prefix 치환
const id = this.id;
const svgHtml = TANK_SVG_TEMPLATE
.replace(/id="/g, `id="${id}-`)
.replace(/url\(#/g, `url(#${id}-`);
const tmp = document.createElement('div');
tmp.innerHTML = svgHtml;
const svgEl = tmp.firstElementChild;
// 크기 조절 (HNO3 sub 탱크는 작게)
svgEl.setAttribute('width', this.width ?? 110);
svgEl.setAttribute('height', this.height ?? 180);
// CSS 변수로 색상 주입 — 인스턴스별로 액체/금속 색 분리
svgEl.style.setProperty('--liquid-color', this.color);
svgEl.style.setProperty('--tank-stroke', '#aaaaaa');
svgEl.style.setProperty('--metal-light', '#dddddd');
svgEl.style.setProperty('--metal-dark', '#666666');
svgEl.style.background = '#050a18';
svgEl.style.display = 'block';
// 뚜껑 클릭 → open/close 토글
const lid = svgEl.querySelector('.tank-lid');
if (lid) {
lid.addEventListener('click', (e) => {
e.stopPropagation();
lid.classList.toggle('open');
});
}
wrapper.appendChild(svgEl);
// 막대 게이지 (탱크 아래)
const gauge = document.createElement('div');
gauge.className = 'gauge-row';
gauge.innerHTML = `
<div class="gauge-label">${this.label} SUPPLY LINES</div>
<div class="gauge-bars" id="bars-${id}"></div>
`;
wrapper.appendChild(gauge);
return wrapper;
}
/**
* 동적 레벨 변경 — SVG 내부 4개 element 갱신:
* tank-liquid (rect) : y, height
* tank-surface (ellipse): cy
* level-text (text) : textContent
* level-bg (rect) : fill (30% 미만 빨강 / 90% 초과 녹색 / 그 외 검정)
*/
setLevel(pct) {
pct = Math.max(0, Math.min(100, pct));
this.level = pct;
const id = this.id;
const liquid = this.element.querySelector(`#${id}-tank-liquid`);
const surface = this.element.querySelector(`#${id}-tank-surface`);
const pctText = this.element.querySelector(`#${id}-level-text`);
const bg = this.element.querySelector(`#${id}-level-bg`);
if (!liquid) return;
// 받은 SVG 좌표: 액체 영역 y=40 (가득) ~ y=155 (바닥), 높이 115
const fullH = 115;
const baseY = 155;
const h = (pct / 100) * fullH;
const y = baseY - h;
liquid.setAttribute('y', y);
liquid.setAttribute('height', h);
surface.setAttribute('cy', y);
pctText.textContent = pct.toFixed(1) + '%';
if (pct < 30) bg.setAttribute('fill', '#a02020');
else if (pct > 90) bg.setAttribute('fill', '#206020');
else bg.setAttribute('fill', '#000000');
}
mount(parentSelector) {
const parent = document.querySelector(parentSelector);
if (parent) parent.appendChild(this.element);
}
}
//==============================================================
// 막대 게이지 — 14칸 (녹/주/꺼짐 랜덤)
//==============================================================
function renderBars(id, count = 14) {
const host = document.getElementById(`bars-${id}`);
if (!host) return;
host.innerHTML = '';
for (let i = 0; i < count; i++) {
const bar = document.createElement('div');
const r = Math.random();
bar.style.background = r < 0.55 ? '#3aa848' : r < 0.85 ? '#e88728' : '#1a2f50';
host.appendChild(bar);
}
}
//==============================================================
// 탱크 6 + 3 = 9 인스턴스 생성
//==============================================================
const tanks = [
new TankGauge({ id: 'HCL', label: 'HCL', capacityText: '10m3', color: '#ff8a3a', level: 56.6 }),
new TankGauge({ id: 'CuCl2', label: 'CuCl2', capacityText: '10m3', color: '#3acc4a', level: 73.7 }),
new TankGauge({ id: 'OXA', label: 'OXA', capacityText: '10m3', color: '#5ac8e8', level: 44.5 }),
new TankGauge({ id: 'HNO3', label: 'HNO3', capacityText: '', color: '#e040a8', level: 42.7, width: 80, height: 130 }),
new TankGauge({ id: 'AU', label: 'AU PLATING', capacityText: '', color: '#888888', level: 0, width: 80, height: 130 }),
new TankGauge({ id: 'WHNO3', label: 'WASTE HNO3', capacityText: '', color: '#f6a8c8', level: 47.6, width: 80, height: 130 }),
new TankGauge({ id: 'H2SO4', label: 'H2SO4', capacityText: '5m3', color: '#e0488a', level: 72.9 }),
new TankGauge({ id: 'H2O2', label: 'H2O2', capacityText: '5m3', color: '#f8c8d8', level: 84.1 }),
];
tanks.forEach(t => {
t.mount(`#cell-${t.id}`);
t.setLevel(t.level);
renderBars(t.id);
});
//==============================================================
// 시뮬레이션 — 1초마다 탱크 레벨 ±0.3% 변동, 막대 2초마다 갱신
//==============================================================
setInterval(() => {
tanks.forEach(t => {
if (t.id === 'AU') return; // AU PLATING 은 빈 탱크 유지
const delta = (Math.random() - 0.5) * 0.6;
t.setLevel(t.level + delta);
});
}, 1000);
setInterval(() => {
tanks.forEach(t => renderBars(t.id));
}, 2000);
//==============================================================
// 시계
//==============================================================
function tick() {
const d = new Date();
const pad = n => String(n).padStart(2, '0');
const time = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
const date = `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()}`;
document.getElementById('clock').textContent = `${time} ${date}`;
}
tick();
setInterval(tick, 1000);
//==============================================================
// 데이터 테이블 시뮬레이션 — 8행 유지, 5초마다 새 행 추가
//==============================================================
const tbody = document.getElementById('dataBody');
const totals = { HCL: 1258.4, CuCl2: 1523.1, OXA: 1024.8, HNO3: 856.3 };
function addRow() {
const d = new Date();
const pad = n => String(n).padStart(2, '0');
const t = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
const flows = {
HCL: 18 + Math.random() * 1.5,
CuCl2: 22 + Math.random() * 1.0,
OXA: 15 + Math.random() * 0.8,
HNO3: 12.5 + Math.random() * 0.6,
};
Object.keys(flows).forEach(k => totals[k] += flows[k]);
const tr = document.createElement('tr');
tr.classList.add('fresh');
tr.innerHTML = `
<td>${t}</td>
<td>${flows.HCL.toFixed(1)}</td><td>${totals.HCL.toFixed(1)}</td>
<td>${flows.CuCl2.toFixed(1)}</td><td>${totals.CuCl2.toFixed(1)}</td>
<td>${flows.OXA.toFixed(1)}</td><td>${totals.OXA.toFixed(1)}</td>
<td>${flows.HNO3.toFixed(1)}</td><td>${totals.HNO3.toFixed(1)}</td>
`;
tbody.insertBefore(tr, tbody.firstChild);
while (tbody.children.length > 8) tbody.removeChild(tbody.lastChild);
}
for (let i = 0; i < 8; i++) addRow();
setInterval(addRow, 5000);
</script>
</body>
</html>
@@ -0,0 +1,21 @@
# Legacy SQL 아카이브 (Flyway 충돌 회피)
2026-05-03 backend-spring/src/main/resources/db/migration/ 에서 이쪽으로 옮겨온 SQL 두 개. 더이상 Flyway 자동 실행 대상 아님.
## 옮긴 이유
- 4/28 Johngreen 이 main 브랜치에 AI 모듈 V001~V016 추가 (`V001__create_ai_llm_providers.sql` 등)
- 5/3 새벽 80cd2b2d0 커밋이 누락 untracked 파일 일괄 추가 시 본 두 파일이 같이 끌려들어가면서 V001/V002 중복 발생
- Flyway 는 같은 버전 두 개면 부팅 fail (`Found more than one migration with version N`) → backend-spring 컨테이너 crash → 운영 다운 위험
- 본 두 파일은 운영 DB 에 이미 도커 직접 실행으로 적용 완료된 1회성 마이그레이션이라, git 트리에서 빼는 대신 기록용으로 이쪽 보관
## 파일
| 파일 | 크기 | 내용 |
|---|---|---|
| `V001__varchar_migration.sql` | 70KB | 비-timestamp 숫자/boolean 컬럼 → VARCHAR 일괄 변환 (608 컬럼, FK 17개 drop/recreate) |
| `V002__create_missing_tables.sql` | 2.9KB | booking_requests / mail_account_file / mail_template_file / open_api_proxy / work_history 5개 테이블 신규 생성 |
## 다시 돌릴 일이 있다면
새 환경(신규 테넌트 DB) 에 처음부터 적용해야 하는 상황이면, 파일명 V001/V002 prefix 를 떼고 `psql -f` 로 직접 돌리거나, AI V016 뒤 V017/V018 로 rename 해서 Flyway 체인에 다시 끼워넣기. 단 이 경우 `flyway_schema_history` 와 실제 DB 상태가 일치하는지 사전 확인 필수.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,88 @@
-- V002: 누락된 테이블 생성
-- booking_requests, mail_account_file, mail_template_file, open_api_proxy, work_history
CREATE TABLE IF NOT EXISTS booking_requests (
id SERIAL PRIMARY KEY,
customer_name VARCHAR(255),
customer_phone VARCHAR(100),
pickup_location TEXT,
dropoff_location TEXT,
scheduled_time TIMESTAMP,
vehicle_type VARCHAR(100),
cargo_type VARCHAR(100),
weight VARCHAR(50),
status VARCHAR(50) DEFAULT 'pending',
priority VARCHAR(50) DEFAULT 'normal',
notes TEXT,
estimated_cost VARCHAR(50),
rejection_reason TEXT,
accepted_at TIMESTAMP,
rejected_at TIMESTAMP,
completed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS mail_account_file (
id SERIAL PRIMARY KEY,
company_code VARCHAR(50),
name VARCHAR(255),
email VARCHAR(255),
smtp_host VARCHAR(255),
smtp_port INTEGER,
smtp_secure BOOLEAN,
smtp_username VARCHAR(255),
smtp_password VARCHAR(255),
daily_limit INTEGER,
status VARCHAR(50) DEFAULT 'active',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS mail_template_file (
id SERIAL PRIMARY KEY,
company_code VARCHAR(50),
name VARCHAR(255),
subject VARCHAR(500),
components JSONB,
query_config JSONB,
recipient_config JSONB,
category VARCHAR(100),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS open_api_proxy (
id SERIAL PRIMARY KEY,
company_code VARCHAR(50),
name VARCHAR(255),
status VARCHAR(50) DEFAULT 'active',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS work_history (
id SERIAL PRIMARY KEY,
work_number VARCHAR(100),
work_date DATE,
work_type VARCHAR(50),
vehicle_number VARCHAR(100),
driver_name VARCHAR(255),
origin VARCHAR(255),
destination VARCHAR(255),
cargo_name VARCHAR(255),
cargo_weight NUMERIC,
cargo_unit VARCHAR(50),
distance NUMERIC,
distance_unit VARCHAR(50),
status VARCHAR(50) DEFAULT 'pending',
scheduled_time TIMESTAMP,
estimated_arrival TIMESTAMP,
actual_arrival TIMESTAMP,
is_on_time BOOLEAN,
notes TEXT,
created_by VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
deleted_at TIMESTAMP
);
@@ -0,0 +1,181 @@
# AI 에이전트 관리 페이지 — 그렘린 카오스 테스트 리포트
- **일시**: 2026-05-02
- **대상**: `http://localhost:9772/admin/aiAssistant/agents`
- **방법**: Claude-in-Chrome MCP + gremlins.js v2.2.0 + Fuzz Payload 주입
- **계정**: Invyone (최고 관리자)
- **테스트 시점 페이지 상태**: 에이전트 추가 모달 + Tweaks 패널 열림
---
## 1. 테스트 구성
| 단계 | 도구/방식 | 횟수 | 종(Species) |
|---|---|---|---|
| 1차 그렘린 | gremlins.js | 100 | clicker, toucher, formFiller, scroller, typer |
| 2차 그렘린 | gremlins.js | 500 | 동일 5종 |
| Fuzz Payload | input/textarea 직접 주입 | 36 (3 input × 12 payload) | SQL injection, XSS, 긴 문자열, 유니코드, JNDI, path traversal 등 |
### 사용한 Fuzz Payload
```
"", "' OR '1'='1", "<script>alert(1)</script>", "A".repeat(3000),
"𝒯𝑒𝓈𝓉 ㅎ안녕 🎉🔥", "../../../etc/passwd", "0", "-1", "9".repeat(20),
"<img src=x onerror=alert(1)>", "${jndi:ldap://x.com/a}", " "
```
### 수집기 설치 항목
- `window.error` 이벤트
- `unhandledrejection` 이벤트
- `console.error` 패치
- `fetch` 패치 (status >= 400 캐치)
- `XMLHttpRequest.open` 패치 (status >= 400 캐치)
---
## 2. 결과 요약
| 항목 | 1차(100) | 2차(500) | Fuzz(36) |
|---|---|---|---|
| 소요 시간 | 3.5s | 12.3s | 1.6s |
| 신규 버그 | 0 | 0 | **1** |
| URL 변경 | 없음 | 없음 | 없음 |
**총 발견 이슈: 2건** (그렘린 폭격으로 발견 1, 부수 검증으로 발견 1)
---
## 3. 발견된 이슈
### 🟡 Issue #1 — `GET /api/ai-agents` 500 산발 발생
- **상황**: 2차 폭격 + fuzz 진행 중 1회 발생
- **증거**: `xhr_500` 1건 수집 (메시지는 Claude-in-Chrome 보안 필터로 마스킹되었으나 path 추출 결과 `/api/ai-agents`)
- **재현 시도**: 직접 fetch 9건 — **모두 200 OK**
- `?` 빈 query
- `?search=`
- `?search=test`
- `?search=' OR '1'='1` (인코딩 / 비인코딩)
- `?page=-1`
- `?page=AAAA...` (300자)
- `?id=null`
- **추정 원인**:
- Race condition (동시 요청 폭주)
- 일시적 DB lock 또는 트랜잭션 충돌
- Spring 측 일시적 세션 검증 실패
- **심각도**: 🟡 중 (재현 불가, 산발성)
- **권장 조치**:
- Spring `application.log` 의 해당 시각 (`ts: 1777686237317` 근방) 스택 트레이스 확인
- `MDC` 로 request id 남겨 향후 동일 패턴 추적
### 🔴 Issue #2 — UI/API 데이터 불일치 ⭐ 핵심
- **상황**: 그렘린 폭격과 무관, 검증 과정에서 발견
- **증거**:
- 화면 표시: **"등록된 에이전트가 없습니다"**
- 실제 API 응답:
```json
{
"success": true,
"data": [{
"id": 13,
"agent_id": "agent-766beb3a",
"name": "UI 테스트 에이전트 (수정됨)",
"description": "두 번째 에이전트 (UI 흐름)",
"model": "claude-sonnet-4-20250514",
...
}]
}
```
- **추정 원인 후보**:
1. **테넌트 컨텍스트 불일치** — 백엔드는 모든 회사 데이터 반환, 프론트가 회사 필터링 후 빈 배열 인식
2. **React Query 캐시 stale** — 이전 빈 응답을 보여주고 재검증 안 함
3. **API 라우팅 분기** — `/api/ai-agents` 와 화면이 사용하는 endpoint 가 다름 (페이지에서 별도 endpoint 호출 가능성)
- **심각도**: 🔴 높음
- CLAUDE.md 멀티테넌시 가드와 직결 — 다른 회사 데이터 노출 가능성
- **권장 조치**:
1. `frontend/app/(main)/admin/aiAssistant/agents/page.tsx` 의 데이터 fetch 코드 확인 — 어느 endpoint 호출?
2. 백엔드 `ai-agents` mapper 의 `companyCodeFilter` include 여부 확인
3. 직접 호출 응답에 `company_code` 가 있는지 확인
---
## 4. 잡힌 것 / 잡히지 않은 것
| 항목 | 결과 |
|---|---|
| UI 크래시 | ✅ 없음 |
| 무작위 클릭 600회 안정성 | ✅ 견고 |
| 폼에 SQL injection / XSS 입력 시 클라이언트 측 폭주 | ✅ 없음 |
| 긴 문자열(3000자) 입력 처리 | ✅ 정상 |
| Console 에러 (앱 발생) | ✅ 0건 |
| Console 워닝 | 🟡 1건 — Radix `DialogContent` 의 `aria-describedby` 누락 (a11y) |
| 백엔드 5xx | 🟡 1건 (산발) |
| 데이터 일관성 | 🔴 1건 (UI/API 불일치) |
| Visual regression | 미검증 (별도 도구 필요) |
---
## 5. 결론 및 다음 단계
### 결론
- **에이전트 관리 페이지는 무작위 폭격에 매우 견고**
- 그렘린의 본래 목적(클라이언트 측 크래시 발견)으로는 큰 수확 없음
- 그러나 **부수 검증(fetch 가로채기)으로 의미 있는 백엔드/데이터 이슈 2건 발견**
### 다음 단계
1. Issue #2 (데이터 불일치) 즉시 조사 — 가장 중요
2. Issue #1 재현 시도 — 동시 다발 요청 (`Promise.all` × 50) 으로 race condition 유발
3. 다른 페이지에도 동일 절차 적용 — 사이드바 메뉴 자동 순회 + 그렘린 + fetch hook
4. Radix Dialog `aria-describedby` 추가 (a11y 개선, 1줄 수정)
### 재사용 가능한 자산
- 본 테스트 절차를 OMC 스킬로 전환 → `/oh-my-claudecode:gremlins` 같은 명령으로 재실행
- Playwright + axe-core + page.on('response') 조합으로 CI 통합 가능
---
## 6. 사용한 스크립트 (재현용)
```javascript
// 1. 다이얼로그 차단 + 수집기 + gremlins 로드
window.alert = () => {};
window.confirm = () => true;
window.prompt = () => '';
window.__gremBugs = [];
window.addEventListener('error', e => window.__gremBugs.push({ type:'window.error', msg:e?.message, url:location.href, ts:Date.now() }));
window.addEventListener('unhandledrejection', e => window.__gremBugs.push({ type:'unhandledrejection', msg:String(e.reason), url:location.href, ts:Date.now() }));
const origFetch = window.fetch;
window.fetch = async (...args) => {
const res = await origFetch(...args);
if (res.status >= 400) {
const u = typeof args[0] === 'string' ? args[0] : args[0]?.url;
window.__gremBugs.push({ type:`http_${res.status}`, msg:`${u} → ${res.status}`, url:location.href, ts:Date.now() });
}
return res;
};
await new Promise((res, rej) => {
const s = document.createElement('script');
s.src = 'https://unpkg.com/gremlins.js@2.2.0/dist/gremlins.min.js';
s.onload = res; s.onerror = rej;
document.head.appendChild(s);
});
// 2. 폭격
await window.gremlins.createHorde({
species: [
window.gremlins.species.clicker(),
window.gremlins.species.toucher(),
window.gremlins.species.formFiller(),
window.gremlins.species.scroller(),
window.gremlins.species.typer()
],
mogwais: [],
strategies: [window.gremlins.strategies.distribution({ delay: 15, nb: 500 })]
}).unleash();
// 3. 결과
console.log(window.__gremBugs);
```
@@ -0,0 +1,216 @@
# 2026-05-02 — 그렘린 카오스 테스트 + JSONB Fix Autopilot 종합 리포트
## 세션 개요
브라우저 그렘린 카오스 테스트 (Claude-in-Chrome MCP + gremlins.js + fetch hook) 로 INVYONE 의 숨겨진 백엔드/프론트엔드 버그를 발견하고 일괄 수정.
**본 세션의 핵심 인사이트**: 그렘린 폭격 자체는 클라이언트 크래시를 거의 안 잡았지만, **fetch hook + 자동 메뉴 순회 + 백엔드 docker logs stack trace 매핑** 3종 세트가 진짜 ROI. 이 패턴을 `/oh-my-claudecode:gremlins` 스킬로 캐시.
---
## 발견된 버그 (총 7개)
### Round 1 — Audit Log
| # | 종류 | 파일 / 위치 | Fix 상태 |
|---|---|---|---|
| 1 | URL 단/복수 mismatch (404) | `frontend/lib/api/auditLog.ts:62, 74, 90` + `frontend/components/screen/CopyScreenModal.tsx:1172` | ✅ `/audit-log``/audit-logs` |
| 2 | DB 컬럼명 mismatch (500) | `backend-spring/.../mapper/auditLog.xml` (11곳) | ✅ `CREATED_DATE``CREATED_AT` |
| 3 | API 응답 키 case mismatch | `frontend/lib/api/auditLog.ts:35-38` interface + `audit-log/page.tsx` (4곳) | ✅ camelCase → snake_case |
### Round 2 — Multi-Agent Workspace + AI 모듈 전반
| # | 종류 | 파일 / 위치 | Fix 상태 |
|---|---|---|---|
| 4 | JSONB string vs array (Client Exception) | `frontend/app/(main)/admin/aiAssistant/workspace/page.tsx:598` (`memberConnectors.map`) | ✅ Phase 1+3 |
| 5 | AI 모듈 Service 5개에 동일 패턴 누락 | `AiAgentApiKeyService`, `AiAgentProviderService`, `AiAgentConversationService`, `AiAgentService`, `AiSchedulerService` | ✅ Phase 2 |
| 6 | `getEntityById` 잉여 분기 (code review N6) | `AiAgentGroupService.java:94-98` | ✅ N6 simplify |
| 7 | 그렘린이 트리거한 산발적 5xx | `/api/ai-agents` GET | 🟡 재현 안 됨, race condition 의심 |
---
## 변경된 파일 전체 목록
### Backend (Java/Spring)
- `backend-spring/src/main/resources/mapper/auditLog.xml` (CREATED_DATE → CREATED_AT, 11곳)
- `backend-spring/src/main/java/com/erp/ai/service/AiAgentGroupService.java` (parseJsonField + memberToResponseMap)
- `backend-spring/src/main/java/com/erp/ai/service/AiAgentApiKeyService.java` (list 응답 변환)
- `backend-spring/src/main/java/com/erp/ai/service/AiAgentProviderService.java` (toMaskedMap 보강 + getById/getEntityById 분리)
- `backend-spring/src/main/java/com/erp/ai/service/AiAgentConversationService.java` (conversations + messages 변환)
- `backend-spring/src/main/java/com/erp/ai/service/AiAgentService.java` (list/getById Map + getEntityById 분리)
- `backend-spring/src/main/java/com/erp/ai/service/AiSchedulerService.java` (list/getById Map + getEntityById 분리)
- `backend-spring/src/main/java/com/erp/ai/controller/AiAgentApiKeyController.java` (return type generic)
- `backend-spring/src/main/java/com/erp/ai/controller/AiAgentController.java` (return type generic)
### Frontend (Next.js/React)
- `frontend/lib/api/auditLog.ts` (URL 4곳 + interface 4 key)
- `frontend/app/(main)/admin/audit-log/page.tsx` (stats access 4곳)
- `frontend/components/screen/CopyScreenModal.tsx` (audit-logs URL 1곳)
- `frontend/lib/utils.ts` (`safeArray<T>` / `safeObject<T>` 헬퍼 추가)
- `frontend/app/(main)/admin/aiAssistant/workspace/page.tsx` (safeArray 적용 + import)
### 메모리 / 스킬
- `~/.claude/skills/gremlins/SKILL.md` (그렘린 카오스 테스트 OMC 스킬 신설)
- `~/.claude/projects/.../memory/feedback_gremlins_default.md` (그렘린 default 1000마리 사용자 선호)
---
## Phase 4 Validation 결과
3개 reviewer 병렬 검증.
| Reviewer | 결과 |
|---|---|
| **Architect** | PASS WITH NOTES |
| **Code-reviewer** | APPROVE WITH NITPICKS |
| **Security-reviewer** | NEEDS REVIEW (H1 발견) |
본 task (JSONB fix) 는 모두 PASS. H1 은 인접한 보안 이슈로 별개 처리.
---
## 🔴 미처리 — Follow-up 필요 (별도 PR 권장)
### H1 (HIGH 보안) — `AiAgentApiKey listAll` Cross-tenant 노출
- `backend-spring/src/main/java/com/erp/ai/service/AiAgentApiKeyService.java:42-43`
- `apiKeyMapper.listAll()``company_code` 필터 없음
- `COMPANY_ADMIN` 이 다른 회사 API 키 목록 받음
- **이번 fix 가 응답을 명확히 하면서 더 잘 노출되는 부수효과**
- Mitigation 옵션:
- A) `listByCompany(companyCode)` mapper 쿼리 신설 + `COMPANY_ADMIN` 분기 변경
- B) `apiKeyMapper.xml``listAll``<include refid="common.companyCodeFilter"/>` 적용 + `SUPER_ADMIN` 만 별도 path
- C) Service 레벨에서 listAll 결과를 stream filter (단순, 단 풀 fetch)
- **권장**: 운영 적용 전 1주일 내 fix
### M1 — `AiLlmProvider.config` 평문 누출 가능성
- `backend-spring/src/main/java/com/erp/ai/service/AiAgentProviderService.java:130, 136`
- `api_key_encrypted``****+last4` 마스킹되지만 `config` JSONB 는 그대로 노출
- 운영자가 config 안에 평문 secret 넣으면 list 응답에서 노출
- Mitigation: config 키 이름 패턴 매칭 (`(?i)key|secret|token|password`) → 자동 마스킹
### M2 — `AiAgentApiKeyController.create` 응답 inconsistency
- `permissions` 필드가 list 에선 array, create 응답에선 string
- frontend `safeArray` 가 안전망 역할이지만 contract 차원에서 일관성 결여
- `AiAgentApiKeyService.toResponseMap` public 노출 → controller 에서 재사용 권장
### M3 — `AiAgentProviderController.create/update` model 직접 노출
- `backend-spring/src/main/java/com/erp/ai/controller/AiAgentProviderController.java:44-46, 53-55`
- `AiLlmProvider` model 그대로 응답 — `config` 가 string 으로 노출
- create 직후 응답을 직접 setState 하면 같은 폭발
### M4 — `workspace/page.tsx` 잔여 `connectors` 직접 접근 5곳
- line 116, 124, 140, 196, 210, 496
- 일부는 클라이언트 init state 라 안전, 일부는 백엔드 응답 사용 가능성
- 모두 `safeArray(...)` 로 통일 권장 (한 페이지 일관성)
### M5 — `api-keys-manage/page.tsx:374-379` `msg.tool_calls` 미방어
- `safeObject` 적용 권장
### N1 — `parseJsonField` 헬퍼 13벌 중복
- AI 모듈 6 + 비-AI 모듈 7 = 총 13 Service 가 동일 헬퍼
- CLAUDE.md "새 추상화 도입 안 함" 원칙엔 부합, 단 유지보수 비용 누적
- 별도 chore PR `refactor: extract MapJsonHelper utility` 권장 (장기)
### N2 — Naming 일관성 (`conv` 약어 vs 풀네임)
- `convToResponseMap``conversationToResponseMap` 권장 (`AiAgentConversationService.java`)
### N3 — `catch (Exception)` 너무 광범위
- 모든 `parseJsonField``catch (Exception e)``catch (JsonProcessingException e)` 로 좁히기
### Round 2 의 산발 5xx (재현 안 됨)
- `GET /api/ai-agents` 가 폭격 중 1회 500 응답
- 재현 시도 9건 모두 200
- Race condition / DB lock 의심
- MDC 로 request id 추가하여 향후 동일 패턴 추적 권장
---
## 검증 결과
**Phase 1 효과 확인** (실제 백엔드 응답):
```json
GET /api/ai-agent-groups/{id}
members[0].connectors: { isArray: true, type: "object", preview: "Array(0)" }
members[0].config: { isObject: true, type: "object", preview: "{}" }
HTTP 200 OK
```
이전 (fix 전):
```
connectors: STRING("[]") → frontend `.map()` 폭발
```
---
## 사용한 OMC 도구
- **Skill**: `gremlins` (이번 세션에서 신설)
- **Skill**: `autopilot` (Phase 1+2+3 일괄 실행)
- **Agent**: `architect` (옵션 A/B/C 자문 + Phase 4 검증)
- **Agent**: `executor` (Phase 2 — 5 Service 일괄 변경)
- **Agent**: `code-reviewer` (Phase 4 검증)
- **Agent**: `security-reviewer` (Phase 4 검증, H1 발견)
- **MCP**: `claude-in-chrome` (브라우저 자동화 + fetch hook)
---
## 확립된 패턴 (앞으로 재사용)
### Backend JSONB 응답 표준
```java
// Service 의 응답 메서드 (컨트롤러가 호출하는 것) 만 변환
public Map<String, Object> getById(long id) {
Entity e = mapper.getById(id);
if (e == null) return null;
Map<String, Object> row = new HashMap<>();
// ... 모든 필드 put
parseJsonField(row, "jsonb_field_1");
parseJsonField(row, "jsonb_field_2");
return row;
}
// 내부용 (model 그대로 필요한 곳, 예: LLM 호출)
public Entity getEntityById(long id) { return mapper.getById(id); }
private void parseJsonField(Map<String, Object> row, String key) {
Object val = row.get(key);
if (val instanceof String s && !s.isBlank()) {
try { row.put(key, objectMapper.readValue(s, Object.class)); }
catch (Exception e) { log.warn("Failed to parse JSONB field '{}': {}", key, e.getMessage()); }
}
}
```
### Frontend 안전망
```typescript
import { safeArray, safeObject } from "@/lib/utils";
// JSONB array 필드 사용
const items = safeArray<Item>(row.connectors);
items.map(...) // 안전
// JSONB object 필드 사용
const cfg = safeObject<Config>(row.config);
cfg.someKey // 안전
```
---
## 향후 미래 버그 예방안
CLAUDE.md 백엔드 섹션에 1단락 추가 권장:
```markdown
## JSONB 컬럼 응답 처리 (★ 절대 규칙)
PostgreSQL JSONB 컬럼은 mapper 에서 `column::text AS column` 으로 SELECT 하고,
**Service 레이어의 응답 메서드에서 `parseJsonField(row, "field1", ...)` 로 후처리**.
프론트 응답에 String 인 채로 노출 금지.
모범 사례: `BusinessRuleService.parseJsonField`, `AuditLogService.processChanges`,
`AiAgentGroupService.memberToResponseMap`
```
---
생성된 그렘린 스킬: `C:\Users\ramse\.claude\skills\gremlins\SKILL.md`
다음 세션부터 `/gremlins` 또는 "그램린" 키워드로 자동 호출.