You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
851 lines
41 KiB
851 lines
41 KiB
|
1 month ago
|
# NEW Web Configurator v1.5.4.2 — 시스템 아키텍처
|
||
|
|
|
||
|
|
**문서 버전**: 1.0
|
||
|
|
**앱 버전**: v1.5.4.2 (SHA `fe5cd25`, 태그 `v1.5.4.2`)
|
||
|
|
**브랜치**: `feature/v1.5-ia-redesign` (origin 푸시 완료)
|
||
|
|
**작성일**: 2026-06-08
|
||
|
|
**상태**: dev/verify 환경 .54 (192.168.55.54), 운영 .56은 v1.4.6.10 유지
|
||
|
|
**상위 문서**: `docs/architecture.md` (v2.2, v1.3.0 시대까지 커버) — 이 문서가 해당 문서를 대체함
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 1. 요약
|
||
|
|
|
||
|
|
**NEW Web Configurator**는 Telechips TCC8030 IoT 디바이스를 위한 브라우저 기반 설정 도구다. **Python stdlib(표준 라이브러리) 전용 HTTP 서버** (pip 없음, 프레임워크 없음)와 **바닐라 ES 모듈 SPA** (빌드 단계 없음, 번들러 없음)로 구성된다. 기존 Java/Spring Boot 기반 설정 도구를 대체한 것으로, 해당 도구는 TCC8030의 700 MHz ARM에서 메모리를 436 MB 이상 점유했다 — 각 백그라운드 서비스에 48 MB MemoryMax 예산이 배정된 디바이스에서. Python 프로세스는 RSS 기준 10–30 MB 수준으로 동작한다.
|
||
|
|
|
||
|
|
핵심 설계 원칙:
|
||
|
|
|
||
|
|
1. **LAN 전용 IoT 환경** — 외부 노출 없음, 인증 없음. 모든 클라이언트는 192.168.55.x (디바이스 LAN 세그먼트) 내부에 있다. CORS는 설계상 전체 개방.
|
||
|
|
2. **SQLite 공유 소유권** — 세 개의 독립 프로그램(이 서비스, 레거시 Java app-runner, `dpworldapp` 데이터 처리 바이너리)이 동일한 `~/db/dynamic_data.db`를 읽고 쓴다. 키 수준 소유권 정책(`board_config_key_ownership_policy`)이 주된 안전 메커니즘이다.
|
||
|
|
3. **config-reader 계약 준수** — `device_config`와 `protocol_config`는 디바이스 config-reader 계약과 정확히 일치해야 한다. 이를 통해 레거시 Java 앱이 .56에서 읽고 쓰는 동작이 예기치 않게 깨지지 않도록 보장한다. 자동화된 드리프트 감지가 이를 강제한다.
|
||
|
|
4. **Partial-merge(부분 병합)만 허용** — 전체 행 REPLACE 없음. `DBManager.update_config` (BEGIN IMMEDIATE RMW)가 모든 POST에서 미설정 키를 보존하도록 보장한다.
|
||
|
|
5. **v1.5.x IA 재설계** — 사이드바 중첩 5그룹 네비게이션, Firmware OTA 실제 통합, Lucide SVG 아이콘, Inter/JetBrains Mono 셀프 호스팅 폰트, gzip + ETag/304 성능 최적화, behavioral(jsdom) 테스트 인프라.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 2. 하드웨어 컨텍스트 (Telechips TCC8030)
|
||
|
|
|
||
|
|
| 항목 | 값 |
|
||
|
|
|---|---|
|
||
|
|
| CPU | 700 MHz ARM (싱글코어 주, 멀티코어 가능) |
|
||
|
|
| OS | Yocto/Poky 4.0.17 (`uname -r`: Telechips) |
|
||
|
|
| 펌웨어 업데이트 | A/B 파티션 슬롯 (`slot A` / `slot B`, dpw-fw-update-tool 경유) |
|
||
|
|
| 영속 파티션 | `/home/root/db` (mmcblk0p11) — SQLite DB; `/opt/log` (mmcblk0p12) — dpworldapp 로그 |
|
||
|
|
| 임시 파티션 | `/tmp` — tmpfs, 서비스별 격리 슬라이스 PrivateTmp=yes |
|
||
|
|
| `/etc/systemd/system` | **tmpfs** — 여기에 작성된 유닛 파일은 **재부팅 시 소실됨** |
|
||
|
|
| systemd 유닛 위치 | `/lib/systemd/system/web-configurator.service` (영속, v1.5.2 H17 수정) |
|
||
|
|
| MemoryMax | 48 MB (서비스 유닛에 설정) |
|
||
|
|
| Web Configurator 포트 | **9090** (직접 연결, 앞단 nginx 없음) |
|
||
|
|
| Java app-runner 포트 | **8080** (.56에서 nginx :80 → Java :8080) |
|
||
|
|
| WiFi 칩셋 | Qualcomm QCA6490 (PCI 17CB:1103, 드라이버 cnss_pci) |
|
||
|
|
| 펌웨어 바이너리 | `/lib/firmware/amss.bin` (WiFi FW 버전은 `QC_IMAGE_VERSION_STRING=`으로 추출) |
|
||
|
|
| dpworldapp 바이너리 | `/usr/bin/dpworldapp` — CAN/Modbus/OPC-UA 데이터 처리기 |
|
||
|
|
| GNSS | u-blox GPS (버전은 dpworldapp 로그 `[GNSS FW VER : ...]`에서 확인) |
|
||
|
|
| 앱 로그 경로 | `/opt/log/dpworldapp/dpworldapp_YYYY-MM-DD.log` |
|
||
|
|
|
||
|
|
**tmpfs 인시던트** (2026-05-29): Poky 4.0.17에서 `/etc/systemd/system/`은 tmpfs다. v1.4.0 배포 시 유닛을 `/etc/`에 작성했고, 다음 재부팅 전까지는 유지되었다. v1.5.2 H17에서 deploy.ps1이 `/lib/systemd/system/`(영속)에 쓰도록 수정했다.
|
||
|
|
|
||
|
|
**ReadWritePaths**: systemd 유닛은 `ProtectSystem=strict`를 사용한다. 서비스가 쓰기 접근해야 하는 경로는 `ReadWritePaths`에 명시적으로 나열해야 한다:
|
||
|
|
- `/opt/log/dpworldapp` — 로그 다운로드 임시 부모 경로 (v1.4.6.6에서 `/tmp`로 수정)
|
||
|
|
- `/opt/fw_staging` — 펌웨어 스테이징 디렉토리 (v1.5.2 C1)
|
||
|
|
- `/opt/config_backups` — 플래시 전 설정 백업 (v1.5.2 C1)
|
||
|
|
- `/home/root/db` — SQLite DB 경로
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 3. 기술 스택
|
||
|
|
|
||
|
|
### 백엔드
|
||
|
|
|
||
|
|
| 컴포넌트 | 기술 |
|
||
|
|
|---|---|
|
||
|
|
| HTTP 서버 | Python 3.10+ `http.server.BaseHTTPRequestHandler` + `socketserver.ThreadingMixIn` |
|
||
|
|
| 데이터베이스 | `sqlite3` stdlib |
|
||
|
|
| 압축 | `gzip` stdlib (응답 압축) |
|
||
|
|
| 설정 | `os.environ` + 상수 |
|
||
|
|
| 의존성 | **Python stdlib 전용** (pip 없음, 서드파티 패키지 없음) |
|
||
|
|
| 진입점 | `src/server.py:main()` |
|
||
|
|
|
||
|
|
### 프론트엔드
|
||
|
|
|
||
|
|
| 컴포넌트 | 기술 |
|
||
|
|
|---|---|
|
||
|
|
| JS 모듈 시스템 | 바닐라 ES 모듈 (`type="module"`, 번들러 없음) |
|
||
|
|
| 아이콘 | Lucide v0.460+ SVG 인라인 (`src/static/js/icons.js`, 49개 항목) |
|
||
|
|
| 타이포그래피 | Inter (5 웨이트) + JetBrains Mono (2 웨이트) — `.woff2` 셀프 호스팅 (`src/static/fonts/`) |
|
||
|
|
| 스타일시트 | 단일 `src/static/css/style.css` (~3000줄 이상) |
|
||
|
|
| XSS 이스케이프 | `src/static/js/utils.js`의 `escapeHtml` (5문자 명시 치환: `& < > " '`) |
|
||
|
|
|
||
|
|
### 테스트
|
||
|
|
|
||
|
|
| 컴포넌트 | 기술 |
|
||
|
|
|---|---|
|
||
|
|
| Python 테스트 | `pytest` (v1.5.4.2 기준 644개 테스트) |
|
||
|
|
| JS behavioral 테스트 | Node 18+ `node:test` + `jsdom` (4개 `.mjs` 파일에 35개 테스트) |
|
||
|
|
| 듀얼 러너 | `python -m pytest` + `npm test` |
|
||
|
|
| 디바이스 배포 격리 | `deploy.ps1`이 `git archive HEAD src` 사용 — tests/node_modules/package.json은 배포 대상 아님 |
|
||
|
|
|
||
|
|
### 배포
|
||
|
|
|
||
|
|
- PowerShell `scripts/deploy.ps1` — git archive → scp → tar → atomic mv → systemctl restart
|
||
|
|
- 헬스 체크 + 실패 시 자동 롤백 (v1.5.2 H18)
|
||
|
|
- `/lib/systemd/system/` 유닛 설치 (v1.5.2 H17 영속화)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 4. 프로세스 모델
|
||
|
|
|
||
|
|
```
|
||
|
|
systemd web-configurator.service
|
||
|
|
└── Python process (server.py main())
|
||
|
|
├── ThreadedHTTPServer (ThreadingMixIn + HTTPServer)
|
||
|
|
│ └── ConfigHandler per thread (BaseHTTPRequestHandler)
|
||
|
|
├── Log auto-compress daemon thread (5-min interval)
|
||
|
|
└── _recover_staging background daemon thread (firmware, v1.5.2 M10/M15)
|
||
|
|
```
|
||
|
|
|
||
|
|
- `socket.setdefaulttimeout(30.0)` — 글로벌 slowloris 방어 (v1.5.1 H4).
|
||
|
|
비활성 상태 30초 후 모든 연결이 타임아웃된다.
|
||
|
|
- `/api/firmware/upload` 라우트는 대용량 펌웨어 파일 업로드 완료를 허용하기 위해 연결별로 `connection.timeout = None`으로 재설정한다 (v1.5.2 H20).
|
||
|
|
- `ThreadedHTTPServer.timeout = 30.0` — 서버 수준 accept 타임아웃.
|
||
|
|
- 서버 프로세스당 `DBManager` 인스턴스 하나 (`server.py:89`의 모듈 수준 `db = DBManager()`). 각 public 메서드는 SQLite 연결을 건드리기 전에 `self._lock` (threading.Lock)을 획득한다.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 5. 데이터 레이어
|
||
|
|
|
||
|
|
### 데이터베이스 파일
|
||
|
|
|
||
|
|
```
|
||
|
|
/home/root/db/dynamic_data.db (mmcblk0p11, 영속 파티션)
|
||
|
|
```
|
||
|
|
|
||
|
|
### 테이블
|
||
|
|
|
||
|
|
| 테이블 | 소유자 | 목적 |
|
||
|
|
|---|---|---|
|
||
|
|
| `board_config` | 공유 (Python + Java + dpworldapp + Super_Relay) | 키-값 설정 저장소 |
|
||
|
|
| `event_history` | dpworldapp | 이벤트 큐 — Web Configurator는 쓰기 안 함 |
|
||
|
|
| `schema_meta` | Python Web Configurator | 마이그레이션 플래그 (멱등 시작) |
|
||
|
|
| `sqlite_sequence` | SQLite 내부 | AUTOINCREMENT 추적 |
|
||
|
|
| `board_config_audit` | Python Web Configurator | 설정 변경 감사 로그 |
|
||
|
|
|
||
|
|
### board_config 키 — 소유권
|
||
|
|
|
||
|
|
| 키 | 소유자 | Java 접근 여부 | dpworldapp 읽기 여부 | 비고 |
|
||
|
|
|---|---|---|---|---|
|
||
|
|
| `device_config` | Python + Java **공유** | 예, 전체 행 REPLACE | 예 | device config-reader 계약 적용 — 37개 필드 |
|
||
|
|
| `protocol_config` | Python 주 | 예 (.56 구버전) | 예 | device register-mapping 계약 — ~21개 필드 |
|
||
|
|
| `log_config` | Python 전용 | 아니오 | 아니오 | Web Configurator 로그 관리 — v1.0.2 |
|
||
|
|
| `security_config` | Super_Relay 프로젝트 | 아니오 | 아니오 | 외부 프로젝트 — 건드리지 말 것 |
|
||
|
|
| `transport_config` | Super_Relay 프로젝트 | 아니오 | 아니오 | 외부 프로젝트 — 건드리지 말 것 |
|
||
|
|
|
||
|
|
**핵심 원칙**: Python 전용 설정은 반드시 별도 키에 넣어야 한다(`device_config`/`protocol_config` 금지). Java의 전체 행 REPLACE가 모든 Java POST 시 Python 전용 필드를 조용히 삭제하기 때문이다.
|
||
|
|
|
||
|
|
### DBManager 백엔드 (db_manager.py)
|
||
|
|
|
||
|
|
우선순위 체인:
|
||
|
|
1. `python` — `sqlite3` stdlib 모듈 (권장; 운영 환경에서 사용)
|
||
|
|
2. `cli` — subprocess 경유 `sqlite3` CLI (import 실패 시 폴백)
|
||
|
|
3. `json` — JSON 파일 폴백 (테스트/긴급 용도만; dpworldapp 호환 없음)
|
||
|
|
|
||
|
|
**Atomic RMW** (`db_manager.py:226` `update_config`):
|
||
|
|
- `python` 백엔드: 단일 연결 내에서 `BEGIN IMMEDIATE` + SELECT + `INSERT OR REPLACE` + COMMIT 수행. 동시 쓰기 차단. 지수 백오프로 잠금 재시도 (3회, 100 ms 기본).
|
||
|
|
- `cli` + `json` 백엔드: `self._lock`으로 직렬화.
|
||
|
|
- 결과: N개 동시 스레드 간 업데이트 유실 없음 (10스레드 + 20스레드 동시 증분 테스트 통과 확인 — `tests/test_v1_5_3_multi_instance.py`).
|
||
|
|
|
||
|
|
### 마이그레이션 (src/migrations.py)
|
||
|
|
|
||
|
|
시작 시 `apply_all_migrations(db)` (`server.py:728`)를 통해 적용됨.
|
||
|
|
Fail-soft: `(OperationalError, DatabaseError, JSONDecodeError, ValueError)` 예외를 잡아서 처리.
|
||
|
|
|
||
|
|
| 마이그레이션 | schema_meta 키 | 수행 내용 |
|
||
|
|
|---|---|---|
|
||
|
|
| `migrate_can_baudrate_units` | `can_baudrate_units_v2` | 구 baudrate 값 2500/5000/10000 → 250/500/1000으로 재매핑 |
|
||
|
|
| `migrate_log_compress_split` | `log_compress_split_v1` | `log_compress_size_mb`/`log_compress_age_days`를 `device_config` → `log_config`로 이동 |
|
||
|
|
| `migrate_port_types` | `port_types_int_v1` | 문자열 타입 서버 포트를 Integer로 정규화 |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 6. 요청 라우팅 (server.py)
|
||
|
|
|
||
|
|
### do_GET 라우트
|
||
|
|
|
||
|
|
| 경로 | 핸들러 | 설명 |
|
||
|
|
|---|---|---|
|
||
|
|
| `/setting/get-device` | `_handle_get_device` | `device_config`와 `log_config` 병합 반환 (log 필드 삽입) |
|
||
|
|
| `/setting/get-protocol` | `_handle_get_protocol` | `protocol_config` 반환 |
|
||
|
|
| `/setting/log-files` | `_handle_get_log_files` | dpworldapp 로그 파일 목록 |
|
||
|
|
| `/setting/kernel-bundle` | `_handle_get_kernel_bundle` | journalctl + 커널 로그 `.tar.gz` (v1.1.0) |
|
||
|
|
| `/setting/log-stats` | `_handle_get_log_stats` | 로그 디스크 사용량 통계 |
|
||
|
|
| `/api/mac` | `_handle_get_mac` | WiFi MAC 주소 (디바이스 식별) |
|
||
|
|
| `/api/health` | `_handle_health` | 서버 헬스 (uptime, DB 백엔드) |
|
||
|
|
| `/api/system-status` | `_handle_system_status` | 홈 대시보드: core_app / communication / network / system / hardware_modules / dpworldapp_status |
|
||
|
|
| `/api/support-bundle` | `_handle_support_bundle` | 진단 ZIP 다운로드 |
|
||
|
|
| `/api/firmware/status` | `_FW.status()` | Firmware OTA 상태 스냅샷 (v1.5.0 P4a) |
|
||
|
|
| 정적 파일 | `_serve_static_file` | Realpath 경로 순회 방어 + gzip + ETag + Cache-Control |
|
||
|
|
|
||
|
|
### do_POST 라우트
|
||
|
|
|
||
|
|
| 경로 | 핸들러 | 설명 |
|
||
|
|
|---|---|---|
|
||
|
|
| `/setting/device` | `_handle_post_device` | device config partial-merge (atomic RMW) |
|
||
|
|
| `/setting/protocol` | `_handle_post_protocol` | protocol config partial-merge + `process_protocol_config` |
|
||
|
|
| `/setting/log-download` | `_handle_post_log_download` | 선택한 로그 파일 → `.tar.gz` |
|
||
|
|
| `/api/test-connections` | `_handle_test_connections` | 설정된 서버 엔드포인트 TCP 프로브 |
|
||
|
|
| `/api/restart-dpworldapp` | `_handle_restart_dpworldapp` | `systemctl restart dpworldapp` |
|
||
|
|
| `/api/firmware/upload` | `_FW.upload()` | 펌웨어 ZIP을 스테이징 디렉토리로 스트리밍 |
|
||
|
|
| `/api/firmware/preflight` | `_FW.preflight()` | 플래시 전 사전 점검 |
|
||
|
|
| `/api/firmware/flash` | `_FW.flash()` | 8단계 OTA 플래시 실행 |
|
||
|
|
| `/api/firmware/restore-check` | `_FW.restore_check()` | 재부팅 후 설정 무결성 검사 |
|
||
|
|
|
||
|
|
### 라우팅 가드
|
||
|
|
|
||
|
|
- `/setting/` 접두사: 알 수 없는 경로 → 404 (SPA로 폴스루 없음)
|
||
|
|
- `/api/` 접두사: 알 수 없는 경로 → 404 (v1.5.0.1 수정, `server.py` do_GET)
|
||
|
|
- 정적 파일: `os.realpath()` + 접두사 검사 (경로 순회 차단, `server.py:237-239`)
|
||
|
|
- SPA 폴백: 나머지 모든 GET 경로에 `index.html` 반환
|
||
|
|
|
||
|
|
### 정적 파일 서빙 (v1.5.4)
|
||
|
|
|
||
|
|
```python
|
||
|
|
# server.py:224 _serve_static_file()
|
||
|
|
|
||
|
|
ETag = W/"mtime-size" # weak ETag (저비용, 해시 없음)
|
||
|
|
If-None-Match == ETag → 304 # 0바이트 body
|
||
|
|
|
||
|
|
Cache-Control:
|
||
|
|
text/html → no-cache, must-revalidate
|
||
|
|
JS/CSS → no-cache, must-revalidate (v1.5.4.2 H1+M2: 즉시 배포 전파)
|
||
|
|
font/image → public, max-age=86400
|
||
|
|
|
||
|
|
gzip:
|
||
|
|
Accept-Encoding q값 파싱 (v1.5.4.2 L1)
|
||
|
|
압축 대상: text/*, application/javascript, application/json, image/svg+xml
|
||
|
|
제외: .woff2, 이미지, 256바이트 미만
|
||
|
|
레벨: 6, Vary: Accept-Encoding 헤더 추가
|
||
|
|
```
|
||
|
|
|
||
|
|
Body 크기 제한: `MAX_BODY_SIZE = 1 MB` (펌웨어 업로드는 예외 — 소켓 타임아웃 None).
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 7. 프론트엔드 아키텍처 (v1.5.0 IA 재설계)
|
||
|
|
|
||
|
|
### 진입점
|
||
|
|
|
||
|
|
`src/static/index.html` — lang="en", title="IoT Web Configurator", 9개 `modulepreload` 힌트
|
||
|
|
(app / state / api / utils / icons / constants / page-dirty / nav-guard / home).
|
||
|
|
|
||
|
|
### 사이드바 레이아웃 (5그룹, 11개 활성 리프)
|
||
|
|
|
||
|
|
```
|
||
|
|
sidebar (aside[role=navigation])
|
||
|
|
├── Dashboard
|
||
|
|
│ └── home
|
||
|
|
├── Network
|
||
|
|
│ ├── wifi (Wi-Fi SSID + 연결 + 지역)
|
||
|
|
│ ├── ethernet (이더넷 IP + LTE 인터페이스)
|
||
|
|
│ └── server-setting (TIOT/Update/RTCM/LTE 서버 엔드포인트)
|
||
|
|
├── Interface & Protocol
|
||
|
|
│ ├── general-settings (Equipment + Protocol 선택 + Odometer)
|
||
|
|
│ ├── sensor-io (RS485 + Analog/Digital 포트)
|
||
|
|
│ ├── can-bus (CAN 설정 + 프레임 매핑)
|
||
|
|
│ ├── opcua (OPC UA 엔드포인트 + 필드 매핑)
|
||
|
|
│ └── modbus (Modbus 엔드포인트 + 바이트 순서 + 필드 매핑)
|
||
|
|
├── Log
|
||
|
|
│ └── log (3탭: Configuration / Files / Kernel)
|
||
|
|
└── Firmware
|
||
|
|
└── firmware (8단계 OTA 스테퍼)
|
||
|
|
```
|
||
|
|
|
||
|
|
그룹 펼침 상태는 `localStorage("wc.sidebar.groups")`에 유지된다.
|
||
|
|
|
||
|
|
`app.js PAGES`에 레거시 별칭 보존 (구 북마크 하위 호환):
|
||
|
|
- `ssid` → `wifi`
|
||
|
|
- `io` → `ethernet`
|
||
|
|
- `network` → `server-setting`
|
||
|
|
- `register` → `general` (= `general-settings` 페이지)
|
||
|
|
- `can` → `can-bus`
|
||
|
|
|
||
|
|
### 페이지 객체 패턴
|
||
|
|
|
||
|
|
모든 페이지 모듈은 기본 페이지 객체를 export한다:
|
||
|
|
|
||
|
|
```js
|
||
|
|
export default {
|
||
|
|
render(container) { ... }, // HTML을 container에 주입
|
||
|
|
mount(container) { ... }, // 이벤트 리스너 연결, collector 등록
|
||
|
|
destroy() { ... }, // 리스너 정리 (선택)
|
||
|
|
validate() { ... }, // 클라이언트 측 유효성 검사 (선택)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
구현 대상: `app.js`, `state.js`, 11개 리프 페이지 전체.
|
||
|
|
|
||
|
|
### 상태 관리 (state.js)
|
||
|
|
|
||
|
|
- `state.device` — 평탄화된 device config 객체 (JS 인메모리)
|
||
|
|
- `state.protocol` — 평탄화된 protocol config 객체
|
||
|
|
- `state.pageDirty` — 페이지별 dirty 매트릭스 (10개 항목):
|
||
|
|
`{home, wifi, ethernet, server-setting, general, sensor-io, can-bus, opcua, modbus, log, firmware: false}`
|
||
|
|
- `state.isDirty` — 불리언 하위 호환 별칭 (모든 pageDirty 값의 OR)
|
||
|
|
- `convertFlatToNested(flat)` / `convertNestedToFlat(nested)` — 레거시 앱의 평탄화 표현과 일치하는 중첩 ↔ 평탄 키 변환
|
||
|
|
|
||
|
|
### Page Dirty + Nav Guard
|
||
|
|
|
||
|
|
`src/static/js/page-dirty.js`:
|
||
|
|
- `markDirty(pageId)` / `clearDirty(pageId)` / `hasDirty()` / `getDirtyPages()` / `clearAllDirty()`
|
||
|
|
- 사이드바 점 (`.nav-item__dirty`)은 모든 호출 시 자동 동기화.
|
||
|
|
|
||
|
|
`src/static/js/nav-guard.js`:
|
||
|
|
- `confirmNavigation(currentPageId)` → Promise 모달 (Save / Discard / Cancel)
|
||
|
|
- 포커스 트랩 (모달 내부 Tab/Shift+Tab 순환) — v1.5.2 H12 + H13
|
||
|
|
- Esc 키 = Cancel
|
||
|
|
- 닫을 때 트리거 포커스 복원 (WCAG 2.4.3) — v1.5.2 H13
|
||
|
|
|
||
|
|
### 아이콘 (icons.js)
|
||
|
|
|
||
|
|
- 49개 Lucide v0.460+ SVG body 문자열 (ISC 라이선스)
|
||
|
|
- `icon(name, opts)` 헬퍼: `opts.cls` (클래스, `<>"'` 제거), `opts.aria` (aria-label, `escapeAttr`)
|
||
|
|
- `escapeAttr`는 모듈 수준에서 정의 (성능 최적화, 호출별 생성 아님)
|
||
|
|
- 모든 아이콘에 `role="img"` + `<title>` 적용 (WCAG 4.1.2)
|
||
|
|
- 장식용 아이콘에는 `aria-hidden="true"` 적용
|
||
|
|
|
||
|
|
### 보안 (프론트엔드)
|
||
|
|
|
||
|
|
- `utils.js`의 `escapeHtml`: `& → &` / `< → <` / `> → >` / `" → "` / `' → '`
|
||
|
|
— 5문자 명시 치환 (v1.5.2 H1 루트 XSS 수정)
|
||
|
|
- 운영자 입력값(레지스터 테이블, odometer 필드, import 데이터) 모두 DOM 삽입 전 `escapeHtml` 통과 — v1.4.6.2 F1 + C5 + C6
|
||
|
|
- icons.js `icon()`의 cls/aria: `escapeAttr`로 `<>"'` 제거
|
||
|
|
- nav-guard.js `_escape`는 `escapeHtml`을 미러링
|
||
|
|
- DEBUG console.log: `state.device` 내용 마스킹 (값 대신 키만 표시) — v1.4.6.9 M6
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 8. Firmware OTA (v1.5.0 Phase 4a + v1.5.2 하드닝)
|
||
|
|
|
||
|
|
### 소스
|
||
|
|
|
||
|
|
`C:\Users\F1304\Downloads\dpw-fw-update-tool\webconfig_fw\`에서 `MERGE.md` + `DESIGN.md` (2026-06-06)에 따라 병합.
|
||
|
|
|
||
|
|
### 백엔드 패키지 (`src/firmware/`)
|
||
|
|
|
||
|
|
| 모듈 | 라인 수 | 역할 |
|
||
|
|
|---|---|---|
|
||
|
|
| `fw_client.py` | ~110 | dpw-fw-update-tool 데몬용 Wire 클라이언트 (host:port) |
|
||
|
|
| `staging.py` | ~47 | ZIP 스테이징: 압축 해제, 유효성 검사, 슬롯 감지 |
|
||
|
|
| `config_safety.py` | ~172 | 플래시 전 설정 백업, 재부팅 후 복원 검증 |
|
||
|
|
| `protocol.py` | ~64 | Wire 프로토콜 저수준 프레이밍 |
|
||
|
|
| `fw_controller.py` | ~480 | 상태 기반 OTA 오케스트레이션, 상태 스키마, 컴포넌트별 진행 바, 재부팅 watchdog |
|
||
|
|
| `fw_routes.py` | ~110 | 전송 계층 독립적 HTTP 라우트 핸들러, `RouteError` 예외 타입 |
|
||
|
|
|
||
|
|
`server.py:94`에서 초기화:
|
||
|
|
```python
|
||
|
|
_FW = FirmwareRoutes(FirmwareController(
|
||
|
|
fw_host=os.environ.get("FW_HOST", "127.0.0.1"),
|
||
|
|
fw_port=int(os.environ.get("FW_PORT", "8990")),
|
||
|
|
staging_dir=os.environ.get("FW_STAGING_DIR", "/opt/fw_staging"),
|
||
|
|
backups_dir=os.environ.get("FW_BACKUPS_DIR", "/opt/config_backups"),
|
||
|
|
db_path=DB_PATH,
|
||
|
|
))
|
||
|
|
```
|
||
|
|
|
||
|
|
### 8단계 스테퍼
|
||
|
|
|
||
|
|
```
|
||
|
|
Upload → Verify → Pre-flight → Backup → Flash → Commit → Reboot → Config check
|
||
|
|
```
|
||
|
|
|
||
|
|
### 상태 스키마 (UI의 단일 진실 공급원)
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"state": "idle|staged|flashing|rebooting|verifying|done|failed",
|
||
|
|
"phases": [{ "key", "label", "state": "pending|active|done|error", "tier", "detail" }],
|
||
|
|
"components": [{ "role", "signature", "name", "size", "sha256",
|
||
|
|
"sent", "pct", "state": "pending|active|done|error" }],
|
||
|
|
"overall": { "sent", "total", "pct", "elapsed_s" },
|
||
|
|
"active_signature": "RTF|null",
|
||
|
|
"backup": { "path", "when" } ,
|
||
|
|
"slot": { "before": "A|B|?", "after": "A|B|?|null" },
|
||
|
|
"message": "human line",
|
||
|
|
"error": null
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### v1.5.2 Firmware 하드닝
|
||
|
|
|
||
|
|
| 항목 | 수정 내용 |
|
||
|
|
|---|---|
|
||
|
|
| C1 ProtectSystem | 서비스 유닛 + deploy.ps1에 `/opt/fw_staging` + `/opt/config_backups` → `ReadWritePaths` 추가, 디렉토리 사전 생성 |
|
||
|
|
| H4 stage_zip TOCTOU | `fw_controller.stage_zip` — `self._lock` 하에서 'staging' 상태 검사 |
|
||
|
|
| H19 default_file | `FirmwareController` default_file 오버라이드를 `server.py`에서 제거 |
|
||
|
|
| H20 upload timeout | `/api/firmware/upload` 소켓 타임아웃을 연결별로 `None`으로 재설정 |
|
||
|
|
| M10/M15 recover | `_recover_staging`을 백그라운드 데몬 스레드로 실행 (시작 비차단) |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 9. 보안 모델
|
||
|
|
|
||
|
|
보안 모델은 레거시 Java 앱 기준선에 맞춰 인증 없는 LAN 전용으로 명시적으로 설계되었다.
|
||
|
|
|
||
|
|
| 제어 항목 | 구현 방식 |
|
||
|
|
|---|---|
|
||
|
|
| 인증 없음 | 의도적 설계 (LAN 전용 IoT, Java 앱과 동일) |
|
||
|
|
| CORS | `Access-Control-Allow-Origin: *` (전체 origin 허용, 의도적) |
|
||
|
|
| HTTP만 사용 | TLS 미적용 (LAN 환경) |
|
||
|
|
| XSS — 출력 | `utils.js`의 `escapeHtml` 5문자 치환 (v1.5.2 H1 루트 수정) |
|
||
|
|
| XSS — 속성 | `icons.js`의 `escapeAttr`, 모든 숫자 속성에 숫자 강제 변환 `Number()` (v1.4.6.9 C5/C6) |
|
||
|
|
| 경로 순회 | `os.realpath()` + `STATIC_DIR` 접두사 검사 (`server.py:237-239`) |
|
||
|
|
| SQL 인젝션 | `DBManager.ALLOWED_KEYS` 화이트리스트 (`db_manager.py:42`) — CLI 백엔드는 키를 파라미터화 |
|
||
|
|
| Body 크기 | `MAX_BODY_SIZE = 1 MB` (`server.py:161`) |
|
||
|
|
| Slowloris | `socket.setdefaulttimeout(30.0)` (`server.py`, v1.5.1) |
|
||
|
|
| enum 인젝션 | 유효하지 않은 enum 값 시 HTTP 400 하드 거부 (`config_validator.py`) |
|
||
|
|
| Super_Relay 키 | POST에 `security_config`/`transport_config` 포함 시 HTTP 400 거부 (`v1.4.4`) |
|
||
|
|
| systemd 하드닝 | `NoNewPrivileges`, `ProtectSystem=strict`, `ProtectHome=read-only`, `PrivateTmp`, `ReadWritePaths` 화이트리스트 |
|
||
|
|
| 지원 번들 | WiFi 비밀번호는 포함 전 마스킹 처리 |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 10. 성능 (v1.5.4 + v1.5.4.2)
|
||
|
|
|
||
|
|
### 정적 파일 전달 (LAN, .54 측정값)
|
||
|
|
|
||
|
|
| 지표 | 값 |
|
||
|
|
|---|---|
|
||
|
|
| 최초 로드 전송량 | ~575 KB → ~200 KB (gzip으로 ~65% 감소) |
|
||
|
|
| 반복 방문 | ETag/304, 0바이트 body — 거의 즉각 렌더링 |
|
||
|
|
| 배포 전파 | 즉시 (JS/CSS no-cache, v1.5.4.2 H1+M2 수정) |
|
||
|
|
| 정적 파일 레이턴시 | 20–32 ms (LAN 왕복) |
|
||
|
|
| system-status 콜드 | ~6.09 s (journalctl + dpworldapp 로그 스캔) |
|
||
|
|
| system-status 웜 | ~1.15 s (모듈 캐시) |
|
||
|
|
|
||
|
|
### 적용된 기법
|
||
|
|
|
||
|
|
- **gzip**: 레벨 6, text/JS/CSS/JSON/SVG, `≥256B`, q값 인식 Accept-Encoding (v1.5.4.2 L1)
|
||
|
|
- **ETag**: `W/"mtime-size"` 약한 ETag, 파일별, `If-None-Match` → 304
|
||
|
|
- **Cache-Control**: HTML/JS/CSS `no-cache, must-revalidate`; 폰트/이미지 `max-age=86400`
|
||
|
|
- **modulepreload**: 9개 핵심 모듈 (`index.html` 링크 힌트) — app.js 파싱 전 병렬 fetch
|
||
|
|
- **셀프 호스팅 폰트**: 7개 `.woff2` 파일 (Inter 5웨이트 + JetBrains Mono 2웨이트, latin 서브셋, 총 ~200 KB)
|
||
|
|
— 외부 CDN 의존 없음 (LAN 전용 IoT에서 필수, v1.5.1.1)
|
||
|
|
- **amss.bin에 mmap 사용**: 전체 읽기 대신 `mmap.find()` — 피크 RSS 1 MB 미만 유지 (v1.4.0)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 11. 동시성 모델
|
||
|
|
|
||
|
|
```
|
||
|
|
Thread A (request) Thread B (request) Daemon (auto-compress)
|
||
|
|
│ │ │
|
||
|
|
_lock.acquire() blocks _lock.acquire() (after A)
|
||
|
|
BEGIN IMMEDIATE ──────── SQLite WAL ────────── (reads log_config)
|
||
|
|
SELECT + mutator()
|
||
|
|
INSERT OR REPLACE
|
||
|
|
COMMIT
|
||
|
|
_lock.release() ─────── unblocks B
|
||
|
|
```
|
||
|
|
|
||
|
|
- `DBManager._lock`은 `threading.Lock` — 하나의 Python 객체, 프로세스 로컬.
|
||
|
|
- SQLite 측의 `BEGIN IMMEDIATE`는 DB 레벨에서 동시 쓰기를 차단한다
|
||
|
|
(WAL 모드는 동시 읽기 허용).
|
||
|
|
- `fw_controller` 상태 머신은 별도 인스턴스의 `self._lock`으로 보호.
|
||
|
|
- Nav-guard 이중 모달 레이스: 빠른 클릭 시 시각적 문제만 발생 (기록됨, 허용 수준).
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 12. 테스트 인프라 (v1.5.3 듀얼 러너)
|
||
|
|
|
||
|
|
### Python pytest (644개 테스트)
|
||
|
|
|
||
|
|
`tests/`에 버전/기능별로 구성:
|
||
|
|
|
||
|
|
| 패턴 | 설명 |
|
||
|
|
|---|---|
|
||
|
|
| Grep 테스트 | 코드 패턴 존재/부재 검증 (대다수 테스트) |
|
||
|
|
| Behavioral 테스트 | server.py 통합 (FakeDB), config_validator 하드 거부 |
|
||
|
|
| Multi-instance | `test_v1_5_3_multi_instance.py` — DBManager BEGIN IMMEDIATE 레이스 (10+20 스레드) |
|
||
|
|
| Schema drift | `test_schema_drift.py` — `scripts/audit_schema_drift.py` 대 `tests/fixtures/java_schema_expected.json` |
|
||
|
|
|
||
|
|
실행: `python -m pytest tests/ -v`
|
||
|
|
|
||
|
|
### Node 18+ jsdom (35개 테스트, 4개 파일)
|
||
|
|
|
||
|
|
| 파일 | 테스트 수 | 검증 내용 |
|
||
|
|
|---|---|---|
|
||
|
|
| `tests/test_nav_guard_behavior.mjs` | 9 | 포커스 트랩 Tab/Shift+Tab, Esc, Save/Discard/Cancel, pageId XSS |
|
||
|
|
| `tests/test_icons_svg.mjs` | 10 | SVG 정합성, viewBox/width/height, aria role=img + title, XSS 저항성 |
|
||
|
|
| `tests/test_log_tabs_switching.mjs` | 4 | 탭 클릭 → panel.hidden 토글, aria-selected, 기본 상태 |
|
||
|
|
| `tests/test_save_all_modal.mjs` | 9 | 모달 role/aria, dirty 페이지 목록, Cancel/Confirm promise, escapeHtml XSS |
|
||
|
|
| `tests/test_firmware_logic.mjs` | 3 | (기존) firmware-logic.js 헬퍼 |
|
||
|
|
|
||
|
|
실행: `npm test` (Node 18+ 필요, 최초 1회 `npm install`)
|
||
|
|
|
||
|
|
**디바이스 배포 격리**: `deploy.ps1`이 `git archive HEAD src` 사용 — `src/`만 배포됨. `tests/`, `node_modules/`, `package.json`, `package-lock.json`은 디바이스로 전송되지 않는다.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 13. 배포 파이프라인 (scripts/deploy.ps1)
|
||
|
|
|
||
|
|
### 단계
|
||
|
|
|
||
|
|
```
|
||
|
|
1. git archive HEAD src → 로컬 tar
|
||
|
|
2. SSH: mkdir -p $AppDir /opt/fw_staging /opt/config_backups
|
||
|
|
3. scp tar → 디바이스
|
||
|
|
4. SSH: tar --warning=no-timestamp -xf tar → _deploy_tmp/
|
||
|
|
5. SSH: 기존 src/ 백업 → backups/src-$ts (최근 3개 유지)
|
||
|
|
6. SSH: atomic mv _deploy_tmp/ → src/
|
||
|
|
7. SSH: /lib/systemd/system/web-configurator.service 설치 (영속)
|
||
|
|
8. SSH: systemctl daemon-reload + enable + restart web-configurator
|
||
|
|
9. Confirm-Health: /api/health 최대 30초 폴링
|
||
|
|
10. 실패 시 자동 롤백: backups/src-$ts 복원 + restart
|
||
|
|
```
|
||
|
|
|
||
|
|
### 주요 플래그
|
||
|
|
|
||
|
|
| 플래그 | 효과 |
|
||
|
|
|---|---|
|
||
|
|
| (기본값) | 클린 git 워킹 트리 + 버전 태그 필요 |
|
||
|
|
| `-AllowUntagged` | 태그 요건 생략 (dev/test 배포) |
|
||
|
|
| `-Rollback` | 가장 최근 backup/src-* 복원 |
|
||
|
|
|
||
|
|
### 배포 후 디바이스 상태
|
||
|
|
|
||
|
|
- `$AppDir` 내 `DEPLOYED_VERSION` 파일 (버전 문자열)
|
||
|
|
- `$AppDir` 내 `deploy-history.log` (배포마다 타임스탬프 + 버전)
|
||
|
|
- `/lib/systemd/system/web-configurator.service`의 systemd 유닛
|
||
|
|
|
||
|
|
### 버전 확인 (배포 후)
|
||
|
|
|
||
|
|
```powershell
|
||
|
|
ssh root@192.168.55.54 "curl -s http://localhost:9090/api/health"
|
||
|
|
# → {"status":"ok","version":"v1.5.4.2","uptime_s":...}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 14. 운영 리뷰 커버리지
|
||
|
|
|
||
|
|
### v1.5.2 멀티에이전트 리뷰 (wf_f4786747-995)
|
||
|
|
|
||
|
|
- 12개 차원, 318개 에이전트, 약 102건 제기 → **41건 확정**
|
||
|
|
- 수정: Critical 1 + High 20 + Medium 7 = 28개 항목
|
||
|
|
- 이연: High 5 (behavioral, jsdom) → v1.5.3
|
||
|
|
- 방법: 발견 사항별 3-lens 적대 검증 (정확성 / 보안 / 재현성)
|
||
|
|
— 다수 반박 (2/3) = false positive, 폐기
|
||
|
|
|
||
|
|
### v1.5.4.2 집중 리뷰 (wf_a4efa2e3-2cb)
|
||
|
|
|
||
|
|
- 6개 차원, 21건 제기 → **5건 확정**
|
||
|
|
- 수정: H1+M2 Cache-Control JS/CSS, M1 sensor-io placeholder, L1 gzip q-value, L2 focus-visible
|
||
|
|
- 잔여: Medium 9 + Low 4 (사용자 체감 영향 0)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 15. v1.5.x 릴리즈 타임라인
|
||
|
|
|
||
|
|
| 태그 | 커밋 | 핵심 변경 | 테스트 수 |
|
||
|
|
|---|---|---|---|
|
||
|
|
| v1.5.0-phase-1 | `c6ec698` | 인프라: 사이드바 5그룹 + icons.js + page-dirty + nav-guard + Firmware placeholder | 481 |
|
||
|
|
| v1.5.0-phase-2 | `1effda8` | Network 3-리프: wifi + ethernet + server-setting | 500 |
|
||
|
|
| v1.5.0-phase-3 | `6bdc2a7` | Interface & Protocol 5-리프: general + sensor-io + can-bus + opcua + modbus | 523 |
|
||
|
|
| v1.5.0-phase-4a | (sha) | Firmware OTA 실제 병합 (webconfig_fw → src/firmware/ + 5개 라우트 + firmware.js 902줄) | 557 |
|
||
|
|
| v1.5.0 | `b3776a1` | Phase 4b: 이모지 → Lucide SVG ~80개 인스턴스 + log 3탭 + Save All 확인 모달 | 562 |
|
||
|
|
| v1.5.0.1 | `2b74ad2` | 핫픽스: deploy.ps1 최초 배포 mkdir + /api/ 404 가드 | 564 |
|
||
|
|
| v1.5.1 | `1c3ffc2` | DBManager.update_config BEGIN IMMEDIATE RMW + slowloris 30초 타임아웃 | 569 |
|
||
|
|
| v1.5.1.1 | `73e188d` | Inter + JetBrains Mono .woff2 7개 폰트 셀프 호스팅 | 572 |
|
||
|
|
| v1.5.2 | `6b7047d` | 운영 리뷰 번들: 41건 확정 수정 (C1 ProtectSystem + H1 XSS + H17 systemd + H18 rollback + ...) | 599 |
|
||
|
|
| v1.5.3 | `3b04e36` | Behavioral 테스트 인프라: jsdom + node:test, 4개 .mjs 파일, 35개 Node 테스트 | Python 615 + Node 35 |
|
||
|
|
| v1.5.3.1 | (sha) | 핫픽스: analog_input_level enum 5V/10V/20mA → 2/4/6 (Java 회귀) + sensor-io UI 카드 | 621 |
|
||
|
|
| v1.5.3.2 | (sha) | 핫픽스: modbus.js two_byte_order 누락 (Java TwoByte 회귀) | 625 |
|
||
|
|
| v1.5.4 | (sha) | 성능: gzip + ETag/304 + Cache-Control + modulepreload (13개 테스트) | 638 |
|
||
|
|
| v1.5.4.1 | (sha) | 핫픽스: 잔여 이모지 → SVG (1개 테스트) | 639 |
|
||
|
|
| v1.5.4.2 | `fe5cd25` | 핫픽스 집중 리뷰: Cache-Control no-cache JS/CSS + sensor-io placeholder + gzip q-value + focus-visible (5개 테스트) | 644 |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 16. 컴포넌트 다이어그램 (ASCII)
|
||
|
|
|
||
|
|
### 최상위 시스템 구성
|
||
|
|
|
||
|
|
```
|
||
|
|
Operator Browser (LAN)
|
||
|
|
│
|
||
|
|
├──:9090 (direct)──► Python web-configurator.service
|
||
|
|
│ │
|
||
|
|
│ server.py (ThreadedHTTPServer)
|
||
|
|
│ │
|
||
|
|
│ ┌─────┼──────────────────────────────┐
|
||
|
|
│ │ │ │
|
||
|
|
│ DBManager firmware/ system_status.py
|
||
|
|
│ (sqlite3) fw_controller.py kernel_log.py
|
||
|
|
│ │ │ log_manager.py
|
||
|
|
│ │ /opt/fw_staging support_bundle.py
|
||
|
|
│ │ /opt/config_backups config_validator.py
|
||
|
|
│ │ dpworldapp_enums.py
|
||
|
|
│ │ enum_normalizer.py
|
||
|
|
│ ~/db/dynamic_data.db migrations.py
|
||
|
|
│ │
|
||
|
|
│ ┌────────┼────────────┐
|
||
|
|
│ board_config schema_meta event_history
|
||
|
|
│ │
|
||
|
|
│ ┌──────┼──────────┐
|
||
|
|
│ device_ protocol_ log_
|
||
|
|
│ config config config
|
||
|
|
│
|
||
|
|
├──:80──► nginx ──:8080──► Java app-runner (legacy, .56 only)
|
||
|
|
│ │
|
||
|
|
│ ~/db/dynamic_data.db (same file!)
|
||
|
|
│
|
||
|
|
└── (no browser)──► dpworldapp (/usr/bin/dpworldapp)
|
||
|
|
│
|
||
|
|
~/db/dynamic_data.db (reads device_config + protocol_config)
|
||
|
|
/opt/log/dpworldapp/*.log (writes)
|
||
|
|
```
|
||
|
|
|
||
|
|
### 프론트엔드 모듈 그래프
|
||
|
|
|
||
|
|
```
|
||
|
|
index.html
|
||
|
|
└── app.js (type=module, entry point)
|
||
|
|
├── state.js (device/protocol 상태 + flat↔nested)
|
||
|
|
├── api.js (모든 REST 엔드포인트 fetch 래퍼)
|
||
|
|
├── utils.js (escapeHtml, debounce)
|
||
|
|
├── icons.js (49개 Lucide SVG + icon() 헬퍼)
|
||
|
|
├── constants.js (APP_VERSION, DEFAULTS, enum 집합)
|
||
|
|
├── page-dirty.js (markDirty/clearDirty/사이드바 점)
|
||
|
|
├── nav-guard.js (confirmNavigation 모달 + 포커스 트랩)
|
||
|
|
├── toast.js (토스트 알림)
|
||
|
|
├── validator.js (클라이언트 측 필드 유효성 검사)
|
||
|
|
├── components/
|
||
|
|
│ ├── crud-table.js (레지스터 필드 매핑 테이블)
|
||
|
|
│ └── ip-input.js (IP 주소 입력 위젯)
|
||
|
|
└── pages/
|
||
|
|
├── home.js (Dashboard)
|
||
|
|
├── wifi.js (Wi-Fi 설정)
|
||
|
|
├── ethernet.js (이더넷 + LTE 인터페이스)
|
||
|
|
├── server-setting.js (서버 엔드포인트)
|
||
|
|
├── general-settings.js (Equipment + Protocol + Odometer)
|
||
|
|
├── sensor-io.js (RS485 + Analog/Digital 포트)
|
||
|
|
├── can-bus.js (CAN 설정 + 프레임 매핑)
|
||
|
|
├── opcua.js (OPC UA 엔드포인트 + 매핑)
|
||
|
|
├── modbus.js (Modbus 엔드포인트 + 바이트 순서 + 매핑)
|
||
|
|
├── log.js (로그 관리 3탭)
|
||
|
|
└── firmware.js (Firmware OTA 8단계 스테퍼)
|
||
|
|
[legacy aliases: ssid.js / io.js / network.js / register.js / can.js]
|
||
|
|
```
|
||
|
|
|
||
|
|
### Partial-Merge 데이터 흐름 (POST /setting/device)
|
||
|
|
|
||
|
|
```
|
||
|
|
Browser POST {partial JSON}
|
||
|
|
│
|
||
|
|
server.py:_handle_post_device
|
||
|
|
│
|
||
|
|
_read_request_body() ← 1 MB 제한; dict 가드 (배열/스칼라 거부)
|
||
|
|
│
|
||
|
|
normalize_device_input() ← enum_normalizer.py (case alias: ON→on 등)
|
||
|
|
│
|
||
|
|
validate_wifi_country_code() ← 유효하지 않은 2자 코드 시 하드 거부 400
|
||
|
|
validate_rs485_integers() ← bool/string 타입 시 하드 거부 400
|
||
|
|
validate_device_port_types_hard() ← 유효하지 않은 포트 값 시 하드 거부 400
|
||
|
|
validate_super_relay_keys() ← security/transport_config 포함 시 하드 거부 400
|
||
|
|
│
|
||
|
|
db.update_config("device_config", mutator)
|
||
|
|
│
|
||
|
|
├── BEGIN IMMEDIATE (SQLite 쓰기 잠금)
|
||
|
|
├── SELECT 기존 데이터
|
||
|
|
├── 기존 데이터 + POST body 병합 (partial-merge)
|
||
|
|
├── log_config 키 분리 → db.update_config("log_config", ...)
|
||
|
|
├── INSERT OR REPLACE device_config
|
||
|
|
└── COMMIT
|
||
|
|
│
|
||
|
|
응답: {success, merged_keys, preserved_keys_count}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 17. 운영 런북
|
||
|
|
|
||
|
|
### .54 배포 (dev/verify)
|
||
|
|
|
||
|
|
```powershell
|
||
|
|
cd C:\Development\NEW_Web_Configurator
|
||
|
|
.\scripts\deploy.ps1 192.168.55.54 -AllowUntagged
|
||
|
|
```
|
||
|
|
|
||
|
|
### .54 태그 배포 (릴리즈)
|
||
|
|
|
||
|
|
```powershell
|
||
|
|
git tag v1.5.4.2 -m "v1.5.4.2"
|
||
|
|
.\scripts\deploy.ps1 192.168.55.54
|
||
|
|
```
|
||
|
|
|
||
|
|
### 롤백
|
||
|
|
|
||
|
|
```powershell
|
||
|
|
.\scripts\deploy.ps1 192.168.55.54 -Rollback
|
||
|
|
```
|
||
|
|
|
||
|
|
### 수동 서비스 재시작 (deploy.ps1 restart가 조용히 실패할 때)
|
||
|
|
|
||
|
|
```powershell
|
||
|
|
ssh root@192.168.55.54 "systemctl restart web-configurator"
|
||
|
|
```
|
||
|
|
|
||
|
|
### 배포 헬스 확인
|
||
|
|
|
||
|
|
```powershell
|
||
|
|
ssh root@192.168.55.54 "curl -s http://localhost:9090/api/health"
|
||
|
|
# 예상 결과: {"status":"ok","version":"v1.5.4.2",...}
|
||
|
|
```
|
||
|
|
|
||
|
|
### DB 점검
|
||
|
|
|
||
|
|
```powershell
|
||
|
|
ssh root@192.168.55.54 "sqlite3 /home/root/db/dynamic_data.db 'SELECT key, length(value) FROM board_config;'"
|
||
|
|
# schema_meta 마이그레이션 점검
|
||
|
|
ssh root@192.168.55.54 "sqlite3 /home/root/db/dynamic_data.db 'SELECT key, value, applied_at FROM schema_meta;'"
|
||
|
|
```
|
||
|
|
|
||
|
|
### 펌웨어 업로드 (OTA)
|
||
|
|
|
||
|
|
```powershell
|
||
|
|
curl -F "file=@firmware.zip" http://192.168.55.54:9090/api/firmware/upload
|
||
|
|
curl -s http://192.168.55.54:9090/api/firmware/preflight
|
||
|
|
curl -X POST http://192.168.55.54:9090/api/firmware/flash
|
||
|
|
```
|
||
|
|
|
||
|
|
### 스키마 드리프트 확인 (로컬)
|
||
|
|
|
||
|
|
```powershell
|
||
|
|
python scripts/audit_schema_drift.py
|
||
|
|
# Exit 0 = 정상, 2 = 드리프트 감지, 1 = 오류
|
||
|
|
```
|
||
|
|
|
||
|
|
### 전체 테스트 실행
|
||
|
|
|
||
|
|
```powershell
|
||
|
|
python -m pytest tests/ -v # Python: 644개 테스트
|
||
|
|
npm test # Node: 35개 jsdom 테스트
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 18. 알려진 제한 사항 및 백로그
|
||
|
|
|
||
|
|
| 항목 | 상태 | 비고 |
|
||
|
|
|---|---|---|
|
||
|
|
| system-status 콜드 레이턴시 ~6초 | v1.5.5 후보 | 모든 콜드 호출 시 journalctl + 전체 로그 스캔 |
|
||
|
|
| Java 측 wipe 경로 | 미해결 (Java는 수정 불가) | 레거시 앱이 알 수 없는 enum 값을 조용히 null 처리하고, null 필드를 쓰기 시 삭제 — Python 하드 거부가 1차 방어선 |
|
||
|
|
| main 브랜치 FF 머지 보류 | 사용자 결정 사항 | .56 운영 환경은 명시적 사용자 승인 전까지 v1.4.6.10 유지 |
|
||
|
|
| GNSS 스캐너 최신 로그만 처리 | 낮은 우선순위 | 로그 회전 후 dpworldapp 재시작 시 GNSS 티어가 'na' 표시 — 다음 dpworldapp 시작 시 자가 복구 |
|
||
|
|
| collectAllPagesData 비대칭 | v1.5.5 후보 | Device Save → 3행 쓰기, Register Save → 1행 — 구조적 재설계 필요 |
|
||
|
|
| 리뷰에서 Medium 9 + Low 4 | 이연 | 사용자 체감 영향 0 — 기각 또는 v1.5.5+ 처리 |
|
||
|
|
| 16개 페이지 동적 import | v1.6+ 후보 | 현재 모든 페이지 즉시 로드; 동적 import 적용 시 초기 파싱 감소 가능 |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 부록 A — 파일 트리 (src/)
|
||
|
|
|
||
|
|
```
|
||
|
|
src/
|
||
|
|
├── server.py ← HTTP 서버, 라우팅, 진입점
|
||
|
|
├── db_manager.py ← SQLite 3티어 + update_config atomic RMW
|
||
|
|
├── config_validator.py ← 서버 측 유효성 검사 + 정규화
|
||
|
|
├── dpworldapp_enums.py ← device 계약 enum 값 (단일 진실 공급원)
|
||
|
|
├── enum_normalizer.py ← Case-only alias 정규화 (ON→on 등)
|
||
|
|
├── migrations.py ← 시작 마이그레이션 (3개 적용)
|
||
|
|
├── log_manager.py ← 로그 목록/다운로드/압축/삭제/통계 + auto-compress 데몬
|
||
|
|
├── kernel_log.py ← journalctl + 커널 로그 번들 빌더
|
||
|
|
├── system_status.py ← 홈 대시보드 상태 집계
|
||
|
|
├── support_bundle.py ← 진단 ZIP 생성기
|
||
|
|
├── firmware/
|
||
|
|
│ ├── __init__.py
|
||
|
|
│ ├── fw_client.py ← dpw-fw-update-tool 데몬용 Wire 클라이언트
|
||
|
|
│ ├── staging.py ← ZIP 스테이징 및 유효성 검사
|
||
|
|
│ ├── config_safety.py ← 설정 백업/복원
|
||
|
|
│ ├── protocol.py ← Wire 프로토콜 프레이밍
|
||
|
|
│ ├── fw_controller.py ← 상태 기반 OTA 오케스트레이션
|
||
|
|
│ └── fw_routes.py ← HTTP 라우트 핸들러
|
||
|
|
└── static/
|
||
|
|
├── index.html ← SPA 진입점, 5그룹 사이드바, modulepreload 힌트
|
||
|
|
├── css/style.css ← 디자인 토큰 + 컴포넌트 스타일 + firmware CSS
|
||
|
|
├── fonts/ ← Inter (.woff2 ×5) + JetBrains Mono (.woff2 ×2)
|
||
|
|
├── img/ ← dp-world-logo.svg
|
||
|
|
└── js/
|
||
|
|
├── app.js ← 라우터, Save All, Import/Export, 사이드바 JS
|
||
|
|
├── api.js ← REST 클라이언트 (모든 fetch 래퍼)
|
||
|
|
├── state.js ← 인메모리 상태 + flat↔nested 변환
|
||
|
|
├── page-dirty.js ← 페이지별 dirty 매트릭스 + 사이드바 점
|
||
|
|
├── nav-guard.js ← 네비게이션 확인 모달 + 포커스 트랩
|
||
|
|
├── icons.js ← Lucide v0.460+ 49개 SVG + icon() 헬퍼
|
||
|
|
├── constants.js ← APP_VERSION + DEFAULTS + enum 집합
|
||
|
|
├── utils.js ← escapeHtml + 헬퍼
|
||
|
|
├── toast.js ← 토스트 알림
|
||
|
|
├── validator.js ← 클라이언트 측 필드 유효성 검사
|
||
|
|
├── country-codes.js ← Wi-Fi 국가 코드 205개 드롭다운용
|
||
|
|
├── firmware-logic.js ← Firmware OTA 순수 로직 헬퍼
|
||
|
|
├── components/
|
||
|
|
│ ├── crud-table.js
|
||
|
|
│ └── ip-input.js
|
||
|
|
└── pages/
|
||
|
|
├── home.js (활성)
|
||
|
|
├── wifi.js (활성)
|
||
|
|
├── ethernet.js (활성)
|
||
|
|
├── server-setting.js (활성)
|
||
|
|
├── general-settings.js (활성)
|
||
|
|
├── sensor-io.js (활성)
|
||
|
|
├── can-bus.js (활성)
|
||
|
|
├── opcua.js (활성)
|
||
|
|
├── modbus.js (활성)
|
||
|
|
├── log.js (활성)
|
||
|
|
├── firmware.js (활성)
|
||
|
|
├── ssid.js (레거시 별칭 → wifi)
|
||
|
|
├── io.js (레거시 별칭 → ethernet, RS485/CAN 잔재)
|
||
|
|
├── network.js (레거시 별칭 → server-setting)
|
||
|
|
├── register.js (레거시 별칭 → general-settings)
|
||
|
|
└── can.js (레거시 별칭 → can-bus)
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 부록 B — 주요 코드 위치
|
||
|
|
|
||
|
|
| 내용 | 파일 | 라인/함수 |
|
||
|
|
|---|---|---|
|
||
|
|
| 진입점 / main() | `src/server.py` | `main()` (파일 하단) |
|
||
|
|
| HTTP 핸들러 클래스 | `src/server.py` | `class ConfigHandler` |
|
||
|
|
| 정적 파일 서빙 | `src/server.py` | `_serve_static_file()` ~L224 |
|
||
|
|
| gzip + ETag 로직 | `src/server.py` | `_serve_static_file()` ~L254-299 |
|
||
|
|
| Partial-merge POST device | `src/server.py` | `_handle_post_device()` |
|
||
|
|
| Partial-merge POST protocol | `src/server.py` | `_handle_post_protocol()` |
|
||
|
|
| Firmware 라우트 | `src/server.py` | do_GET + do_POST firmware 분기 |
|
||
|
|
| Atomic RMW | `src/db_manager.py` | `update_config()` L226 |
|
||
|
|
| BEGIN IMMEDIATE | `src/db_manager.py` | `update_config()` L258 |
|
||
|
|
| Java enum 값 | `src/dpworldapp_enums.py` | `DEVICE_ENUM_VALUES` L16, `PROTOCOL_ENUM_VALUES` L39 |
|
||
|
|
| Case alias 정규화 | `src/enum_normalizer.py` | `normalize_device_input()`, `normalize_protocol_input()` |
|
||
|
|
| 마이그레이션 | `src/migrations.py` | `apply_all_migrations()` |
|
||
|
|
| escapeHtml (5문자) | `src/static/js/utils.js` | `escapeHtml()` |
|
||
|
|
| icon() 헬퍼 | `src/static/js/icons.js` | `icon()` |
|
||
|
|
| page-dirty 매트릭스 | `src/static/js/state.js` | `state.pageDirty` |
|
||
|
|
| nav-guard 모달 | `src/static/js/nav-guard.js` | `confirmNavigation()` |
|
||
|
|
| flat↔nested 변환 | `src/static/js/state.js` | `convertFlatToNested()`, `convertNestedToFlat()` |
|
||
|
|
| FirmwareController | `src/firmware/fw_controller.py` | `class FirmwareController` |
|
||
|
|
| Schema drift 테스트 | `tests/test_schema_drift.py` | 파일 전체 |
|
||
|
|
| Config 계약 스냅샷 | `tests/fixtures/java_schema_expected.json` | 파일 전체 |
|
||
|
|
| 감사 스크립트 | `scripts/audit_schema_drift.py` | 파일 전체 |
|
||
|
|
| jsdom nav-guard 테스트 | `tests/test_nav_guard_behavior.mjs` | 파일 전체 |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 부록 C — 관련 문서
|
||
|
|
|
||
|
|
| 문서 | 위치 | 내용 |
|
||
|
|
|---|---|---|
|
||
|
|
| 구 아키텍처 (v2.2) | `docs/architecture.md` | v1.3.0 시대, 현재 이 문서로 대체됨 |
|
||
|
|
| 배포 런북 | `docs/DEPLOY.md` | 배포 절차, 버전 관리 |
|
||
|
|
| 2026-05-29 인시던트 | 내부 인시던트 기록(이 배포물에 미포함) | 전체 행 replace wipe 인시던트 |
|
||
|
|
| 변경 이력 | `CHANGELOG.md` | v1.4.0 → v1.5.4.2 전체 릴리즈 이력 |
|