Browse Source
Folds in the previously-uncommitted v1.12.0 changes (install path /opt/web-configurator -> /usr/lib/web-configurator; default port 8080 -> 9090), since v1.12.0 was never committed or tagged. The diff vs v1.11.16 therefore covers v1.12.0 + v1.12.1 together; CHANGELOG.md keeps the logical split. v1.12.1: - Telemetry Uplink: switch dpworldapp cloud telemetry egress between Wi-Fi and Ethernet using host routes (/32) only; no dpworldapp/OS network config touched; async apply + watchdog self-heal + management-interface protection. - Routing transparency: per-server actual egress interface + pre-apply warnings (read-only; no forwarding/NAT opened). - Full source review (41 items): honesty/safety/structure fixes. dpworldapp shared DB (device/protocol), atomic-write contract, and types unchanged. - Remove superseded pages ssid.js/network.js (aliased to wifi.js/server-setting.js). - Remove legacy deploy/nginx.conf sample (optional; excluded per integration guide). See CHANGELOG.md and RELEASE-NOTES.md for the full per-version breakdown. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>release/v1.12.1
53 changed files with 1939 additions and 950 deletions
@ -1 +1 @@ |
|||||
v1.11.16 |
v1.12.1 |
||||
|
|||||
@ -1,25 +0,0 @@ |
|||||
server { |
|
||||
listen 80; |
|
||||
server_name _; |
|
||||
|
|
||||
# Static files |
|
||||
location / { |
|
||||
root /opt/web-configurator/static; |
|
||||
try_files $uri $uri/ /index.html; |
|
||||
expires 1h; |
|
||||
add_header Cache-Control "public, must-revalidate"; |
|
||||
} |
|
||||
|
|
||||
location /configuration/ { |
|
||||
proxy_pass http://127.0.0.1:8080/; |
|
||||
proxy_set_header Host $host; |
|
||||
proxy_set_header X-Real-IP $remote_addr; |
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; |
|
||||
proxy_set_header X-Forwarded-Proto $scheme; |
|
||||
proxy_read_timeout 30s; |
|
||||
} |
|
||||
|
|
||||
location = /configuration { |
|
||||
return 301 /configuration/; |
|
||||
} |
|
||||
} |
|
||||
@ -0,0 +1,168 @@ |
|||||
|
<!DOCTYPE html> |
||||
|
<html lang="ko"> |
||||
|
<head> |
||||
|
<meta charset="utf-8"> |
||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"> |
||||
|
<title>Web Configurator — deploy/ 통합 가이드 (v1.12.1)</title> |
||||
|
<style> |
||||
|
:root { --fg:#1c2128; --muted:#57606a; --line:#d0d7de; --bg:#fff; --accent:#0969da; |
||||
|
--callout-bg:#f6f8fa; --callout-bd:#0969da; --warn-bg:#fff8e6; --warn-bd:#bf8700; --code-bg:#eff1f3; } |
||||
|
* { box-sizing:border-box; } |
||||
|
body { margin:0; background:#f4f5f7; color:var(--fg); |
||||
|
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Malgun Gothic","Apple SD Gothic Neo",sans-serif; |
||||
|
line-height:1.6; font-size:15px; } |
||||
|
.wrap { max-width:980px; margin:0 auto; padding:40px 28px 80px; background:var(--bg); |
||||
|
min-height:100vh; box-shadow:0 0 0 1px var(--line); } |
||||
|
h1 { font-size:26px; margin:0 0 6px; } |
||||
|
h2 { font-size:20px; margin:34px 0 12px; padding-top:14px; border-top:1px solid var(--line); } |
||||
|
h3 { font-size:16px; margin:22px 0 8px; } |
||||
|
p { margin:10px 0; } |
||||
|
.meta { color:var(--muted); font-size:13.5px; margin:0 0 8px; padding:10px 14px; |
||||
|
background:var(--callout-bg); border-radius:6px; } |
||||
|
table { border-collapse:collapse; width:100%; margin:12px 0; font-size:14px; } |
||||
|
th, td { border:1px solid var(--line); padding:8px 10px; text-align:left; vertical-align:top; } |
||||
|
th { background:var(--callout-bg); font-weight:600; } |
||||
|
code { background:var(--code-bg); padding:1px 5px; border-radius:4px; font-size:13px; |
||||
|
font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace; } |
||||
|
blockquote { margin:12px 0; padding:10px 16px; background:var(--callout-bg); |
||||
|
border-left:4px solid var(--callout-bd); border-radius:0 6px 6px 0; color:#24292f; } |
||||
|
blockquote.warn { background:var(--warn-bg); border-left-color:var(--warn-bd); } |
||||
|
ul, ol { margin:10px 0; padding-left:24px; } |
||||
|
li { margin:5px 0; } |
||||
|
.footer { margin-top:40px; padding-top:16px; border-top:1px solid var(--line); |
||||
|
color:var(--muted); font-size:13px; } |
||||
|
strong { font-weight:700; } |
||||
|
</style> |
||||
|
</head> |
||||
|
<body> |
||||
|
<div class="wrap"> |
||||
|
|
||||
|
<h1>Web Configurator — <code>deploy/</code> 통합 가이드 <span style="color:var(--muted);font-size:18px;">(협력사 전달용)</span></h1> |
||||
|
<div class="meta"> |
||||
|
<strong>목적</strong>: <code>deploy/</code> 폴더의 구성요소 중 <strong>꼭 필요한 것</strong>과 그 <strong>기능</strong>을 정리하고, 기존 <code>dpworldapp</code>/펌웨어와의 <strong>"충돌" 오해</strong>를 해소합니다.<br> |
||||
|
<strong>대상 버전</strong>: v1.12.1 · <strong>검증</strong>: <code>deploy/</code> 통합 의미론은 v1.11.15 기준 소스 코드 + 실디바이스(192.168.55.54, 펌웨어 원본 상태) 대조 완료. v1.12.0 설치 경로 이전(<code>/usr/lib</code>)과 v1.12.1 <code>nginx.conf</code> 제거를 반영했습니다. |
||||
|
</div> |
||||
|
|
||||
|
<h2>0. 한눈에 — 핵심 3가지</h2> |
||||
|
<ol> |
||||
|
<li><strong>웹 설정기 앱 자체는 네트워크/AP 파일 없이도 단독으로 동작합니다</strong> (<code>:9090</code>). 앱은 <code>src/</code>(파이썬) + <code>web-configurator.service</code> 두 가지만 있으면 기동·설정 저장이 됩니다.</li> |
||||
|
<li><strong>"충돌"의 실체는 단 3개 파일</strong>(네트워크 드롭인)이 <em>펌웨어가 소유한 유닛을 덮어쓰는 것</em>뿐입니다. 그 외 모든 파일은 <strong>새로 추가(additive)</strong>되는 것이라 기존 자원을 전혀 건드리지 않습니다 — 크래시·에러를 내지 않습니다.</li> |
||||
|
<li>따라서 <strong>원하는 기능만 골라 설치</strong>하면 충돌 없이 통합됩니다. (§3에 3가지 통합 방식)</li> |
||||
|
</ol> |
||||
|
<blockquote> |
||||
|
<strong>경로 표준 (v1.12.0~, 협력사 협의 반영)</strong>: 앱 코드는 <strong><code>/usr/lib/web-configurator</code></strong> 에 설치합니다 — FHS상 <code>/usr/lib</code> 가 read-only program code 자리이며, BSP가 이미지에 굽는 read-only rootfs에 적합합니다. 쓰기 런타임 데이터(로그·펌웨어 staging/upload·설정 백업)는 <code>/usr/lib</code>(read-only)가 아니라 <strong>쓰기 가능한 영속 파티션(<code>/opt</code>, <code>/home/root</code>)</strong> 에 둡니다(§4).<br><br> |
||||
|
<strong>신규 텔레메트리 Uplink (v1.12.1)</strong> 는 런타임에 OS 호스트 라우트(/32)만 조작하므로 <strong><code>deploy/</code> 추가 구성요소가 필요 없습니다</strong> — 새 유닛/스크립트 없이 기존 <code>web-configurator.service</code>(root 권한) 만으로 동작합니다. |
||||
|
</blockquote> |
||||
|
|
||||
|
<h2>1. 기능 그룹과 필요한 파일</h2> |
||||
|
<p><code>deploy/</code>의 구성요소는 <strong>3개 기능 그룹</strong>으로 나뉩니다. 그룹 단위로 켜고 끌 수 있습니다.</p> |
||||
|
|
||||
|
<h3>A. 웹 설정기 (필수 — 항상 설치)</h3> |
||||
|
<table> |
||||
|
<thead><tr><th>파일</th><th>기능</th><th>설치 위치</th></tr></thead> |
||||
|
<tbody> |
||||
|
<tr><td>(앱 본체 <code>src/</code>)</td><td>파이썬 stdlib HTTP 서버. 웹 UI + 설정 API. <code>:9090</code> 리슨</td><td><code>/usr/lib/web-configurator/src</code> <em>(표준 — read-only rootfs 가능; 쓰기 데이터는 §4)</em></td></tr> |
||||
|
<tr><td><code>web-configurator.service</code></td><td>위 앱을 부팅 자동기동 + 크래시 시 재시작하는 systemd 유닛</td><td><code>${systemd_system_unitdir}</code></td></tr> |
||||
|
</tbody> |
||||
|
</table> |
||||
|
<p>이 그룹만으로 웹 설정기가 동작합니다(설정은 DB <code>/home/root/db</code>에 저장). <strong><code>dpworldapp</code>과 충돌하지 않습니다.</strong></p> |
||||
|
|
||||
|
<h3>B. 네트워크 즉시(라이브) 적용 — 선택</h3> |
||||
|
<blockquote> |
||||
|
<strong>왜 필요한가</strong>: <code>dpworldapp</code>은 <strong>재기동 시에만</strong> 네트워크/설정을 OS에 반영합니다. 이 그룹은 웹 UI에서 IP·Wi-Fi(SSID/비밀번호)를 바꾸면 <strong>재부팅·dpworldapp 재기동 없이 즉시</strong> 반영하기 위한 <strong>보완</strong> 구성입니다.<br><br> |
||||
|
<strong>동작 메커니즘</strong>: 웹이 렌더 파일을 <code>/home/root/network/</code>에 기록 → 적용 스크립트가 그것을 <code>/run/systemd/network/</code>로 동기화 + <code>networkctl</code>/<code>wpa_cli</code> 재구성. (※ <code>networkctl</code>은 <code>/home/root/network</code>를 직접 읽지 않으므로 이 <strong>동기화 스크립트가 있어야</strong> 라이브 적용이 됩니다.) |
||||
|
</blockquote> |
||||
|
<table> |
||||
|
<thead><tr><th>파일</th><th>기능</th><th>설치 위치</th></tr></thead> |
||||
|
<tbody> |
||||
|
<tr><td><code>dpworld-network-apply-hardened.sh</code></td><td>네트워크 적용 스크립트. <strong>펌웨어 원본의 안전 버전</strong> — Wi-Fi country 변경 시 <code>modprobe -r wlan</code>(QCA6490 워치독 리부팅 루프 유발)을 제거하고 country를 <strong>재부팅 시 적용(reboot-deferred)</strong>으로 처리</td><td><code>/usr/bin/</code> (0755)</td></tr> |
||||
|
<tr><td><code>dpworld-net-recover.service</code> <em>(옵션)</em></td><td>wlan 모듈이 완전히 내려갔을 때 복구(<code>modprobe</code> + <code>wpa</code> 재시작). 웹/워치독이 on-demand로 호출</td><td><code>${systemd_system_unitdir}</code></td></tr> |
||||
|
</tbody> |
||||
|
</table> |
||||
|
<blockquote>위 스크립트를 <strong>어떻게</strong> 펌웨어 적용 경로에 연결할지(드롭인 vs 전용 유닛 vs 미사용)는 <strong>§3</strong>에서 선택합니다. 드롭인 3종은 §3 옵션 ③에서만 사용합니다.</blockquote> |
||||
|
|
||||
|
<h3>C. Wi-Fi AP 모드 — 선택</h3> |
||||
|
<blockquote><strong>기능</strong>: 장치를 Wi-Fi AP로 띄워 작업자가 휴대폰/노트북으로 직접 접속(현장 provisioning). <code>wlan0</code>(STA)은 건드리지 않고 <strong><code>ap0</code> 가상 인터페이스</strong>를 추가해 사용. hostapd + udhcpd + <strong>ap0 전용 방화벽</strong>(INPUT 전체 개방·WPA2 PSK가 접근 게이트 / FORWARD 차단 = AP 클라이언트의 내부망·PLC(eth1) 경유 차단).</blockquote> |
||||
|
<table> |
||||
|
<thead><tr><th>파일</th><th>기능</th><th>설치 위치</th></tr></thead> |
||||
|
<tbody> |
||||
|
<tr><td><code>dpworld-ap-apply.sh</code></td><td>AP 기동/해제 스크립트(ap0 생성·IP·방화벽·hostapd/udhcpd 시작)</td><td><code>/usr/bin/</code> (0755)</td></tr> |
||||
|
<tr><td><code>dpworld-ap-apply.service</code></td><td>웹이 on-demand로 호출하는 AP 적용 유닛 (<code>[Install]</code> 없음 = 부팅 자동기동 안 함)</td><td><code>${systemd_system_unitdir}</code></td></tr> |
||||
|
<tr><td><code>dpworld-hostapd-ap0.service</code></td><td><code>ap0</code>에서 hostapd 실행 (<code>[Install]</code> 없음, ap-apply.sh가 기동)</td><td><code>${systemd_system_unitdir}</code></td></tr> |
||||
|
<tr><td><code>dpworld-udhcpd-ap0.service</code></td><td><code>ap0</code>에서 DHCP 서버 실행 (<code>[Install]</code> 없음, ap-apply.sh가 기동)</td><td><code>${systemd_system_unitdir}</code></td></tr> |
||||
|
<tr><td><code>dpworld-ap-seed.service</code> <em>(옵션)</em></td><td>재부팅 후 AP를 자동 재기동(복구). <strong>없으면 재부팅 후 AP 수동 재활성 필요</strong></td><td><code>${systemd_system_unitdir}</code></td></tr> |
||||
|
</tbody> |
||||
|
</table> |
||||
|
<blockquote>AP 그룹은 <strong>전적으로 신규 구성</strong>입니다. <code>dpworldapp</code>/펌웨어 자원을 건드리지 않으며, <code>ap0</code> 전용이라 STA·eth·<code>dpworldapp</code>과 충돌하지 않습니다.</blockquote> |
||||
|
|
||||
|
<h2>2. 제외 / 참고</h2> |
||||
|
<table> |
||||
|
<thead><tr><th>파일</th><th>사유</th></tr></thead> |
||||
|
<tbody> |
||||
|
<tr><td><code>deploy/nginx.conf</code></td><td><strong>v1.12.1에서 패키지에서 제거됨</strong>. 앱은 <code>:9090</code>에서 직접 서비스되므로 reverse proxy 없이 동작합니다. 협력사가 자체 nginx로 <code>:80</code> 프런트할 경우, 협력사 설정에서 <code>proxy_pass http://127.0.0.1:9090/</code>만 잡으면 됩니다(앱 포트는 <code>:9090</code> — 레거시 <code>:8080</code> Java app-runner 아님).</td></tr> |
||||
|
<tr><td><code>scripts/board_trace*.sh</code></td><td>(<code>deploy/</code> 밖이지만 참고) board_config를 폴링해 <code>/tmp</code>에 기록하는 <strong>개발 진단용</strong> 스크립트 — 운영 배포물에서 제거 권장</td></tr> |
||||
|
</tbody> |
||||
|
</table> |
||||
|
|
||||
|
<h2>3. "충돌" 해소 — 네트워크 적용 통합 3가지 방식</h2> |
||||
|
<p><strong>"충돌"은 §1-B의 라이브 적용을 <em>펌웨어 유닛을 빌려서</em> 수행하기 때문에 발생합니다.</strong> 웹은 적용을 <code>dpworld-network-apply.service</code>(펌웨어 소유 유닛) 이름으로 호출하므로, 그 유닛이 우리 스크립트를 실행하게 하려면 유닛을 건드려야 합니다. 아래 3가지 중 선택하세요.</p> |
||||
|
<table> |
||||
|
<thead><tr><th>방식</th><th>설치 파일</th><th>펌웨어 유닛 접촉</th><th>라이브 적용</th><th>비고</th></tr></thead> |
||||
|
<tbody> |
||||
|
<tr><td><strong>① 저장 전용</strong></td><td>네트워크 파일 <strong>0개</strong></td><td><strong>없음(무접촉)</strong></td><td>✗ (dpworldapp 재기동 시 반영)</td><td>웹은 설정 편집·저장만. 충돌 0. 가장 단순</td></tr> |
||||
|
<tr><td><strong>② 전용 유닛 (권장)</strong></td><td><code>dpworld-network-apply-hardened.sh</code> + 신규 <strong>웹 전용 유닛</strong></td><td><strong>없음(무접촉)</strong></td><td>✓</td><td>웹이 펌웨어 유닛 대신 <strong>자체 유닛</strong>으로 적용. <strong>웹 소규모 코드 변경 필요</strong>(적용 유닛명을 <code>NET_APPLY_SERVICE</code> env로 분리 — 현재는 <code>dpworld-network-apply.service</code>로 하드코딩)</td></tr> |
||||
|
<tr><td><strong>③ 드롭인 override (현재 패키지)</strong></td><td><code>dpworld-network-apply-hardened.sh</code> + 드롭인 3종</td><td><strong>있음(override)</strong></td><td>✓</td><td>펌웨어 유닛의 ExecStart/의존성을 우리 것으로 교체. 이것이 협력사가 본 "충돌"</td></tr> |
||||
|
</tbody> |
||||
|
</table> |
||||
|
<p><strong>드롭인 3종(③에서만 사용):</strong></p> |
||||
|
<ul> |
||||
|
<li><code>dpworld-network-apply.service.d/20-hardened.conf</code> — 펌웨어 <code>dpworld-network-apply.service</code>의 <code>ExecStart</code>를 hardened.sh로 교체</li> |
||||
|
<li><code>dpworld-network-seed.service.d/20-hardened.conf</code> — 펌웨어 <code>dpworld-network-seed.service</code>(부팅 seed)를 <code>hardened.sh --boot</code>로 교체</li> |
||||
|
<li><code>dpworld-network-apply-ondemand.conf</code> — 위 유닛의 <code>Requires=</code>(boot-only seed 의존)를 비워 on-demand 기동 허용</li> |
||||
|
</ul> |
||||
|
<blockquote><strong>권장</strong>: 펌웨어 베이킹(BSP)에서는 하드닝본을 <code>/usr/bin/dpworld-network-apply.sh</code> <strong>원본 이름으로 직접 교체</strong>하는 방식이 1차입니다(드롭인 미설치 — <code>BSP-INTEGRATION.md</code> §7 ①). 펌웨어 유닛을 못 건드리는 <strong>라이브 디바이스</strong>에서만 드롭인 override(③)를 fallback으로 씁니다. 라이브 적용이 불필요하면 <strong>① 저장 전용</strong>. (③/직접교체 시 §1-B 동작 차이 — Wi-Fi country는 재부팅 시 적용 — 만 합의하면 됨).</blockquote> |
||||
|
|
||||
|
<h2>4. 설치 시 주의 (부분배포 사고 방지)</h2> |
||||
|
<ul> |
||||
|
<li><strong>드롭인(③)을 설치하면 <code>dpworld-network-apply-hardened.sh</code>도 반드시 함께</strong> 설치하세요. 드롭인만 있고 스크립트가 없으면 ExecStart가 없는 파일을 가리켜 <strong>펌웨어 네트워크 유닛이 EXEC 실패</strong>합니다(멀쩡하던 적용이 깨짐).</li> |
||||
|
<li><strong><code>web-configurator.service</code>를 설치하면 앱 본체 <code>src/</code>도 함께</strong> 설치하세요. 앱이 없으면 서비스가 재시작 루프에 빠집니다.</li> |
||||
|
<li><strong>쓰기 가능 경로</strong>: 앱 코드는 read-only rootfs(<code>/usr/lib/web-configurator</code>)에 두지만, 아래 런타임 경로는 <strong>쓰기 가능한 영속 파티션</strong>(<code>/opt</code>, <code>/home/root</code>)에 있어야 합니다. |
||||
|
<ul> |
||||
|
<li>DB <code>/home/root/db</code> · 네트워크 렌더 <code>/home/root/network</code> <em>(dpworldapp과 공유 — 그대로)</em></li> |
||||
|
<li>로그 <code>/opt/log/dpworldapp</code>(유닛 <code>LOG_DIR</code>) · 펌웨어 staging/upload <code>/opt/fw_staging</code>·<code>/opt/fw_upload</code> · 설정 백업 <code>/opt/config_backups</code> <em>(유닛 <code>ReadWritePaths</code> 참조)</em></li> |
||||
|
<li>hardened.sh 상태/로그 <code>STATE_DIR=/opt/dpworld-network</code> <em>(펌웨어 원본엔 없던 신규 경로 — <code>/opt</code>가 늦게 마운트되면 로그/reboot 마커만 유실, 적용 자체는 진행). <code>/opt</code> 마운트 보장(<code>RequiresMountsFor=/opt</code>)을 권장</em></li> |
||||
|
</ul> |
||||
|
</li> |
||||
|
</ul> |
||||
|
|
||||
|
<h2>5. 전체 파일 분류표 (요약)</h2> |
||||
|
<table> |
||||
|
<thead><tr><th><code>deploy/</code> 파일</th><th>그룹</th><th>필요성</th><th>펌웨어 유닛 접촉</th></tr></thead> |
||||
|
<tbody> |
||||
|
<tr><td><code>web-configurator.service</code></td><td>A 코어</td><td><strong>필수</strong></td><td>무접촉(신규 유닛)</td></tr> |
||||
|
<tr><td><code>dpworld-network-apply-hardened.sh</code></td><td>B 네트워크</td><td>라이브 적용 시 필요</td><td>무접촉(파일)</td></tr> |
||||
|
<tr><td><code>dpworld-network-apply.service.d/20-hardened.conf</code></td><td>B 네트워크</td><td><strong>③에서만</strong></td><td>★ override</td></tr> |
||||
|
<tr><td><code>dpworld-network-seed.service.d/20-hardened.conf</code></td><td>B 네트워크</td><td><strong>③에서만</strong></td><td>★ override</td></tr> |
||||
|
<tr><td><code>dpworld-network-apply-ondemand.conf</code></td><td>B 네트워크</td><td><strong>③에서만</strong></td><td>★ override</td></tr> |
||||
|
<tr><td><code>dpworld-net-recover.service</code></td><td>B 네트워크</td><td>옵션(복구 안전망)</td><td>무접촉(신규 유닛)</td></tr> |
||||
|
<tr><td><code>dpworld-ap-apply.sh</code></td><td>C AP</td><td>AP 시 필수</td><td>무접촉(파일)</td></tr> |
||||
|
<tr><td><code>dpworld-ap-apply.service</code></td><td>C AP</td><td>AP 시 필수</td><td>무접촉(신규 유닛)</td></tr> |
||||
|
<tr><td><code>dpworld-hostapd-ap0.service</code></td><td>C AP</td><td>AP 시 필수</td><td>무접촉(신규 유닛)</td></tr> |
||||
|
<tr><td><code>dpworld-udhcpd-ap0.service</code></td><td>C AP</td><td>AP 시 필수</td><td>무접촉(신규 유닛)</td></tr> |
||||
|
<tr><td><code>dpworld-ap-seed.service</code></td><td>C AP</td><td>옵션(재부팅 후 AP 자동복구)</td><td>무접촉(신규 유닛)</td></tr> |
||||
|
</tbody> |
||||
|
</table> |
||||
|
<blockquote>★ 표시(드롭인 3종)만이 펌웨어 소유 유닛을 건드립니다 = "충돌"의 전부. 이 3개를 빼면 펌웨어/<code>dpworldapp</code> 자원은 <strong>0개</strong> 건드리지 않습니다.<br><em><code>nginx.conf</code>는 v1.12.1에서 패키지에서 제거됨(§2) — deploy/ 구성요소 아님.</em></blockquote> |
||||
|
|
||||
|
<h2>부록: dpworldapp과의 공유·격리 요약</h2> |
||||
|
<ul> |
||||
|
<li><strong>공유(의도된 연동)</strong>: DB <code>/home/root/db</code>, 네트워크 렌더 <code>/home/root/network</code> — 웹과 <code>dpworldapp</code>이 동일 파일 계약(byte 호환)을 공유. 웹이 편집·저장하고 <code>dpworldapp</code>이 소비하는 구조(충돌 아님).</li> |
||||
|
<li><strong>격리(신규, 무접촉)</strong>: 웹 앱(<code>:9090</code>), AP(<code>ap0</code> 전용 + 방화벽), 복구 유닛, 텔레메트리 Uplink(OS 호스트 라우트만) — 전부 신규 자원.</li> |
||||
|
<li><strong>유일한 접점</strong>: <code>dpworld-network-apply.service</code>/<code>-seed.service</code>(펌웨어 소유) — 라이브 적용을 위해 ③ 방식에서만 override. ① 또는 ②를 택하면 이 접점도 사라집니다.</li> |
||||
|
</ul> |
||||
|
|
||||
|
<div class="footer">Web Configurator v1.12.1 · <code>deploy/</code> 통합 가이드 · 앱 코드 <code>/usr/lib/web-configurator</code> 표준 · 소스 + 실디바이스(.54) 검증본(통합 의미론)</div> |
||||
|
|
||||
|
</div> |
||||
|
</body> |
||||
|
</html> |
||||
@ -0,0 +1,126 @@ |
|||||
|
# Web Configurator — `deploy/` 통합 가이드 (협력사 전달용) |
||||
|
|
||||
|
> **목적**: `deploy/` 폴더의 구성요소 중 **꼭 필요한 것**과 그 **기능**을 정리하고, 기존 `dpworldapp`/펌웨어와의 **"충돌" 오해**를 해소합니다. |
||||
|
> **대상 버전**: v1.12.1 · **검증**: `deploy/` 통합 의미론은 v1.11.15 기준 소스 코드 + 실디바이스(192.168.55.54, 펌웨어 원본 상태) 대조 완료. v1.12.0 설치 경로 이전(`/usr/lib`)과 v1.12.1 `nginx.conf` 제거를 반영했습니다. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 0. 한눈에 — 핵심 3가지 |
||||
|
|
||||
|
1. **웹 설정기 앱 자체는 네트워크/AP 파일 없이도 단독으로 동작합니다** (`:9090`). 앱은 `src/`(파이썬) + `web-configurator.service` 두 가지만 있으면 기동·설정 저장이 됩니다. |
||||
|
2. **"충돌"의 실체는 단 3개 파일**(네트워크 드롭인)이 *펌웨어가 소유한 유닛을 덮어쓰는 것*뿐입니다. 그 외 모든 파일은 **새로 추가(additive)**되는 것이라 기존 자원을 전혀 건드리지 않습니다 — 크래시·에러를 내지 않습니다. |
||||
|
3. 따라서 **원하는 기능만 골라 설치**하면 충돌 없이 통합됩니다. (§3에 3가지 통합 방식) |
||||
|
|
||||
|
> **경로 표준 (v1.12.0~, 협력사 협의 반영)**: 앱 코드는 **`/usr/lib/web-configurator`** 에 설치합니다 — FHS상 `/usr/lib` 가 read-only program code 자리이며, BSP가 이미지에 굽는 read-only rootfs에 적합합니다. 쓰기 런타임 데이터(로그·펌웨어 staging/upload·설정 백업)는 `/usr/lib`(read-only)가 아니라 **쓰기 가능한 영속 파티션(`/opt`, `/home/root`)** 에 둡니다(§4). |
||||
|
> |
||||
|
> **신규 텔레메트리 Uplink (v1.12.1)** 는 런타임에 OS 호스트 라우트(/32)만 조작하므로 **`deploy/` 추가 구성요소가 필요 없습니다** — 새 유닛/스크립트 없이 기존 `web-configurator.service`(root 권한) 만으로 동작합니다. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 1. 기능 그룹과 필요한 파일 |
||||
|
|
||||
|
`deploy/`의 구성요소는 **3개 기능 그룹**으로 나뉩니다. 그룹 단위로 켜고 끌 수 있습니다. |
||||
|
|
||||
|
### A. 웹 설정기 (필수 — 항상 설치) |
||||
|
|
||||
|
| 파일 | 기능 | 설치 위치 | |
||||
|
|---|---|---| |
||||
|
| (앱 본체 `src/`) | 파이썬 stdlib HTTP 서버. 웹 UI + 설정 API. `:9090` 리슨 | `/usr/lib/web-configurator/src` *(표준 — read-only rootfs 가능; 쓰기 데이터는 §4)* | |
||||
|
| `web-configurator.service` | 위 앱을 부팅 자동기동 + 크래시 시 재시작하는 systemd 유닛 | `${systemd_system_unitdir}` | |
||||
|
|
||||
|
이 그룹만으로 웹 설정기가 동작합니다(설정은 DB `/home/root/db`에 저장). **`dpworldapp`과 충돌하지 않습니다.** |
||||
|
|
||||
|
### B. 네트워크 즉시(라이브) 적용 — 선택 |
||||
|
|
||||
|
> **왜 필요한가**: `dpworldapp`은 **재기동 시에만** 네트워크/설정을 OS에 반영합니다. 이 그룹은 웹 UI에서 IP·Wi-Fi(SSID/비밀번호)를 바꾸면 **재부팅·dpworldapp 재기동 없이 즉시** 반영하기 위한 **보완** 구성입니다. |
||||
|
> |
||||
|
> **동작 메커니즘**: 웹이 렌더 파일을 `/home/root/network/`에 기록 → 적용 스크립트가 그것을 `/run/systemd/network/`로 동기화 + `networkctl`/`wpa_cli` 재구성. (※ `networkctl`은 `/home/root/network`를 직접 읽지 않으므로 이 **동기화 스크립트가 있어야** 라이브 적용이 됩니다.) |
||||
|
|
||||
|
| 파일 | 기능 | 설치 위치 | |
||||
|
|---|---|---| |
||||
|
| `dpworld-network-apply-hardened.sh` | 네트워크 적용 스크립트. **펌웨어 원본의 안전 버전** — Wi-Fi country 변경 시 `modprobe -r wlan`(QCA6490 워치독 리부팅 루프 유발)을 제거하고 country를 **재부팅 시 적용(reboot-deferred)**으로 처리 | `/usr/bin/` (0755) | |
||||
|
| `dpworld-net-recover.service` *(옵션)* | wlan 모듈이 완전히 내려갔을 때 복구(`modprobe` + `wpa` 재시작). 웹/워치독이 on-demand로 호출 | `${systemd_system_unitdir}` | |
||||
|
|
||||
|
> 위 스크립트를 **어떻게** 펌웨어 적용 경로에 연결할지(드롭인 vs 전용 유닛 vs 미사용)는 **§3**에서 선택합니다. 드롭인 3종은 §3 옵션 ③에서만 사용합니다. |
||||
|
|
||||
|
### C. Wi-Fi AP 모드 — 선택 |
||||
|
|
||||
|
> **기능**: 장치를 Wi-Fi AP로 띄워 작업자가 휴대폰/노트북으로 직접 접속(현장 provisioning). `wlan0`(STA)은 건드리지 않고 **`ap0` 가상 인터페이스**를 추가해 사용. hostapd + udhcpd + **ap0 전용 방화벽**(INPUT 전체 개방·WPA2 PSK가 접근 게이트 / FORWARD 차단 = AP 클라이언트의 내부망·PLC(eth1) 경유 차단). |
||||
|
|
||||
|
| 파일 | 기능 | 설치 위치 | |
||||
|
|---|---|---| |
||||
|
| `dpworld-ap-apply.sh` | AP 기동/해제 스크립트(ap0 생성·IP·방화벽·hostapd/udhcpd 시작) | `/usr/bin/` (0755) | |
||||
|
| `dpworld-ap-apply.service` | 웹이 on-demand로 호출하는 AP 적용 유닛 (`[Install]` 없음 = 부팅 자동기동 안 함) | `${systemd_system_unitdir}` | |
||||
|
| `dpworld-hostapd-ap0.service` | `ap0`에서 hostapd 실행 (`[Install]` 없음, ap-apply.sh가 기동) | `${systemd_system_unitdir}` | |
||||
|
| `dpworld-udhcpd-ap0.service` | `ap0`에서 DHCP 서버 실행 (`[Install]` 없음, ap-apply.sh가 기동) | `${systemd_system_unitdir}` | |
||||
|
| `dpworld-ap-seed.service` *(옵션)* | 재부팅 후 AP를 자동 재기동(복구). **없으면 재부팅 후 AP 수동 재활성 필요** | `${systemd_system_unitdir}` | |
||||
|
|
||||
|
> AP 그룹은 **전적으로 신규 구성**입니다. `dpworldapp`/펌웨어 자원을 건드리지 않으며, `ap0` 전용이라 STA·eth·`dpworldapp`과 충돌하지 않습니다. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 2. 제외 / 참고 |
||||
|
|
||||
|
| 파일 | 사유 | |
||||
|
|---|---| |
||||
|
| `deploy/nginx.conf` | **v1.12.1에서 패키지에서 제거됨**. 앱은 `:9090`에서 직접 서비스되므로 reverse proxy 없이 동작합니다. 협력사가 자체 nginx로 `:80` 프런트할 경우, 협력사 설정에서 `proxy_pass http://127.0.0.1:9090/`만 잡으면 됩니다(앱 포트는 `:9090` — 레거시 `:8080` Java app-runner 아님). | |
||||
|
| `scripts/board_trace*.sh` | (`deploy/` 밖이지만 참고) board_config를 폴링해 `/tmp`에 기록하는 **개발 진단용** 스크립트 — 운영 배포물에서 제거 권장 | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 3. "충돌" 해소 — 네트워크 적용 통합 3가지 방식 |
||||
|
|
||||
|
**"충돌"은 §1-B의 라이브 적용을 *펌웨어 유닛을 빌려서* 수행하기 때문에 발생합니다.** 웹은 적용을 `dpworld-network-apply.service`(펌웨어 소유 유닛) 이름으로 호출하므로, 그 유닛이 우리 스크립트를 실행하게 하려면 유닛을 건드려야 합니다. 아래 3가지 중 선택하세요. |
||||
|
|
||||
|
| 방식 | 설치 파일 | 펌웨어 유닛 접촉 | 라이브 적용 | 비고 | |
||||
|
|---|---|---|---|---| |
||||
|
| **① 저장 전용** | 네트워크 파일 **0개** | **없음(무접촉)** | ✗ (dpworldapp 재기동 시 반영) | 웹은 설정 편집·저장만. 충돌 0. 가장 단순 | |
||||
|
| **② 전용 유닛 (권장)** | `dpworld-network-apply-hardened.sh` + 신규 **웹 전용 유닛** | **없음(무접촉)** | ✓ | 웹이 펌웨어 유닛 대신 **자체 유닛**으로 적용. **웹 소규모 코드 변경 필요**(적용 유닛명을 `NET_APPLY_SERVICE` env로 분리 — 현재는 `dpworld-network-apply.service`로 하드코딩) | |
||||
|
| **③ 드롭인 override (현재 패키지)** | `dpworld-network-apply-hardened.sh` + 드롭인 3종 | **있음(override)** | ✓ | 펌웨어 유닛의 ExecStart/의존성을 우리 것으로 교체. 이것이 협력사가 본 "충돌" | |
||||
|
|
||||
|
**드롭인 3종(③에서만 사용):** |
||||
|
- `dpworld-network-apply.service.d/20-hardened.conf` — 펌웨어 `dpworld-network-apply.service`의 `ExecStart`를 hardened.sh로 교체 |
||||
|
- `dpworld-network-seed.service.d/20-hardened.conf` — 펌웨어 `dpworld-network-seed.service`(부팅 seed)를 `hardened.sh --boot`로 교체 |
||||
|
- `dpworld-network-apply-ondemand.conf` — 위 유닛의 `Requires=`(boot-only seed 의존)를 비워 on-demand 기동 허용 |
||||
|
|
||||
|
> **권장**: 펌웨어 베이킹(BSP)에서는 하드닝본을 `/usr/bin/dpworld-network-apply.sh` **원본 이름으로 직접 교체**하는 방식이 1차입니다(드롭인 미설치 — `BSP-INTEGRATION.md` §7 ①). 펌웨어 유닛을 못 건드리는 **라이브 디바이스**에서만 드롭인 override(③)를 fallback으로 씁니다. 라이브 적용이 불필요하면 **① 저장 전용**. (③/직접교체 시 §1-B 동작 차이 — Wi-Fi country는 재부팅 시 적용 — 만 합의하면 됨). |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 4. 설치 시 주의 (부분배포 사고 방지) |
||||
|
|
||||
|
- **드롭인(③)을 설치하면 `dpworld-network-apply-hardened.sh`도 반드시 함께** 설치하세요. 드롭인만 있고 스크립트가 없으면 ExecStart가 없는 파일을 가리켜 **펌웨어 네트워크 유닛이 EXEC 실패**합니다(멀쩡하던 적용이 깨짐). |
||||
|
- **`web-configurator.service`를 설치하면 앱 본체 `src/`도 함께** 설치하세요. 앱이 없으면 서비스가 재시작 루프에 빠집니다. |
||||
|
- **쓰기 가능 경로**: 앱 코드는 read-only rootfs(`/usr/lib/web-configurator`)에 두지만, 아래 런타임 경로는 **쓰기 가능한 영속 파티션**(`/opt`, `/home/root`)에 있어야 합니다. |
||||
|
- DB `/home/root/db` · 네트워크 렌더 `/home/root/network` *(dpworldapp과 공유 — 그대로)* |
||||
|
- 로그 `/opt/log/dpworldapp`(유닛 `LOG_DIR`) · 펌웨어 staging/upload `/opt/fw_staging`·`/opt/fw_upload` · 설정 백업 `/opt/config_backups` *(유닛 `ReadWritePaths` 참조)* |
||||
|
- hardened.sh 상태/로그 `STATE_DIR=/opt/dpworld-network` *(펌웨어 원본엔 없던 신규 경로 — `/opt`가 늦게 마운트되면 로그/reboot 마커만 유실, 적용 자체는 진행). `/opt` 마운트 보장(`RequiresMountsFor=/opt`)을 권장* |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 5. 전체 파일 분류표 (요약) |
||||
|
|
||||
|
| `deploy/` 파일 | 그룹 | 필요성 | 펌웨어 유닛 접촉 | |
||||
|
|---|---|---|---| |
||||
|
| `web-configurator.service` | A 코어 | **필수** | 무접촉(신규 유닛) | |
||||
|
| `dpworld-network-apply-hardened.sh` | B 네트워크 | 라이브 적용 시 필요 | 무접촉(파일) | |
||||
|
| `dpworld-network-apply.service.d/20-hardened.conf` | B 네트워크 | **③에서만** | ★ override | |
||||
|
| `dpworld-network-seed.service.d/20-hardened.conf` | B 네트워크 | **③에서만** | ★ override | |
||||
|
| `dpworld-network-apply-ondemand.conf` | B 네트워크 | **③에서만** | ★ override | |
||||
|
| `dpworld-net-recover.service` | B 네트워크 | 옵션(복구 안전망) | 무접촉(신규 유닛) | |
||||
|
| `dpworld-ap-apply.sh` | C AP | AP 시 필수 | 무접촉(파일) | |
||||
|
| `dpworld-ap-apply.service` | C AP | AP 시 필수 | 무접촉(신규 유닛) | |
||||
|
| `dpworld-hostapd-ap0.service` | C AP | AP 시 필수 | 무접촉(신규 유닛) | |
||||
|
| `dpworld-udhcpd-ap0.service` | C AP | AP 시 필수 | 무접촉(신규 유닛) | |
||||
|
| `dpworld-ap-seed.service` | C AP | 옵션(재부팅 후 AP 자동복구) | 무접촉(신규 유닛) | |
||||
|
|
||||
|
> ★ 표시(드롭인 3종)만이 펌웨어 소유 유닛을 건드립니다 = "충돌"의 전부. 이 3개를 빼면 펌웨어/`dpworldapp` 자원은 **0개** 건드리지 않습니다. |
||||
|
> *`nginx.conf`는 v1.12.1에서 패키지에서 제거됨(§2) — deploy/ 구성요소 아님.* |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 부록: dpworldapp과의 공유·격리 요약 |
||||
|
|
||||
|
- **공유(의도된 연동)**: DB `/home/root/db`, 네트워크 렌더 `/home/root/network` — 웹과 `dpworldapp`이 동일 파일 계약(byte 호환)을 공유. 웹이 편집·저장하고 `dpworldapp`이 소비하는 구조(충돌 아님). |
||||
|
- **격리(신규, 무접촉)**: 웹 앱(`:9090`), AP(`ap0` 전용 + 방화벽), 복구 유닛, 텔레메트리 Uplink(OS 호스트 라우트만) — 전부 신규 자원. |
||||
|
- **유일한 접점**: `dpworld-network-apply.service`/`-seed.service`(펌웨어 소유) — 라이브 적용을 위해 ③ 방식에서만 override. ① 또는 ②를 택하면 이 접점도 사라집니다. |
||||
@ -0,0 +1,381 @@ |
|||||
|
# src/network/uplink.py |
||||
|
"""Telemetry uplink (Ethernet 전환) — dpworldapp 클라우드 텔레메트리(protocol/update/rtcm)를 |
||||
|
선택 인터페이스로 보내는 /32 host-route 생성·검증·상태 (spec 2026-06-25). |
||||
|
dpworldapp 소유 서버 IP는 읽기 전용; 선택은 웹 소유 uplink_config 키.""" |
||||
|
import re |
||||
|
from network.netmodel import _s, _ip_u32, gateway_set |
||||
|
from network.renderer import _in_subnet |
||||
|
|
||||
|
CLOUD_TELEMETRY_KEYS = ( |
||||
|
("protocol_server_ip", "protocol_server_port"), |
||||
|
("update_server_ip", "update_server_port"), |
||||
|
("rtcm_server_ip", "rtcm_server_port"), |
||||
|
) |
||||
|
# UI display labels for the cloud telemetry servers — TIOT=protocol stream, |
||||
|
# Update=firmware update, DGPS=RTCM corrections. Display-only; never affects routing/targets. |
||||
|
CLOUD_TELEMETRY_LABELS = { |
||||
|
"protocol_server_ip": "TIOT", |
||||
|
"update_server_ip": "Update", |
||||
|
"rtcm_server_ip": "DGPS", |
||||
|
} |
||||
|
UPLINK_IFACES = ("wlan0", "eth0", "eth1") |
||||
|
DEFAULT_IFACE = "wlan0" # 라우트 없음 = 현 default route 거동 |
||||
|
|
||||
|
|
||||
|
def _plen_to_mask(plen): |
||||
|
"""prefix-length (int 0-32) → dotted-quad netmask 문자열. stdlib만 사용(ipaddress 불필요).""" |
||||
|
n = (0xffffffff << (32 - int(plen))) & 0xffffffff |
||||
|
return "%d.%d.%d.%d" % (n >> 24, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff) |
||||
|
|
||||
|
|
||||
|
def live_subnets(runner, ifaces=UPLINK_IFACES): |
||||
|
"""{ifc: (ip, netmask)} — 현재 IPv4 주소가 살아 있는 인터페이스 맵 (DHCP 포함). |
||||
|
'ip -o -4 addr show dev <ifc>' 파싱; inet <a.b.c.d>/<plen> 토큰 추출·변환. |
||||
|
fail-safe: ifc별 try/except — rc≠0·no-match·예외 시 해당 ifc 생략, raise 없음. |
||||
|
on-link 탐지 보강에만 사용(렌더·static IP 경로 미영향, U3).""" |
||||
|
out = {} |
||||
|
for ifc in ifaces: |
||||
|
try: |
||||
|
rc, text = runner(["ip", "-o", "-4", "addr", "show", "dev", ifc], 5) |
||||
|
if rc != 0: |
||||
|
continue |
||||
|
m = re.search(r'\binet\s+(\d+\.\d+\.\d+\.\d+)/(\d+)\b', text or "") |
||||
|
if not m: |
||||
|
continue |
||||
|
out[ifc] = (m.group(1), _plen_to_mask(int(m.group(2)))) |
||||
|
except Exception: # noqa: BLE001 — fail-safe: ifc 생략, 절대 raise 안 함 |
||||
|
continue |
||||
|
return out |
||||
|
|
||||
|
|
||||
|
def _telemetry_servers(device_config): |
||||
|
"""[(ip, [ip_key, ...]), ...] — deduped cloud telemetry IPs in CLOUD_TELEMETRY_KEYS first-seen |
||||
|
order, each paired with EVERY key that targets it. Empty/invalid IPs (_ip_u32 0/None) excluded. |
||||
|
SINGLE source for telemetry_targets (the IP list) and telemetry_target_labels (joined labels) — |
||||
|
so their IP sets can never drift (fix-the-twin: one filter, not two copies).""" |
||||
|
dc = device_config if isinstance(device_config, dict) else {} |
||||
|
order, by_ip = [], {} |
||||
|
for ip_key, _port_key in CLOUD_TELEMETRY_KEYS: |
||||
|
ip = _s(dc.get(ip_key)) |
||||
|
if ip and _ip_u32(ip) not in (0, None): |
||||
|
if ip not in by_ip: |
||||
|
by_ip[ip] = [] |
||||
|
order.append(ip) |
||||
|
by_ip[ip].append(ip_key) |
||||
|
return [(ip, by_ip[ip]) for ip in order] |
||||
|
|
||||
|
|
||||
|
def telemetry_targets(device_config): |
||||
|
"""클라우드 텔레메트리 서버 IP(읽기 전용, 빈/비파싱 제외·중복 제거·순서 보존). |
||||
|
로컬 opc_ua/modbus·lte_server 는 미포함(CLOUD_TELEMETRY_KEYS 한정). _telemetry_servers 단일 출처.""" |
||||
|
return [ip for ip, _keys in _telemetry_servers(device_config)] |
||||
|
|
||||
|
|
||||
|
def telemetry_target_labels(device_config): |
||||
|
"""{ip: label}. 같은 IP에 여러 서버(예: protocol=TIOT, rtcm=DGPS — 같은 호스트·다른 포트)면 라벨 join |
||||
|
('TIOT / DGPS') — /32 host-route 는 per-IP(포트 무관)라 한 라우트가 둘 다 커버 → 둘 다 표기. 표시 전용. |
||||
|
_telemetry_servers 단일 출처라 telemetry_targets 와 키 집합이 구조적으로 동일(drift 불가).""" |
||||
|
out = {} |
||||
|
for ip, keys in _telemetry_servers(device_config): |
||||
|
labels = [] |
||||
|
for k in keys: |
||||
|
lbl = CLOUD_TELEMETRY_LABELS.get(k, k) |
||||
|
if lbl not in labels: |
||||
|
labels.append(lbl) |
||||
|
out[ip] = " / ".join(labels) |
||||
|
return out |
||||
|
|
||||
|
|
||||
|
def selected_iface(uplink_config): |
||||
|
"""uplink_config.telemetry_iface ∈ UPLINK_IFACES. 그 외/부재 → DEFAULT_IFACE.""" |
||||
|
uc = uplink_config if isinstance(uplink_config, dict) else {} |
||||
|
ifc = _s(uc.get("telemetry_iface")) |
||||
|
return ifc if ifc in UPLINK_IFACES else DEFAULT_IFACE |
||||
|
|
||||
|
|
||||
|
def attach(intent, device_config, uplink_config, live=None): |
||||
|
"""intent['_uplink'] = {'iface','targets','labels','live_subnets'} 주입(in-memory). |
||||
|
persist(network_config.json)엔 미기록(§5.2). 렌더러/verifier/watchdog 공용 단일 출처. |
||||
|
device-network apply 도 _write_renders 전 호출(라우트 wipe 방지). |
||||
|
live: live_subnets() 결과({ifc:(ip,mask)}) — None/미지정 시 {} (기존 호출자 무영향, U3).""" |
||||
|
intent["_uplink"] = {"iface": selected_iface(uplink_config), |
||||
|
"targets": telemetry_targets(device_config), |
||||
|
"labels": telemetry_target_labels(device_config), |
||||
|
"live_subnets": live or {}} |
||||
|
|
||||
|
|
||||
|
def validate_selection(iface, intent): |
||||
|
"""선택 검증 — errors 목록. iface enum + (비-wlan0면) static-gateway 존재. |
||||
|
intent 는 device 필드가 채워진 상태(net_dir baseline 또는 device_config 유도).""" |
||||
|
if iface not in UPLINK_IFACES: |
||||
|
return [f"telemetry_iface must be one of {UPLINK_IFACES}"] |
||||
|
if iface != DEFAULT_IFACE: |
||||
|
c = intent.get(iface) or {} |
||||
|
if not gateway_set(c): |
||||
|
return ["no_gateway"] |
||||
|
return [] |
||||
|
|
||||
|
|
||||
|
def _on_link_ifaces(ip, intent): |
||||
|
"""All UPLINK_IFACES whose subnet contains `ip` (UPLINK_IFACES order). |
||||
|
Primary source: CONFIGURED ip+netmask from the intent (static ifaces). |
||||
|
Live fallback (U3): when configured ip/netmask is empty (DHCP iface), falls back to |
||||
|
intent['_uplink']['live_subnets'][ifc] supplied by callers with a runner (attach live= kwarg). |
||||
|
Absent live_subnets → today's behavior (DHCP iface never matches). Fail-safe: a failed |
||||
|
live_subnets read degrades to {} and never installs a wrong /32. An iface with empty |
||||
|
ip/netmask (from both sources) never matches (empty netmask would read as /0 = match-all). |
||||
|
length>1 result means overlapping subnets (misconfiguration — selection_advisories surfaces it).""" |
||||
|
live = (intent.get("_uplink") or {}).get("live_subnets") or {} |
||||
|
out = [] |
||||
|
for ifc in UPLINK_IFACES: |
||||
|
c = intent.get(ifc) or {} |
||||
|
cip, mask = _s(c.get("ip")), _s(c.get("netmask")) |
||||
|
if not (cip and mask): |
||||
|
# DHCP iface(또는 미설정): live 주소로 폴백 — 부재 시 ""/"" → 여전히 미매칭(fail-safe) |
||||
|
lr = live.get(ifc) |
||||
|
if lr: |
||||
|
cip, mask = lr |
||||
|
if cip and mask and _in_subnet(ip, cip, mask): |
||||
|
out.append(ifc) |
||||
|
return out |
||||
|
|
||||
|
|
||||
|
def _on_link_iface(ip, intent): |
||||
|
"""First interface `ip` is on-link via (configured), or None. Deterministic by UPLINK_IFACES |
||||
|
order. Configured, not live route proof (see _on_link_ifaces).""" |
||||
|
ifaces = _on_link_ifaces(ip, intent) |
||||
|
return ifaces[0] if ifaces else None |
||||
|
|
||||
|
|
||||
|
def selection_advisories(iface, intent): |
||||
|
"""Soft, English pre-apply advisories for selecting `iface` (the candidate). PURE — no runner, |
||||
|
no I/O. Reads the iface-independent targets/labels from intent['_uplink'] (attach()-populated; |
||||
|
same source as plan_routes/status) and per-iface subnets from intent[<ifc>]. Returns [] when |
||||
|
'_uplink' is absent. Rules (spec 2026-06-26 §5.2), in emitted order: 0 overlap-subnet, |
||||
|
1 local-via-other-iface, 2 all-local (non-wlan0), 3 wlan0 revert.""" |
||||
|
up = intent.get("_uplink") |
||||
|
if not isinstance(up, dict): |
||||
|
return [] # spec §5.2: absent _uplink → [] for EVERY iface (incl. wlan0 — no targets to revert) |
||||
|
targets = up.get("targets") or [] |
||||
|
labels = up.get("labels") or {} |
||||
|
out = [] |
||||
|
# rule 0: overlapping-subnet misconfiguration (a target on-link via >1 iface) |
||||
|
for ip in targets: |
||||
|
ifs = _on_link_ifaces(ip, intent) |
||||
|
if len(ifs) > 1: |
||||
|
lbl = labels.get(ip) or ip |
||||
|
out.append("%s %s matches more than one interface subnet (%s) — check the interface " |
||||
|
"configuration; the displayed interface is the first match." |
||||
|
% (lbl, ip, ", ".join(ifs))) |
||||
|
# rule 1: target on-link via a DIFFERENT interface than the candidate |
||||
|
for ip in targets: |
||||
|
y = _on_link_iface(ip, intent) |
||||
|
if y is not None and y != iface: |
||||
|
lbl = labels.get(ip) or ip |
||||
|
out.append("%s %s stays on %s (local LAN) — selecting %s won't move it." |
||||
|
% (lbl, ip, y, iface)) |
||||
|
# rules 2/3 (mutually exclusive on iface) |
||||
|
if iface != DEFAULT_IFACE: |
||||
|
steerable = [ip for ip in targets if _on_link_iface(ip, intent) is None] |
||||
|
if targets and not steerable: |
||||
|
out.append("All telemetry servers are on local LANs; selecting %s installs no " |
||||
|
"host-routes (this may be correct)." % iface) |
||||
|
else: |
||||
|
out.append("Reverts to the system default route and removes the host-routes.") |
||||
|
return out |
||||
|
|
||||
|
|
||||
|
def _on_link_any(ip, intent): |
||||
|
"""True if `ip` is on-link via ANY configured interface — directly reachable without a gateway. |
||||
|
Such a target MUST NOT be host-routed via a foreign gateway: doing so black-holes a |
||||
|
locally-reachable host (e.g. selecting eth0 for a 192.168.55.x server on-link via eth1) and |
||||
|
breaks asymmetric routing to/from it (management lockout, 2026-06-26).""" |
||||
|
return bool(_on_link_ifaces(ip, intent)) |
||||
|
|
||||
|
|
||||
|
def plan_routes(intent): |
||||
|
"""(routes, skips). intent['_uplink']={iface,targets} 기준. |
||||
|
iface==DEFAULT_IFACE → ([], []). iface 게이트웨이 없음 → ([], ['no_gateway']). |
||||
|
타겟이 ★어느 인터페이스든 on-link(서브넷 내) → 그 타겟 skip('local:<ip>') — 선택 iface 게이트웨이로 |
||||
|
/32 깔면 on-link 호스트 블랙홀(타 iface 로컬 서버 + 관리 락아웃, 2026-06-26). 그 외 타겟별 /32 route dict. |
||||
|
static-gateway 한정 — Gateway=_dhcp4 미사용(systemd 버전 의존 회피).""" |
||||
|
up = intent.get("_uplink") or {} |
||||
|
iface = up.get("iface") or DEFAULT_IFACE |
||||
|
targets = up.get("targets") or [] |
||||
|
if iface == DEFAULT_IFACE: |
||||
|
return [], [] |
||||
|
c = intent.get(iface) or {} |
||||
|
if not gateway_set(c): |
||||
|
return [], ["no_gateway"] |
||||
|
gw = _s(c.get("gateway")) |
||||
|
routes, skips = [], [] |
||||
|
for ip in targets: |
||||
|
if _on_link_any(ip, intent): # on-link via ANY iface → no /32 (would black-hole; see _on_link_any) |
||||
|
skips.append(f"local:{ip}") |
||||
|
continue |
||||
|
routes.append({"dest": ip, "gateway": gw}) |
||||
|
return routes, skips |
||||
|
|
||||
|
|
||||
|
def render_routes(intent, iface): |
||||
|
"""선택 iface .network 에 붙일 [Route] 텍스트. render_network_file 이 iface==_uplink.iface 일 때만 호출.""" |
||||
|
routes, _skips = plan_routes(intent) |
||||
|
out = "" |
||||
|
for r in routes: |
||||
|
out += f"\n[Route]\nDestination={r['dest']}/32\nGateway={r['gateway']}\n" |
||||
|
return out |
||||
|
|
||||
|
|
||||
|
def _routes_show(runner): |
||||
|
rc, out = runner(["ip", "route", "show"], 5) |
||||
|
return out if rc == 0 else "" |
||||
|
|
||||
|
|
||||
|
def _installed_route(routes, ip): |
||||
|
"""ip route show 에서 host <ip> 라인 파싱 → {'dev','gateway'} 또는 None (Codex #6: dev+gateway). |
||||
|
/32 host 는 suffix 없이 '<ip> via <gw> dev <ifc>' 로 표기.""" |
||||
|
for ln in routes.splitlines(): |
||||
|
toks = ln.split() |
||||
|
if not toks or toks[0] != ip: |
||||
|
continue |
||||
|
dev = toks[toks.index("dev") + 1] if "dev" in toks else "" |
||||
|
gw = toks[toks.index("via") + 1] if "via" in toks else "" |
||||
|
return {"dev": dev, "gateway": gw} |
||||
|
return None |
||||
|
|
||||
|
|
||||
|
def stale_routes(targets, routes): |
||||
|
"""클라우드 타겟(목록)이 eth0/eth1 에 /32 로 잔존하면 [{ip,dev}] 반환 (Codex #5 — wlan0 선택 시 비어야 ok).""" |
||||
|
out = [] |
||||
|
for ip in targets: |
||||
|
r = _installed_route(routes, ip) |
||||
|
if r and r["dev"] in ("eth0", "eth1"): |
||||
|
out.append({"ip": ip, "dev": r["dev"]}) |
||||
|
return out |
||||
|
|
||||
|
|
||||
|
def _addr_to_iface(intent): |
||||
|
"""intent per-iface ip → iface 이름. egress local IP 를 iface 로 매핑(정직 표기).""" |
||||
|
m = {} |
||||
|
for ifc in UPLINK_IFACES: |
||||
|
ip = _s((intent.get(ifc) or {}).get("ip")) |
||||
|
if ip: |
||||
|
m[ip] = ifc |
||||
|
return m |
||||
|
|
||||
|
|
||||
|
def observe_egress(targets, runner): |
||||
|
"""ss -tn(없으면 /proc/net/tcp 폴백)로 각 target 으로의 ESTAB 소켓 local IP 관측. {target_ip: local_ip|None}. |
||||
|
dpworldapp 실제 송신 경로의 ground truth — '적용됨'을 'working' 으로 위장 금지.""" |
||||
|
res = {ip: None for ip in targets} |
||||
|
rc, out = runner(["ss", "-tn"], 5) |
||||
|
if rc != 0: |
||||
|
return _egress_from_proc(targets, runner, res) |
||||
|
for ln in out.splitlines(): |
||||
|
toks = ln.split() |
||||
|
if len(toks) < 5 or toks[0] != "ESTAB": |
||||
|
continue |
||||
|
peer, local = toks[-1], toks[-2] |
||||
|
if ":" in peer and ":" in local: |
||||
|
pip = peer.rsplit(":", 1)[0] |
||||
|
if pip in res: |
||||
|
res[pip] = local.rsplit(":", 1)[0] |
||||
|
return res |
||||
|
|
||||
|
|
||||
|
def _egress_from_proc(targets, runner, res): |
||||
|
"""ss 부재(busybox) 폴백 — /proc/net/tcp hex 파싱. 실패 시 모두 None 유지(거짓 green 금지).""" |
||||
|
rc, out = runner(["cat", "/proc/net/tcp"], 5) |
||||
|
if rc != 0: |
||||
|
return res |
||||
|
want = {_ip_to_hex_le(ip): ip for ip in targets} |
||||
|
for ln in out.splitlines()[1:]: |
||||
|
f = ln.split() |
||||
|
if len(f) < 4 or f[3] != "01": # 01 = ESTABLISHED |
||||
|
continue |
||||
|
rem_hex = f[2].split(":")[0] |
||||
|
if rem_hex in want: |
||||
|
loc_hex = f[1].split(":")[0] |
||||
|
res[want[rem_hex]] = _hex_le_to_ip(loc_hex) |
||||
|
return res |
||||
|
|
||||
|
|
||||
|
def _ip_to_hex_le(ip): |
||||
|
parts = ip.split(".") |
||||
|
if len(parts) != 4: |
||||
|
return "" |
||||
|
return "".join(f"{int(p):02X}" for p in reversed(parts)) |
||||
|
|
||||
|
|
||||
|
def _hex_le_to_ip(h): |
||||
|
try: |
||||
|
b = [int(h[i:i + 2], 16) for i in (6, 4, 2, 0)] |
||||
|
return ".".join(str(x) for x in b) |
||||
|
except (ValueError, IndexError): |
||||
|
return "" |
||||
|
|
||||
|
|
||||
|
def reachability(targets, iface, gateway, runner): |
||||
|
"""비침습 도달성 — ip route show 로 <ip> via <gateway> dev <iface> 설치 확인(dev+gateway 둘 다 — Codex #6).""" |
||||
|
routes = _routes_show(runner) |
||||
|
out = [] |
||||
|
for ip in targets: |
||||
|
r = _installed_route(routes, ip) |
||||
|
out.append({"ip": ip, |
||||
|
"route_installed": bool(r) and r["dev"] == iface and r["gateway"] == gateway}) |
||||
|
return out |
||||
|
|
||||
|
|
||||
|
def status(intent, runner): |
||||
|
"""3단 정직 상태. {iface, per_target:[{ip,label,on_link_iface,route_installed,local,egress_iface}], rollup}. |
||||
|
label = 서버 표시명(TIOT/Update/DGPS, 표시 전용). rollup ∈ {unset, pending, egress, unreachable, stale} |
||||
|
(언어중립 코드 — 프론트 영문 매핑). 'working' 절대 주장 X. |
||||
|
라우트 판정은 dev+gateway 둘 다(Codex #6). wlan0 선택 시 eth0/eth1 stale /32 잔존 검사(Codex #5). |
||||
|
선택 iface 서브넷 내 타겟(local)은 /32 불요(on-link 직접 도달) → route_installed 거짓표시 금지(Codex).""" |
||||
|
up = intent.get("_uplink") or {} |
||||
|
iface = up.get("iface") or DEFAULT_IFACE |
||||
|
targets = up.get("targets") or [] |
||||
|
labels = up.get("labels") or {} |
||||
|
routes = _routes_show(runner) |
||||
|
egress = observe_egress(targets, runner) |
||||
|
a2i = _addr_to_iface(intent) |
||||
|
gw = _s((intent.get(iface) or {}).get("gateway")) |
||||
|
# local targets (in the selected iface subnet) need NO /32 route — on-link direct. Use plan_routes' |
||||
|
# classification so status agrees with what apply installs (Codex: don't show "unreachable/missing" |
||||
|
# for a target deliberately skipped as on-link-local). |
||||
|
local_ips = {s.split(":", 1)[1] for s in plan_routes(intent)[1] if s.startswith("local:")} |
||||
|
per = [] |
||||
|
for ip in targets: |
||||
|
if ip in local_ips: |
||||
|
installed, is_local = True, True # on-link, no host-route needed (direct) |
||||
|
elif iface == DEFAULT_IFACE: |
||||
|
installed, is_local = False, False |
||||
|
else: |
||||
|
r = _installed_route(routes, ip) |
||||
|
installed, is_local = bool(r) and r["dev"] == iface and r["gateway"] == gw, False |
||||
|
egr = egress.get(ip) |
||||
|
per.append({"ip": ip, "label": labels.get(ip), "on_link_iface": _on_link_iface(ip, intent), |
||||
|
"route_installed": installed, "local": is_local, |
||||
|
"egress_iface": a2i.get(egr) if egr else None}) |
||||
|
stale = stale_routes(targets, routes) if iface == DEFAULT_IFACE else [] |
||||
|
return {"iface": iface, "per_target": per, "rollup": _rollup(iface, per, stale)} |
||||
|
|
||||
|
|
||||
|
def _rollup(iface, per, stale): |
||||
|
"""언어중립 status 코드(프론트가 영문 표시로 매핑) — 영문 UI에 한국어 누출 방지. |
||||
|
honesty: 'working' 류 추론 green 없음. egress=실제 송신 관측만 confirmed.""" |
||||
|
if iface == DEFAULT_IFACE: |
||||
|
return "stale" if stale else "unset" |
||||
|
if not per: |
||||
|
return "unset" |
||||
|
if any(p["egress_iface"] == iface for p in per): |
||||
|
# Genuine unreachable: non-local target with no route AND no observed egress — don't mask |
||||
|
genuinely_unreachable = any( |
||||
|
not p["local"] and not p["route_installed"] and p["egress_iface"] is None |
||||
|
for p in per |
||||
|
) |
||||
|
if not genuinely_unreachable: |
||||
|
return "egress" |
||||
|
if all(p["route_installed"] for p in per): |
||||
|
return "pending" |
||||
|
return "unreachable" |
||||
@ -1,207 +0,0 @@ |
|||||
/** |
|
||||
* network.js — Network Configuration Page |
|
||||
* |
|
||||
* Server endpoint settings (IP + Port for all server types). |
|
||||
* Uses shared IP octet component. |
|
||||
*/ |
|
||||
|
|
||||
import { state, bindIpGroup, bindInput } from '../state.js'; |
|
||||
import { escapeHtml } from '../utils.js'; |
|
||||
import { isValidIP, isValidPort } from '../validator.js'; |
|
||||
import { renderIpOctets, setupIpOctets, collectIpValue } from '../components/ip-input.js'; |
|
||||
import { setupPortInputs } from '../components/port-input.js'; |
|
||||
|
|
||||
export function renderNetworkPage(container) { |
|
||||
const device = state.device || {}; |
|
||||
const server = device.server || {}; |
|
||||
|
|
||||
const servers = [ |
|
||||
{ label: 'TIOT Server', ipKey: 'protocol_server_ip', portKey: 'protocol_server_port' }, |
|
||||
{ label: 'Update Server', ipKey: 'update_server_ip', portKey: 'update_server_port' }, |
|
||||
{ label: 'DGPS Server', ipKey: 'rtcm_server_ip', portKey: 'rtcm_server_port' }, |
|
||||
{ label: 'OPC-UA Server', ipKey: 'opc_ua_server_ip', portKey: 'opc_ua_server_port' }, |
|
||||
{ label: 'Modbus Server', ipKey: 'modbus_server_ip', portKey: 'modbus_server_port' }, |
|
||||
{ label: 'LTE Server', ipKey: 'lte_server_ip', portKey: 'lte_server_port' }, |
|
||||
]; |
|
||||
|
|
||||
container.innerHTML = ` |
|
||||
<div class="page-header"> |
|
||||
<h1 class="page-header__title">🌐 Network Settings</h1> |
|
||||
<p class="page-header__desc">Configure IP addresses and ports for each server.</p> |
|
||||
</div> |
|
||||
|
|
||||
<div class="card" style="width: fit-content; margin-left: auto; margin-right: auto;"> |
|
||||
<div class="card__header"> |
|
||||
<h2 class="card__title"><span class="card__title-icon">🖥️</span> Server Endpoints</h2> |
|
||||
</div> |
|
||||
<div class="data-table-wrapper"> |
|
||||
<table class="data-table" style="width: auto;"> |
|
||||
<thead> |
|
||||
<tr> |
|
||||
<th style="width: 160px;">Server Type</th> |
|
||||
<th>IP Address</th> |
|
||||
<th style="width: 100px;">Port</th> |
|
||||
</tr> |
|
||||
</thead> |
|
||||
<tbody> |
|
||||
${servers.map(s => ` |
|
||||
<tr> |
|
||||
<td style="font-weight: 500; color: var(--text-primary);">${s.label}</td> |
|
||||
<td>${renderIpOctets(s.ipKey, server[s.ipKey] || '')}</td> |
|
||||
<td><input class="form-input form-input--mono" id="${s.portKey}" value="${escapeHtml(server[s.portKey])}" placeholder="0"></td> |
|
||||
</tr> |
|
||||
`).join('')}
|
|
||||
</tbody> |
|
||||
</table> |
|
||||
</div> |
|
||||
</div> |
|
||||
`;
|
|
||||
|
|
||||
// Setup IP octet event listeners
|
|
||||
setupIpOctets(container); |
|
||||
|
|
||||
setupPortInputs(container); |
|
||||
|
|
||||
// Register data collector
|
|
||||
window.__pageCollectors.network = collectNetworkData; |
|
||||
} |
|
||||
|
|
||||
function collectNetworkData() { |
|
||||
if (!state.device) state.device = {}; |
|
||||
|
|
||||
const ipKeys = [ |
|
||||
'protocol_server_ip', 'update_server_ip', 'rtcm_server_ip', |
|
||||
'opc_ua_server_ip', 'modbus_server_ip', 'lte_server_ip', |
|
||||
]; |
|
||||
const portKeys = [ |
|
||||
'protocol_server_port', 'update_server_port', 'rtcm_server_port', |
|
||||
'opc_ua_server_port', 'modbus_server_port', 'lte_server_port', |
|
||||
]; |
|
||||
|
|
||||
const server = {}; |
|
||||
|
|
||||
ipKeys.forEach(ipKey => { |
|
||||
server[ipKey] = collectIpValue(ipKey); |
|
||||
}); |
|
||||
|
|
||||
portKeys.forEach(key => { |
|
||||
const el = document.getElementById(key); |
|
||||
if (el) { |
|
||||
server[key] = el.value.trim(); |
|
||||
} |
|
||||
}); |
|
||||
|
|
||||
state.device.server = server; |
|
||||
} |
|
||||
|
|
||||
// ─── Server definitions (shared) ────────────────────────────
|
|
||||
const SERVERS = [ |
|
||||
{ label: 'TIOT Server', ipKey: 'protocol_server_ip', portKey: 'protocol_server_port' }, |
|
||||
{ label: 'Update Server', ipKey: 'update_server_ip', portKey: 'update_server_port' }, |
|
||||
{ label: 'DGPS Server', ipKey: 'rtcm_server_ip', portKey: 'rtcm_server_port' }, |
|
||||
{ label: 'OPC-UA Server', ipKey: 'opc_ua_server_ip', portKey: 'opc_ua_server_port' }, |
|
||||
{ label: 'Modbus Server', ipKey: 'modbus_server_ip', portKey: 'modbus_server_port' }, |
|
||||
{ label: 'LTE Server', ipKey: 'lte_server_ip', portKey: 'lte_server_port' }, |
|
||||
]; |
|
||||
|
|
||||
// ─── New Page Interface ─────────────────────────────────────
|
|
||||
const networkPage = { |
|
||||
render(container) { |
|
||||
const device = state.device || {}; |
|
||||
const server = device.server || {}; |
|
||||
|
|
||||
container.innerHTML = ` |
|
||||
<div class="page-header"> |
|
||||
<h1 class="page-header__title">🌐 Network Settings</h1> |
|
||||
<p class="page-header__desc">Configure IP addresses and ports for each server.</p> |
|
||||
</div> |
|
||||
|
|
||||
<div class="card" style="width: fit-content; margin-left: auto; margin-right: auto;"> |
|
||||
<div class="card__header"> |
|
||||
<h2 class="card__title"><span class="card__title-icon">🖥️</span> Server Endpoints</h2> |
|
||||
</div> |
|
||||
<div class="data-table-wrapper"> |
|
||||
<table class="data-table" style="width: auto;"> |
|
||||
<thead> |
|
||||
<tr> |
|
||||
<th style="width: 160px;">Server Type</th> |
|
||||
<th>IP Address</th> |
|
||||
<th style="width: 100px;">Port</th> |
|
||||
</tr> |
|
||||
</thead> |
|
||||
<tbody> |
|
||||
${SERVERS.map(s => ` |
|
||||
<tr> |
|
||||
<td style="font-weight: 500; color: var(--text-primary);">${s.label}</td> |
|
||||
<td>${renderIpOctets(s.ipKey, server[s.ipKey] || '')}</td> |
|
||||
<td><input class="form-input form-input--mono" id="${s.portKey}" value="${escapeHtml(server[s.portKey])}" placeholder="0"></td> |
|
||||
</tr> |
|
||||
`).join('')}
|
|
||||
</tbody> |
|
||||
</table> |
|
||||
</div> |
|
||||
</div> |
|
||||
`;
|
|
||||
}, |
|
||||
|
|
||||
mount(container) { |
|
||||
const server = state.device?.server || {}; |
|
||||
|
|
||||
// Setup IP octet event listeners (auto-tab, paste, numeric enforcement)
|
|
||||
setupIpOctets(container); |
|
||||
setupPortInputs(container); |
|
||||
|
|
||||
// Reactive bindings: IP groups → state
|
|
||||
SERVERS.forEach(s => { |
|
||||
bindIpGroup(container, s.ipKey, |
|
||||
() => server[s.ipKey] || '', |
|
||||
(val) => { |
|
||||
if (!state.device) state.device = {}; |
|
||||
if (!state.device.server) state.device.server = {}; |
|
||||
state.device.server[s.ipKey] = val; |
|
||||
state.isDirty = true; |
|
||||
} |
|
||||
); |
|
||||
}); |
|
||||
|
|
||||
// Reactive bindings: Port inputs → state
|
|
||||
SERVERS.forEach(s => { |
|
||||
bindInput(container, `#${s.portKey}`, |
|
||||
() => server[s.portKey] || '', |
|
||||
(val) => { |
|
||||
if (!state.device) state.device = {}; |
|
||||
if (!state.device.server) state.device.server = {}; |
|
||||
state.device.server[s.portKey] = val.trim(); |
|
||||
state.isDirty = true; |
|
||||
} |
|
||||
); |
|
||||
}); |
|
||||
|
|
||||
// Register legacy collector (R4: __pageCollectors 유지)
|
|
||||
window.__pageCollectors.network = collectNetworkData; |
|
||||
}, |
|
||||
|
|
||||
destroy() { |
|
||||
// No cleanup needed — DOM is replaced by showPage()
|
|
||||
}, |
|
||||
|
|
||||
validate() { |
|
||||
const errors = []; |
|
||||
const server = state.device?.server || {}; |
|
||||
|
|
||||
SERVERS.forEach(s => { |
|
||||
const ip = server[s.ipKey]; |
|
||||
if (ip && ip !== '...' && !isValidIP(ip)) { |
|
||||
errors.push({ field: s.ipKey, page: 'network', message: `${s.label} — Invalid IP format` }); |
|
||||
} |
|
||||
const port = server[s.portKey]; |
|
||||
if (port && !isValidPort(port)) { |
|
||||
errors.push({ field: s.portKey, page: 'network', message: `${s.label} Port — Must be 1–65535` }); |
|
||||
} |
|
||||
}); |
|
||||
|
|
||||
return errors; |
|
||||
}, |
|
||||
}; |
|
||||
|
|
||||
export default networkPage; |
|
||||
@ -1,458 +0,0 @@ |
|||||
/** |
|
||||
* ssid.js — SSID Configuration Page |
|
||||
* |
|
||||
* Manages WiFi SSID list and WiFi connection settings (static/DHCP). |
|
||||
* IP fields use shared octet-split component. |
|
||||
*/ |
|
||||
|
|
||||
import { state, bindIpGroup, bindRadio } from '../state.js'; |
|
||||
import { escapeHtml } from '../utils.js'; |
|
||||
import { isValidIP, isValidCountryCode, normalizeCountryCode } from '../validator.js'; |
|
||||
import { renderIpOctets, setupIpOctets, collectIpValue } from '../components/ip-input.js'; |
|
||||
import { DEFAULTS } from '../constants.js'; |
|
||||
import { COUNTRY_CODES, CONTINENT_GROUPS, FREQUENT_COUNTRY_CODES } from '../country-codes.js'; |
|
||||
|
|
||||
const MAX_SSID_COUNT = 10; |
|
||||
|
|
||||
/** |
|
||||
* Build the grouped <optgroup>/<option> markup for the Country Code <select>: |
|
||||
* a pinned "Frequently used" group above five continent groups (205 codes). |
|
||||
* `selected` is the current stored code; if it is not a recognized code (an |
|
||||
* empty value, or a stale "EU" from the old free-text field), the dropdown |
|
||||
* falls back to the AE default. The `selected` attribute is applied to the |
|
||||
* first occurrence only, so exactly one option is selected. |
|
||||
*/ |
|
||||
function renderCountryOptions(selected) { |
|
||||
const want = (selected || DEFAULTS.WIFI_COUNTRY_CODE).toUpperCase(); |
|
||||
const sel = COUNTRY_CODES[want] ? want : DEFAULTS.WIFI_COUNTRY_CODE; |
|
||||
let selPlaced = false; |
|
||||
|
|
||||
const option = (code) => { |
|
||||
const isSel = !selPlaced && code === sel; |
|
||||
if (isSel) selPlaced = true; |
|
||||
return `<option value="${code}"${isSel ? ' selected' : ''}>` + |
|
||||
`${escapeHtml(code + ' — ' + COUNTRY_CODES[code])}</option>`; |
|
||||
}; |
|
||||
const group = (label, codes) => |
|
||||
`<optgroup label="${escapeHtml(label)}">` + |
|
||||
codes.map(option).join('') + |
|
||||
`</optgroup>`; |
|
||||
|
|
||||
let html = group('Frequently used', FREQUENT_COUNTRY_CODES); |
|
||||
html += CONTINENT_GROUPS.map(g => group(g.region, g.codes)).join(''); |
|
||||
return html; |
|
||||
} |
|
||||
|
|
||||
export function renderSsidPage(container) { |
|
||||
const device = state.device || {}; |
|
||||
const wifi = device.wifi || {}; |
|
||||
const ssidList = device.ssid_list || []; |
|
||||
|
|
||||
container.innerHTML = ` |
|
||||
<div class="page-header"> |
|
||||
<h1 class="page-header__title">📶 WiFi SSID Management</h1> |
|
||||
<p class="page-header__desc">Manage WiFi network list and connection settings.</p> |
|
||||
</div> |
|
||||
|
|
||||
<!-- SSID List --> |
|
||||
<div class="card"> |
|
||||
<div class="card__header"> |
|
||||
<h2 class="card__title"><span class="card__title-icon">📡</span> SSID List</h2> |
|
||||
<button class="btn btn--outline btn--sm" id="ssid-add-btn">+ Add</button> |
|
||||
</div> |
|
||||
<div class="data-table-wrapper"> |
|
||||
<table class="data-table" id="ssid-table"> |
|
||||
<thead> |
|
||||
<tr> |
|
||||
<th>SSID Name</th> |
|
||||
<th>Password</th> |
|
||||
<th>Security</th> |
|
||||
<th class="col-action"></th> |
|
||||
</tr> |
|
||||
</thead> |
|
||||
<tbody id="ssid-tbody"></tbody> |
|
||||
</table> |
|
||||
</div> |
|
||||
<p class="form-hint mt-sm">Up to 10 SSIDs can be registered.</p> |
|
||||
</div> |
|
||||
|
|
||||
<!-- WiFi Connection Settings --> |
|
||||
<div class="card"> |
|
||||
<div class="card__header"> |
|
||||
<h2 class="card__title"><span class="card__title-icon">🔧</span> WiFi Connection Settings</h2> |
|
||||
</div> |
|
||||
|
|
||||
<div class="form-group"> |
|
||||
<label class="form-label">Connection Type</label> |
|
||||
<div class="radio-group"> |
|
||||
<label class="radio-label"> |
|
||||
<input type="radio" name="wifi_type" value="static" ${wifi.type !== 'dhcp' ? 'checked' : ''}> |
|
||||
Static |
|
||||
</label> |
|
||||
<label class="radio-label"> |
|
||||
<input type="radio" name="wifi_type" value="dhcp" ${wifi.type === 'dhcp' ? 'checked' : ''}> |
|
||||
DHCP |
|
||||
</label> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
<div id="wifi-static-fields"> |
|
||||
<div class="form-row"> |
|
||||
<div class="form-group"> |
|
||||
<label class="form-label">IP Address</label> |
|
||||
${renderIpOctets('wifi_ip', wifi.ip)} |
|
||||
</div> |
|
||||
<div class="form-group"> |
|
||||
<label class="form-label">Subnet Mask</label> |
|
||||
${renderIpOctets('wifi_netmask', wifi.netmask)} |
|
||||
</div> |
|
||||
</div> |
|
||||
<div class="form-row"> |
|
||||
<div class="form-group"> |
|
||||
<label class="form-label">Gateway</label> |
|
||||
${renderIpOctets('wifi_gateway', wifi.gateway)} |
|
||||
</div> |
|
||||
<div class="form-group"> |
|
||||
<label class="form-label">DNS 1</label> |
|
||||
${renderIpOctets('wifi_dns1', wifi.dns1)} |
|
||||
</div> |
|
||||
</div> |
|
||||
<div class="form-row"> |
|
||||
<div class="form-group"> |
|
||||
<label class="form-label">DNS 2</label> |
|
||||
${renderIpOctets('wifi_dns2', wifi.dns2)} |
|
||||
</div> |
|
||||
<div class="form-group"></div> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
`;
|
|
||||
|
|
||||
// Setup IP octet event listeners
|
|
||||
setupIpOctets(container); |
|
||||
|
|
||||
// Populate SSID rows
|
|
||||
const tbody = document.getElementById('ssid-tbody'); |
|
||||
ssidList.forEach((s, i) => addSsidRow(tbody, s, i)); |
|
||||
|
|
||||
// Add SSID button (max 10 rows)
|
|
||||
const addBtn = document.getElementById('ssid-add-btn'); |
|
||||
addBtn.addEventListener('click', () => { |
|
||||
if (tbody.querySelectorAll('tr').length >= MAX_SSID_COUNT) return; |
|
||||
addSsidRow(tbody, { ssid: '', password: '', security: 'wpa/wpa2' }, ssidList.length); |
|
||||
updateSsidAddButton(); |
|
||||
}); |
|
||||
updateSsidAddButton(); |
|
||||
|
|
||||
// Static/DHCP toggle
|
|
||||
document.querySelectorAll('input[name="wifi_type"]').forEach(radio => { |
|
||||
radio.addEventListener('change', () => toggleStaticFields()); |
|
||||
}); |
|
||||
toggleStaticFields(); |
|
||||
|
|
||||
// Register data collector
|
|
||||
window.__pageCollectors.ssid = collectSsidData; |
|
||||
} |
|
||||
|
|
||||
function addSsidRow(tbody, data, index) { |
|
||||
const tr = document.createElement('tr'); |
|
||||
tr.innerHTML = ` |
|
||||
<td><input class="form-input" data-field="ssid" value="${escapeHtml(data.ssid)}" placeholder="SSID Name"></td> |
|
||||
<td> |
|
||||
<div class="input-password"> |
|
||||
<input class="form-input" type="password" data-field="password" value="${escapeHtml(data.password)}" placeholder="Password"> |
|
||||
<button class="input-password__toggle" type="button" title="Show password">👁</button> |
|
||||
</div> |
|
||||
</td> |
|
||||
<td> |
|
||||
<select class="form-select" data-field="security"> |
|
||||
<option value="wpa/wpa2" ${data.security === 'wpa/wpa2' || data.security === 'WPA' ? 'selected' : ''}>WPA/WPA2</option> |
|
||||
<option value="WEP" ${data.security === 'WEP' ? 'selected' : ''}>WEP</option> |
|
||||
<option value="Open" ${data.security === 'Open' ? 'selected' : ''}>Open</option> |
|
||||
</select> |
|
||||
</td> |
|
||||
<td class="col-action"> |
|
||||
<button class="btn btn--ghost btn--icon ssid-delete" title="Delete">🗑</button> |
|
||||
</td> |
|
||||
`;
|
|
||||
|
|
||||
// Toggle password visibility
|
|
||||
tr.querySelector('.input-password__toggle').addEventListener('click', (e) => { |
|
||||
const input = tr.querySelector('input[data-field="password"]'); |
|
||||
input.type = input.type === 'password' ? 'text' : 'password'; |
|
||||
e.target.textContent = input.type === 'password' ? '👁' : '🔒'; |
|
||||
}); |
|
||||
|
|
||||
// Delete row
|
|
||||
tr.querySelector('.ssid-delete').addEventListener('click', () => { |
|
||||
tr.remove(); |
|
||||
updateSsidAddButton(); |
|
||||
}); |
|
||||
|
|
||||
tbody.appendChild(tr); |
|
||||
} |
|
||||
|
|
||||
function updateSsidAddButton() { |
|
||||
const btn = document.getElementById('ssid-add-btn'); |
|
||||
const count = document.querySelectorAll('#ssid-tbody tr').length; |
|
||||
if (btn) btn.disabled = count >= MAX_SSID_COUNT; |
|
||||
} |
|
||||
|
|
||||
function toggleStaticFields() { |
|
||||
const isDhcp = document.querySelector('input[name="wifi_type"]:checked')?.value === 'dhcp'; |
|
||||
const fields = document.getElementById('wifi-static-fields'); |
|
||||
if (!fields) return; |
|
||||
fields.querySelectorAll('.form-input').forEach(input => { |
|
||||
input.disabled = isDhcp; |
|
||||
}); |
|
||||
fields.style.opacity = isDhcp ? '0.5' : '1'; |
|
||||
} |
|
||||
|
|
||||
function collectSsidData() { |
|
||||
if (!state.device) state.device = {}; |
|
||||
|
|
||||
// WiFi settings — collect from octet groups.
|
|
||||
// country_code MUST be re-collected here: this object replaces state.device.wifi
|
|
||||
// wholesale, so omitting it would silently drop the legacy Java value on save.
|
|
||||
const wifiType = document.querySelector('input[name="wifi_type"]:checked')?.value || 'static'; |
|
||||
const ccEl = document.getElementById('wifi_country_code'); |
|
||||
const countryCode = normalizeCountryCode( |
|
||||
ccEl ? ccEl.value : (state.device.wifi && state.device.wifi.country_code) |
|
||||
) || DEFAULTS.WIFI_COUNTRY_CODE; |
|
||||
state.device.wifi = { |
|
||||
type: wifiType, |
|
||||
ip: collectIpValue('wifi_ip'), |
|
||||
netmask: collectIpValue('wifi_netmask'), |
|
||||
gateway: collectIpValue('wifi_gateway'), |
|
||||
dns1: collectIpValue('wifi_dns1'), |
|
||||
dns2: collectIpValue('wifi_dns2'), |
|
||||
country_code: countryCode, |
|
||||
}; |
|
||||
|
|
||||
// SSID list
|
|
||||
const rows = document.querySelectorAll('#ssid-tbody tr'); |
|
||||
state.device.ssid_list = Array.from(rows).map(tr => ({ |
|
||||
ssid: tr.querySelector('[data-field="ssid"]')?.value || '', |
|
||||
password: tr.querySelector('[data-field="password"]')?.value || '', |
|
||||
security: tr.querySelector('[data-field="security"]')?.value || 'wpa/wpa2', |
|
||||
})); |
|
||||
} |
|
||||
|
|
||||
// ─── WiFi IP fields ─────────────────────────────────────────
|
|
||||
const WIFI_IP_FIELDS = ['wifi_ip', 'wifi_netmask', 'wifi_gateway', 'wifi_dns1', 'wifi_dns2']; |
|
||||
const WIFI_IP_LABELS = { |
|
||||
wifi_ip: 'WiFi IP', wifi_netmask: 'WiFi Subnet Mask', wifi_gateway: 'WiFi Gateway', |
|
||||
wifi_dns1: 'WiFi DNS 1', wifi_dns2: 'WiFi DNS 2', |
|
||||
}; |
|
||||
const WIFI_STATE_KEYS = { wifi_ip: 'ip', wifi_netmask: 'netmask', wifi_gateway: 'gateway', wifi_dns1: 'dns1', wifi_dns2: 'dns2' }; |
|
||||
|
|
||||
// ─── New Page Interface ─────────────────────────────────────
|
|
||||
const ssidPage = { |
|
||||
render(container) { |
|
||||
const device = state.device || {}; |
|
||||
const wifi = device.wifi || {}; |
|
||||
const ssidList = device.ssid_list || []; |
|
||||
|
|
||||
container.innerHTML = ` |
|
||||
<div class="page-header"> |
|
||||
<h1 class="page-header__title">📶 WiFi SSID Management</h1> |
|
||||
<p class="page-header__desc">Manage WiFi network list and connection settings.</p> |
|
||||
</div> |
|
||||
|
|
||||
<div class="card"> |
|
||||
<div class="card__header"> |
|
||||
<h2 class="card__title"><span class="card__title-icon">📡</span> SSID List</h2> |
|
||||
<button class="btn btn--outline btn--sm" id="ssid-add-btn">+ Add</button> |
|
||||
</div> |
|
||||
<div class="data-table-wrapper"> |
|
||||
<table class="data-table" id="ssid-table"> |
|
||||
<thead> |
|
||||
<tr> |
|
||||
<th>SSID Name</th> |
|
||||
<th>Password</th> |
|
||||
<th>Security</th> |
|
||||
<th class="col-action"></th> |
|
||||
</tr> |
|
||||
</thead> |
|
||||
<tbody id="ssid-tbody"></tbody> |
|
||||
</table> |
|
||||
</div> |
|
||||
<p class="form-hint mt-sm">Up to 10 SSIDs can be registered.</p> |
|
||||
</div> |
|
||||
|
|
||||
<div class="card"> |
|
||||
<div class="card__header"> |
|
||||
<h2 class="card__title"><span class="card__title-icon">🔧</span> WiFi Connection Settings</h2> |
|
||||
</div> |
|
||||
<div class="form-group"> |
|
||||
<label class="form-label">Connection Type</label> |
|
||||
<div class="radio-group"> |
|
||||
<label class="radio-label"> |
|
||||
<input type="radio" name="wifi_type" value="static" ${wifi.type !== 'dhcp' ? 'checked' : ''}> |
|
||||
Static |
|
||||
</label> |
|
||||
<label class="radio-label"> |
|
||||
<input type="radio" name="wifi_type" value="dhcp" ${wifi.type === 'dhcp' ? 'checked' : ''}> |
|
||||
DHCP |
|
||||
</label> |
|
||||
</div> |
|
||||
</div> |
|
||||
<div id="wifi-static-fields"> |
|
||||
<div class="form-row"> |
|
||||
<div class="form-group"> |
|
||||
<label class="form-label">IP Address</label> |
|
||||
${renderIpOctets('wifi_ip', wifi.ip)} |
|
||||
</div> |
|
||||
<div class="form-group"> |
|
||||
<label class="form-label">Subnet Mask</label> |
|
||||
${renderIpOctets('wifi_netmask', wifi.netmask)} |
|
||||
</div> |
|
||||
</div> |
|
||||
<div class="form-row"> |
|
||||
<div class="form-group"> |
|
||||
<label class="form-label">Gateway</label> |
|
||||
${renderIpOctets('wifi_gateway', wifi.gateway)} |
|
||||
</div> |
|
||||
<div class="form-group"> |
|
||||
<label class="form-label">DNS 1</label> |
|
||||
${renderIpOctets('wifi_dns1', wifi.dns1)} |
|
||||
</div> |
|
||||
</div> |
|
||||
<div class="form-row"> |
|
||||
<div class="form-group"> |
|
||||
<label class="form-label">DNS 2</label> |
|
||||
${renderIpOctets('wifi_dns2', wifi.dns2)} |
|
||||
</div> |
|
||||
<div class="form-group"></div> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
<div class="card"> |
|
||||
<div class="card__header"> |
|
||||
<h2 class="card__title"><span class="card__title-icon">🌍</span> Region</h2> |
|
||||
</div> |
|
||||
<div class="form-group"> |
|
||||
<label class="form-label" for="wifi_country_code">Country Code</label> |
|
||||
<select class="form-input" id="wifi_country_code" style="max-width: 300px;"> |
|
||||
${renderCountryOptions(wifi.country_code || DEFAULTS.WIFI_COUNTRY_CODE)} |
|
||||
</select> |
|
||||
<p class="form-hint">WiFi regulatory region. Defaults to AE (United Arab Emirates); dpworldapp applies it to the WiFi module.</p> |
|
||||
</div> |
|
||||
</div> |
|
||||
`;
|
|
||||
}, |
|
||||
|
|
||||
mount(container) { |
|
||||
setupIpOctets(container); |
|
||||
|
|
||||
// Reactive bindings: WiFi type radio
|
|
||||
bindRadio(container, 'wifi_type', |
|
||||
() => (state.device?.wifi?.type || 'static'), |
|
||||
(val) => { |
|
||||
if (!state.device) state.device = {}; |
|
||||
if (!state.device.wifi) state.device.wifi = {}; |
|
||||
state.device.wifi.type = val; |
|
||||
state.isDirty = true; |
|
||||
} |
|
||||
); |
|
||||
|
|
||||
// Reactive bindings: WiFi IP groups
|
|
||||
WIFI_IP_FIELDS.forEach(ipKey => { |
|
||||
const stateKey = WIFI_STATE_KEYS[ipKey]; |
|
||||
bindIpGroup(container, ipKey, |
|
||||
() => state.device?.wifi?.[stateKey] || '', |
|
||||
(val) => { |
|
||||
if (!state.device) state.device = {}; |
|
||||
if (!state.device.wifi) state.device.wifi = {}; |
|
||||
state.device.wifi[stateKey] = val; |
|
||||
state.isDirty = true; |
|
||||
} |
|
||||
); |
|
||||
}); |
|
||||
|
|
||||
// Reactive binding: WiFi country code (<select>)
|
|
||||
const ccEl = container.querySelector('#wifi_country_code'); |
|
||||
if (ccEl) { |
|
||||
ccEl.addEventListener('change', () => { |
|
||||
if (!state.device) state.device = {}; |
|
||||
if (!state.device.wifi) state.device.wifi = {}; |
|
||||
state.device.wifi.country_code = ccEl.value; |
|
||||
state.isDirty = true; |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
// SSID list: populate + event delegation
|
|
||||
const tbody = container.querySelector('#ssid-tbody'); |
|
||||
const ssidList = state.device?.ssid_list || []; |
|
||||
ssidList.forEach((s, i) => addSsidRow(tbody, s, i)); |
|
||||
|
|
||||
// SSID list: reactive sync on input/change/delete
|
|
||||
tbody.addEventListener('input', () => { this._syncSsidList(tbody); }); |
|
||||
tbody.addEventListener('change', () => { this._syncSsidList(tbody); }); |
|
||||
// Sync after row deletion (DOM mutation via click on delete button)
|
|
||||
tbody.addEventListener('click', (e) => { |
|
||||
if (e.target.closest('.ssid-delete')) { |
|
||||
// Delay to let the row be removed first
|
|
||||
setTimeout(() => { this._syncSsidList(tbody); updateSsidAddButton(); }, 0); |
|
||||
} |
|
||||
}); |
|
||||
|
|
||||
// Add button
|
|
||||
const addBtn = container.querySelector('#ssid-add-btn'); |
|
||||
addBtn.addEventListener('click', () => { |
|
||||
if (tbody.querySelectorAll('tr').length >= MAX_SSID_COUNT) return; |
|
||||
addSsidRow(tbody, { ssid: '', password: '', security: 'wpa/wpa2' }, tbody.querySelectorAll('tr').length); |
|
||||
this._syncSsidList(tbody); |
|
||||
updateSsidAddButton(); |
|
||||
}); |
|
||||
updateSsidAddButton(); |
|
||||
|
|
||||
// Static/DHCP toggle
|
|
||||
container.querySelectorAll('input[name="wifi_type"]').forEach(radio => { |
|
||||
radio.addEventListener('change', () => toggleStaticFields()); |
|
||||
}); |
|
||||
toggleStaticFields(); |
|
||||
|
|
||||
// Legacy collector (R4)
|
|
||||
window.__pageCollectors.ssid = collectSsidData; |
|
||||
}, |
|
||||
|
|
||||
_syncSsidList(tbody) { |
|
||||
if (!state.device) state.device = {}; |
|
||||
const rows = tbody.querySelectorAll('tr'); |
|
||||
state.device.ssid_list = Array.from(rows).map(tr => ({ |
|
||||
ssid: tr.querySelector('[data-field="ssid"]')?.value || '', |
|
||||
password: tr.querySelector('[data-field="password"]')?.value || '', |
|
||||
security: tr.querySelector('[data-field="security"]')?.value || 'wpa/wpa2', |
|
||||
})); |
|
||||
state.isDirty = true; |
|
||||
}, |
|
||||
|
|
||||
destroy() {}, |
|
||||
|
|
||||
validate() { |
|
||||
const errors = []; |
|
||||
const wifi = state.device?.wifi || {}; |
|
||||
// Only validate IP fields when static mode
|
|
||||
if (wifi.type !== 'dhcp') { |
|
||||
WIFI_IP_FIELDS.forEach(ipKey => { |
|
||||
const stateKey = WIFI_STATE_KEYS[ipKey]; |
|
||||
const val = wifi[stateKey]; |
|
||||
if (val && val !== '...' && !isValidIP(val)) { |
|
||||
errors.push({ field: ipKey, page: 'ssid', message: `${WIFI_IP_LABELS[ipKey]} — Invalid IP format` }); |
|
||||
} |
|
||||
}); |
|
||||
} |
|
||||
// Region — WiFi country code: required, exactly 2 uppercase letters
|
|
||||
if (!isValidCountryCode(normalizeCountryCode(wifi.country_code))) { |
|
||||
errors.push({ |
|
||||
field: 'wifi_country_code', page: 'ssid', |
|
||||
message: 'Country Code — must be a 2-letter ISO 3166-1 alpha-2 code (e.g. KR, US, DE, JP, AE)', |
|
||||
}); |
|
||||
} |
|
||||
return errors; |
|
||||
}, |
|
||||
}; |
|
||||
|
|
||||
export default ssidPage; |
|
||||
@ -0,0 +1,347 @@ |
|||||
|
// src/static/js/pages/uplink.js — Telemetry Uplink leaf (Task 11).
|
||||
|
//
|
||||
|
// Routes the cloud telemetry/update/RTCM traffic over the operator-selected
|
||||
|
// interface (wlan0 default · eth0 LTE · eth1 LAN) via per-target /32 host
|
||||
|
// routes — relay/dpworldapp untouched. Self-contained Save&Apply, route-only
|
||||
|
// (not disruptive). All status text honours the honesty invariant: NO inferred
|
||||
|
// "working"; only the five rollup codes per spec are surfaced (color added for
|
||||
|
// legibility, wording unchanged).
|
||||
|
|
||||
|
import { showToast } from '../toast.js'; |
||||
|
import { confirmModal } from '../confirm-modal.js'; |
||||
|
import { escapeHtml } from '../utils.js'; |
||||
|
import { icon } from '../icons.js'; |
||||
|
import { getUplink, postUplink } from '../api.js'; |
||||
|
import { clearDirty } from '../page-dirty.js'; |
||||
|
|
||||
|
const _IFACE_LABELS = { |
||||
|
wlan0: 'Wi-Fi (wlan0)', // default path — labelled by iface, not "current" (it may not be selected)
|
||||
|
eth0: 'Ethernet · LTE (eth0)', |
||||
|
eth1: 'Ethernet · LAN (eth1)', |
||||
|
}; |
||||
|
const _DEFAULT_IFACE = 'wlan0'; // the system-default-route option (no per-server host-routes)
|
||||
|
// Short reason shown as the hint on a DISABLED option.
|
||||
|
const _REASON_LABELS = { |
||||
|
no_gateway: 'No gateway configured', |
||||
|
}; |
||||
|
// Secondary remediation note under a disabled option.
|
||||
|
const _DISABLED_REASONS = { |
||||
|
no_gateway: 'Set a gateway on the matching Ethernet page first.', |
||||
|
}; |
||||
|
// Switching the uplink installs/removes routes immediately, but dpworldapp keeps its existing
|
||||
|
// TCP connections on the old path until they reconnect — the switch only takes full effect once
|
||||
|
// dpworldapp reconnects. This shared phrase is the operator action to apply it right away.
|
||||
|
const _RESTART_ACTION = 'restart dpworldapp or reboot the device'; |
||||
|
// Backend returns language-neutral rollup codes; map to English (UI is English per v1.11.3).
|
||||
|
const _ROLLUP_TEXT = { |
||||
|
'unset': 'Using the system default route (no interface override set).', |
||||
|
'pending': `Applied — ${_RESTART_ACTION} to switch now; existing connections move over when they reconnect.`, |
||||
|
'egress': 'Confirmed — outgoing traffic observed on the selected interface.', |
||||
|
// Backend returns "unreachable" when NOT all host-routes are installed (some missing) — text must say so.
|
||||
|
'unreachable': 'Not fully applied — one or more host-routes are missing; re-apply or check the interface gateway.', |
||||
|
'stale': 'Stale Ethernet routes remain from a previous selection — re-apply to clean up.', |
||||
|
}; |
||||
|
// Rollup code → severity tone for status COLOR only. Honesty: the text above is
|
||||
|
// unchanged; tone just lets an operator tell a healthy uplink from a failing one
|
||||
|
// at a glance (a failure must not look identical to a success).
|
||||
|
const _ROLLUP_TONE = { |
||||
|
unset: 'na', pending: 'warn', egress: 'ok', unreachable: 'error', stale: 'warn', |
||||
|
}; |
||||
|
|
||||
|
let _data = null; // last GET response (uplink_config / options / status)
|
||||
|
let _selected = null; // pending UI selection (string iface)
|
||||
|
let _saving = false; // concurrent click guard
|
||||
|
let _statusTimer = null; // periodic refresh timer
|
||||
|
let _epoch = 0; // bumped on destroy() — orphans in-flight _refresh resolutions
|
||||
|
let _lastRollup = null; // last rendered rollup code — only re-announce (aria-live) when it changes
|
||||
|
let _lastUpdated = null; // Date of last successful status fetch (for "Last updated")
|
||||
|
let _staleError = null; // last refresh-failure message while keeping the last-good table
|
||||
|
|
||||
|
function _currentIface() { |
||||
|
return (_data && _data.uplink_config && _data.uplink_config.telemetry_iface) || 'wlan0'; |
||||
|
} |
||||
|
|
||||
|
function _optionsHtml() { |
||||
|
const opts = (_data && _data.options) || []; |
||||
|
const sel = _selected || _currentIface(); |
||||
|
return opts.map(o => { |
||||
|
const label = _IFACE_LABELS[o.iface] || o.iface; |
||||
|
const disabled = !o.gateway_present; |
||||
|
const reason = o.disabled_reason || (disabled ? 'no_gateway' : null); |
||||
|
// Cleanest hinting: a disabled option states its reason; the default-route option
|
||||
|
// (wlan0) is marked "system default"; other healthy options show no hint (no clutter,
|
||||
|
// and no inaccurate "gateway present" — eth0/eth1 just route per-server when selected).
|
||||
|
let hint = ''; |
||||
|
if (disabled) { |
||||
|
hint = `<span class="uplink-opt__hint uplink-opt__hint--warn">${escapeHtml(_REASON_LABELS[reason] || 'unavailable')}</span>`; |
||||
|
} else if (o.iface === _DEFAULT_IFACE) { |
||||
|
hint = '<span class="uplink-opt__hint">system default</span>'; |
||||
|
} |
||||
|
const reasonNote = disabled && reason |
||||
|
? `<p class="uplink-opt__note">${escapeHtml(_DISABLED_REASONS[reason] || reason)}</p>` |
||||
|
: ''; |
||||
|
const checked = (o.iface === sel && !disabled) ? 'checked' : ''; |
||||
|
return ` |
||||
|
<label class="uplink-opt ${disabled ? 'uplink-opt--disabled' : ''}"> |
||||
|
<input type="radio" name="telemetry_iface" value="${escapeHtml(o.iface)}" ${checked} ${disabled ? 'disabled' : ''}> |
||||
|
<span class="uplink-opt__label">${escapeHtml(label)}</span> |
||||
|
${hint} |
||||
|
${reasonNote} |
||||
|
</label>`; |
||||
|
}).join(''); |
||||
|
} |
||||
|
|
||||
|
function _advisoriesHtml() { |
||||
|
const sel = _selected || _currentIface(); |
||||
|
const opt = ((_data && _data.options) || []).find(o => o.iface === sel); |
||||
|
const adv = (opt && opt.advisories) || []; |
||||
|
if (!adv.length) return ''; |
||||
|
return adv.map(a => |
||||
|
`<p class="uplink-status__msg uplink-status__msg--warning uplink-advisory">${escapeHtml(a)}</p>` |
||||
|
).join(''); |
||||
|
} |
||||
|
|
||||
|
function _renderAdvisories() { |
||||
|
const el = document.getElementById('uplink-advisories'); |
||||
|
if (el) el.innerHTML = _advisoriesHtml(); |
||||
|
} |
||||
|
|
||||
|
function _rollupCode() { |
||||
|
return (_data && _data.status && _data.status.rollup) || null; |
||||
|
} |
||||
|
function _rollupText(code) { return _ROLLUP_TEXT[code] || code || ''; } |
||||
|
function _rollupTone(code) { return _ROLLUP_TONE[code] || 'na'; } |
||||
|
|
||||
|
function _fmtTime(d) { |
||||
|
try { return d.toLocaleTimeString(); } catch (_) { return ''; } |
||||
|
} |
||||
|
|
||||
|
// The dynamic status body: the per-target table (or a distinct loading/empty/error
|
||||
|
// message). NOT a live region — the rollup line below carries the announcement.
|
||||
|
function _statusBodyHtml() { |
||||
|
if (!_data || !_data.status) { |
||||
|
return _staleError |
||||
|
? `<p class="uplink-status__msg uplink-status__msg--error">Status unavailable — ${escapeHtml(_staleError)}</p>` |
||||
|
: `<p class="uplink-status__msg uplink-status__msg--loading">Loading status…</p>`; |
||||
|
} |
||||
|
const s = _data.status; |
||||
|
const rows = (s.per_target || []).map(p => { |
||||
|
// Honesty: "Route ready" describes the host-route being in place — NOT that traffic flows.
|
||||
|
// Confirmation of actual traffic is the "Traffic" column (ss-observed egress interface).
|
||||
|
// In default-route mode (wlan0) NO per-server /32 is expected, so "No route yet" would
|
||||
|
// mis-read as a fault — show "System default" instead.
|
||||
|
// §5.1 fate — first match wins; on-link rows take precedence over mode/route rows because
|
||||
|
// an on-link target's egress is mode-independent (it always uses its own LAN).
|
||||
|
let routeText; |
||||
|
if (p.on_link_iface && p.on_link_iface === s.iface) { |
||||
|
routeText = `On ${p.on_link_iface} — local (direct)`; |
||||
|
} else if (p.on_link_iface) { |
||||
|
routeText = `On ${p.on_link_iface} — local LAN (not steered)`; |
||||
|
} else if (s.iface === _DEFAULT_IFACE) { |
||||
|
routeText = 'System default'; |
||||
|
} else if (p.route_installed) { |
||||
|
routeText = 'Route ready'; // route PRESENT, not a claim traffic flows (see Traffic column)
|
||||
|
} else { |
||||
|
routeText = 'No route yet'; |
||||
|
} |
||||
|
const traffic = p.egress_iface || 'Not yet observed'; |
||||
|
// Server cell: role label (TIOT / Update / DGPS) alongside the IP, when known.
|
||||
|
const server = p.label |
||||
|
? `<span class="uplink-target__role">${escapeHtml(p.label)}</span> ${escapeHtml(p.ip)}` |
||||
|
: escapeHtml(p.ip); |
||||
|
return ` |
||||
|
<tr> |
||||
|
<td>${server}</td> |
||||
|
<td>${escapeHtml(routeText)}</td> |
||||
|
<td>${escapeHtml(traffic)}</td> |
||||
|
</tr>`; |
||||
|
}).join(''); |
||||
|
const empty = (s.per_target || []).length === 0 |
||||
|
? `<tr><td colspan="3" class="uplink-status__msg uplink-status__msg--empty">No telemetry targets configured (set protocol/update/RTCM server IPs first).</td></tr>` |
||||
|
: ''; |
||||
|
const errBanner = _staleError |
||||
|
? `<p class="uplink-status__msg uplink-status__msg--error">Status refresh failed — showing last known result. ${escapeHtml(_staleError)}</p>` |
||||
|
: ''; |
||||
|
const stamp = _lastUpdated |
||||
|
? `<p class="uplink-status__stamp">Last updated ${escapeHtml(_fmtTime(_lastUpdated))}</p>` |
||||
|
: ''; |
||||
|
return ` |
||||
|
${errBanner} |
||||
|
<div class="data-table-wrapper"> |
||||
|
<table class="uplink-status data-table"> |
||||
|
<thead><tr><th>Server</th><th>Route</th><th>Traffic</th></tr></thead> |
||||
|
<tbody>${rows}${empty}</tbody> |
||||
|
</table> |
||||
|
</div> |
||||
|
${stamp}`;
|
||||
|
} |
||||
|
|
||||
|
function _render(container) { |
||||
|
const code = _rollupCode(); |
||||
|
_lastRollup = code; |
||||
|
container.innerHTML = ` |
||||
|
<div class="page-header"> |
||||
|
<h1 class="page-header__title">${icon('upload', { size: 28 })} Telemetry Uplink</h1> |
||||
|
<p class="page-header__desc">Route the TIOT (telemetry), Update (firmware) and DGPS (RTCM) server traffic over the selected interface using per-server host-routes. Management access (this configurator, SSH) is unaffected.</p> |
||||
|
</div> |
||||
|
<div class="card uplink-card"> |
||||
|
<fieldset class="uplink-fieldset"> |
||||
|
<legend class="uplink-card__title">Uplink interface</legend> |
||||
|
<div class="uplink-options" id="uplink-options">${_optionsHtml()}</div> |
||||
|
</fieldset> |
||||
|
<div id="uplink-advisories" aria-live="polite">${_advisoriesHtml()}</div> |
||||
|
<div class="dash-actions"> |
||||
|
<button class="btn btn--primary" id="uplink-save-apply" type="button" ${_saving ? 'disabled' : ''}> |
||||
|
${icon('check-circle-2', { size: 14 })} Save & Apply |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="card uplink-card"> |
||||
|
<h2 class="uplink-card__title">Status</h2> |
||||
|
<div id="uplink-status"> |
||||
|
<div id="uplink-status-body">${_statusBodyHtml()}</div> |
||||
|
<p id="uplink-rollup" class="uplink-status__rollup" data-rollup="${_rollupTone(code)}" aria-live="polite">${escapeHtml(_rollupText(code))}</p> |
||||
|
</div> |
||||
|
</div>`; |
||||
|
} |
||||
|
|
||||
|
function _bindRadios(scope) { |
||||
|
scope.querySelectorAll('input[name="telemetry_iface"]').forEach(r => { |
||||
|
r.addEventListener('change', () => { |
||||
|
if (r.checked && !r.disabled) { _selected = r.value; _renderAdvisories(); } |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function _bind(container) { |
||||
|
_bindRadios(container); |
||||
|
container.querySelector('#uplink-save-apply')?.addEventListener('click', () => _saveAndApply()); |
||||
|
} |
||||
|
|
||||
|
// Update ONLY the dynamic subtrees, preserving radio keyboard focus and not
|
||||
|
// spamming screen readers (rollup re-announced only when its code changes).
|
||||
|
function _applyRefreshDom() { |
||||
|
const optEl = document.getElementById('uplink-options'); |
||||
|
// Don't rebuild the radio group while the operator is focused inside it (would steal focus mid-selection).
|
||||
|
if (optEl && !optEl.contains(document.activeElement)) { |
||||
|
optEl.innerHTML = _optionsHtml(); |
||||
|
_bindRadios(optEl); |
||||
|
} |
||||
|
const bodyEl = document.getElementById('uplink-status-body'); |
||||
|
if (bodyEl) bodyEl.innerHTML = _statusBodyHtml(); |
||||
|
const code = _rollupCode(); |
||||
|
if (code !== _lastRollup) { |
||||
|
const rEl = document.getElementById('uplink-rollup'); |
||||
|
if (rEl) { |
||||
|
rEl.setAttribute('data-rollup', _rollupTone(code)); |
||||
|
rEl.textContent = _rollupText(code); |
||||
|
} |
||||
|
_lastRollup = code; |
||||
|
} |
||||
|
_renderAdvisories(); |
||||
|
} |
||||
|
|
||||
|
async function _refresh() { |
||||
|
const myEpoch = _epoch; |
||||
|
let data; |
||||
|
try { |
||||
|
data = await getUplink(); |
||||
|
} catch (e) { |
||||
|
if (myEpoch !== _epoch) return; // torn down during await — don't touch the DOM
|
||||
|
// Fail-soft: keep last-good _data and the last-known table visible; surface a
|
||||
|
// distinct error notice over it instead of blanking the panel (matches peer poll convention).
|
||||
|
_staleError = e.message || 'load failed'; |
||||
|
const bodyEl = document.getElementById('uplink-status-body'); |
||||
|
if (bodyEl) bodyEl.innerHTML = _statusBodyHtml(); |
||||
|
return; |
||||
|
} |
||||
|
if (myEpoch !== _epoch) return; // page torn down / re-mounted while the GET was in flight
|
||||
|
_data = data; |
||||
|
_staleError = null; |
||||
|
_lastUpdated = new Date(); |
||||
|
// Reconcile UI selection with backend: if the user hasn't picked anything yet,
|
||||
|
// mirror the backend's current iface; if they had, keep their choice when valid.
|
||||
|
const valid = new Set((_data.options || []).filter(o => o.gateway_present).map(o => o.iface)); |
||||
|
if (!_selected || !valid.has(_selected)) _selected = _currentIface(); |
||||
|
if (document.getElementById('uplink-status-body')) { |
||||
|
_applyRefreshDom(); // subtree update — preserves focus + avoids SR churn
|
||||
|
} else { |
||||
|
const c = document.getElementById('page-container'); // first-mount race fallback
|
||||
|
if (c) { _render(c); _bind(c); } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function _saveAndApply() { |
||||
|
if (_saving) return; |
||||
|
const iface = _selected || _currentIface(); |
||||
|
if (!_data) { |
||||
|
showToast('Uplink data not loaded yet.', 'error'); |
||||
|
return; |
||||
|
} |
||||
|
const opt = (_data.options || []).find(o => o.iface === iface); |
||||
|
if (!opt || !opt.gateway_present) { |
||||
|
showToast(`Cannot select ${iface}: ${opt?.disabled_reason || 'no gateway'}.`, 'error'); |
||||
|
return; |
||||
|
} |
||||
|
if (iface === _currentIface()) { |
||||
|
showToast(`Telemetry is already routed over ${iface}.`, 'info'); |
||||
|
return; |
||||
|
} |
||||
|
const adv = (opt && opt.advisories) || []; |
||||
|
const advText = adv.length ? `Effects of this selection: ${adv.join(' ')} ` : ''; |
||||
|
const ok = await confirmModal({ |
||||
|
title: 'Switch telemetry uplink?', |
||||
|
message: `${advText}Telemetry will switch to ${iface}. To make the switch take effect now, ${_RESTART_ACTION} — until then dpworldapp keeps its current connections on the old path. If the servers are not reachable via ${iface}, transmission stops until you switch back. Management access (this configurator, SSH) is not affected.`, |
||||
|
confirmLabel: 'Switch & Apply', |
||||
|
cancelLabel: 'Cancel', |
||||
|
danger: false, // route-only, non-disruptive to management access; the caveat lives in the message body
|
||||
|
}); |
||||
|
if (!ok) return; |
||||
|
_saving = true; |
||||
|
const saveApplyBtn = document.getElementById('uplink-save-apply'); |
||||
|
const origBtnHtml = saveApplyBtn ? saveApplyBtn.innerHTML : ''; |
||||
|
if (saveApplyBtn) { saveApplyBtn.disabled = true; saveApplyBtn.textContent = 'Saving…'; } |
||||
|
try { |
||||
|
await postUplink({ telemetry_iface: iface }); |
||||
|
clearDirty('uplink'); |
||||
|
// Reinforce the action: routes are applied, but dpworldapp must reconnect to switch.
|
||||
|
showToast(`Uplink switch applied — ${_RESTART_ACTION} to take effect now.`, 'success'); |
||||
|
} catch (e) { |
||||
|
showToast('Failed — ' + (e.message || 'apply error'), 'error'); |
||||
|
} finally { |
||||
|
_saving = false; |
||||
|
if (saveApplyBtn) { saveApplyBtn.disabled = false; saveApplyBtn.innerHTML = origBtnHtml; } |
||||
|
} |
||||
|
await _refresh(); // re-render AFTER _saving cleared so the button re-enables immediately
|
||||
|
} |
||||
|
|
||||
|
const uplinkPage = { |
||||
|
render(container) { _render(container); _bind(container); }, |
||||
|
mount(container) { |
||||
|
_refresh(); |
||||
|
if (!_statusTimer) _statusTimer = setInterval(_refresh, 15000); |
||||
|
}, |
||||
|
destroy() { |
||||
|
_epoch++; // orphan any in-flight _refresh so it won't write to a torn-down/other page
|
||||
|
if (_statusTimer) { clearInterval(_statusTimer); _statusTimer = null; } |
||||
|
_data = null; |
||||
|
_selected = null; |
||||
|
_saving = false; |
||||
|
_lastRollup = null; |
||||
|
_lastUpdated = null; |
||||
|
_staleError = null; |
||||
|
}, |
||||
|
validate() { return []; }, // route-only; selection is constrained by radios
|
||||
|
async saveSelfContained() { |
||||
|
// Global Save All entry point: route-only apply, no confirm modal (Save All
|
||||
|
// already gathered confirmation upstream for the whole batch).
|
||||
|
const iface = _selected || _currentIface(); |
||||
|
if (iface === _currentIface()) return; |
||||
|
await postUplink({ telemetry_iface: iface }); |
||||
|
clearDirty('uplink'); |
||||
|
// Refresh only when the uplink page is actually mounted (no-op during a batch
|
||||
|
// Save All triggered from another page — avoids rendering into a foreign container).
|
||||
|
if (document.getElementById('uplink-status')) await _refresh(); |
||||
|
}, |
||||
|
}; |
||||
|
export default uplinkPage; |
||||
Loading…
Reference in new issue