41 KiB
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 수준으로 동작한다.
핵심 설계 원칙:
- LAN 전용 IoT 환경 — 외부 노출 없음, 인증 없음. 모든 클라이언트는 192.168.55.x (디바이스 LAN 세그먼트) 내부에 있다. CORS는 설계상 전체 개방.
- SQLite 공유 소유권 — 세 개의 독립 프로그램(이 서비스, 레거시 Java app-runner,
dpworldapp데이터 처리 바이너리)이 동일한~/db/dynamic_data.db를 읽고 쓴다. 키 수준 소유권 정책(board_config_key_ownership_policy)이 주된 안전 메커니즘이다. - config-reader 계약 준수 —
device_config와protocol_config는 디바이스 config-reader 계약과 정확히 일치해야 한다. 이를 통해 레거시 Java 앱이 .56에서 읽고 쓰는 동작이 예기치 않게 깨지지 않도록 보장한다. 자동화된 드리프트 감지가 이를 강제한다. - Partial-merge(부분 병합)만 허용 — 전체 행 REPLACE 없음.
DBManager.update_config(BEGIN IMMEDIATE RMW)가 모든 POST에서 미설정 키를 보존하도록 보장한다. - 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)
우선순위 체인:
python—sqlite3stdlib 모듈 (권장; 운영 환경에서 사용)cli— subprocess 경유sqlite3CLI (import 실패 시 폴백)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.pydo_GET)- 정적 파일:
os.realpath()+ 접두사 검사 (경로 순회 차단,server.py:237-239) - SPA 폴백: 나머지 모든 GET 경로에
index.html반환
정적 파일 서빙 (v1.5.4)
# 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→wifiio→ethernetnetwork→server-settingregister→general(=general-settings페이지)can→can-bus
페이지 객체 패턴
모든 페이지 모듈은 기본 페이지 객체를 export한다:
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에서 초기화:
_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의 단일 진실 공급원)
{
"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 유닛
버전 확인 (배포 후)
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)
cd C:\Development\NEW_Web_Configurator
.\scripts\deploy.ps1 192.168.55.54 -AllowUntagged
.54 태그 배포 (릴리즈)
git tag v1.5.4.2 -m "v1.5.4.2"
.\scripts\deploy.ps1 192.168.55.54
롤백
.\scripts\deploy.ps1 192.168.55.54 -Rollback
수동 서비스 재시작 (deploy.ps1 restart가 조용히 실패할 때)
ssh root@192.168.55.54 "systemctl restart web-configurator"
배포 헬스 확인
ssh root@192.168.55.54 "curl -s http://localhost:9090/api/health"
# 예상 결과: {"status":"ok","version":"v1.5.4.2",...}
DB 점검
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)
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
스키마 드리프트 확인 (로컬)
python scripts/audit_schema_drift.py
# Exit 0 = 정상, 2 = 드리프트 감지, 1 = 오류
전체 테스트 실행
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 전체 릴리즈 이력 |