/** * apply-flow.js — v1.8.0 Task 4 ★ Simple Apply (User 모드, 네트워크) * * Spec §1.2 / §4: User 모드에서 Save 성공 + 네트워크 변경이면 **자동으로 apply 를 트리거**해 * "적용 중… → 적용됨 ✓" 한 동작으로 만든다. 상태머신 step·§용어·watchdog·journal 은 노출하지 않는다. * * - 기존 라우트 그대로 사용: POST /api/network/apply {dry_run:false, fields:{}} (drift-apply). * - 경량 status 폴링 (net-apply.js epoch 패턴 미러) → terminal 시 평이한 결과 1줄. * - terminal 매핑(엔진 status → User 카피): * COMMITTED → "Network settings applied." (success) * NOOP → "Saved — nothing to apply." (success) * ROLLED_BACK → "Could not apply — reverted to the previous settings." (warning) * FAILED_VALIDATION → "Could not apply — settings rejected. Reverted." (warning) * FAILED_CRITICAL → "Apply failed — device needs attention. (Advanced view for details)" (error) * * ★ 안전 불변식: eth1(웹/SSH 접속 경로) 관리 IP 변경은 스스로 끊길 수 있어 confirm-or-rollback 이 * 안전상 필수 → User 모드에서도 **반드시 노출**(평이한 언어). 절대 숨기지 않는다(숨기면 lockout). * 재접속 후 확정 = 기존 delegated `data-net-confirm` 계약 재사용. * * 순환 import 회피: view-mode 를 import 하지 않는다(이 모듈은 호출 주체가 User 모드 판정 후 부른다). */ import { showToast } from './toast.js'; import { escapeHtml } from './utils.js'; const SIMPLE_POLL_MS = 1000; // 경량 폴링 주기 const TERMINAL = ['COMMITTED', 'ROLLED_BACK', 'FAILED_CRITICAL', 'FAILED_VALIDATION', 'NOOP']; /** * 폴링용 setTimeout 래퍼. Node(테스트/헤드리스)에선 unref 해 진행 중 poll 타이머가 * 런타임 종료를 막지 않게 한다(브라우저 setTimeout 은 number 반환 → unref 없음 → no-op). * 운영(브라우저) 동작에는 영향 없음. */ function _schedule(fn) { const t = setTimeout(fn, SIMPLE_POLL_MS); if (t && typeof t === 'object' && typeof t.unref === 'function') t.unref(); return t; } // epoch 가드 — 중복 호출/재방문 시 stale tick 자가 폐기 (net-apply.js I-2 패턴). let _epoch = 0; let _timer = null; let _currentApplyId = null; let _hostBound = null; // eth1 confirm 위임 리스너가 바인딩된 host let _inFlight = false; // I-2: 진행 중 재진입 가드 — 동시 두 번째 apply POST 차단 /** Test-only / 정리: 진행 중 폴링 중단 + 상태 리셋. */ export function _resetApplyFlow() { _epoch++; if (_timer) { clearTimeout(_timer); _timer = null; } _currentApplyId = null; _inFlight = false; // I-4: 진행/eth1 배너 DOM 도 정리 — 중단 후 stale "Keep this address" 버튼 잔존(잘못된 확정) 방지. _clearHost(_hostBound || (typeof document !== 'undefined' ? document.getElementById('apply-flow-host') : null)); if (_hostBound) { _hostBound.removeEventListener('click', _onConfirmClick); _hostBound = null; } } /** 현재 User-mode 적용 흐름이 진행 중인지 (net-apply.js 가 이중 폴링 회피 판정에 사용). */ export function isInFlight() { return _inFlight === true; } // 비-terminal(진행) 상태 — resume 판정용. const IN_FLIGHT_STATES = ['VALIDATING', 'SNAPSHOT', 'WRITING', 'APPLYING', 'VERIFYING', 'CONFIRM_WAIT']; /** * ★ C1 (v1.8.1): 재접속/새 로드 후 진행 중 apply 를 이어받아 eth1 confirm 배너를 다시 띄운다. * eth1 관리 IP 변경은 본질적으로 현 연결을 끊어 **새 페이지 로드**를 강제한다 — 그 새 로드엔 * runSimpleApply 호출 지점이 없어(=Save 시점에만) CONFIRM_WAIT 배너가 사라지고, 확정 못 하면 * TTL 만료 → 자동 롤백 → 운영자(새 IP 접속 중) 단절. 이를 막기 위해 app.js init / 모드전환 / * net-apply 이탈 시 본 함수를 호출 — 빈 id 로 현재 apply 상태를 조회해 진행 중이면 폴링 재개. * net-apply.js(Advanced 페이지)가 활성일 땐 그쪽이 소유하므로, 그 페이지가 정리(destroy)될 때 * 다시 본 함수로 핸드백한다. * @param {Object} [opts] * @param {HTMLElement} [opts.host] * @returns {Promise} 재개한 state (없으면 null) */ export async function resumeIfConfirming(opts = {}) { if (_inFlight) return null; // 이미 본 모듈이 흐름을 소유 중 const host = opts.host || (typeof document !== 'undefined' ? document.getElementById('apply-flow-host') : null); let s; try { // 빈 id → 엔진은 현재/마지막 apply 를 반환(apply_engine.status(None) — UNKNOWN 가드 우회). s = await _api('api/network/apply/status?id='); } catch (e) { return null; // apply 없음 / 엔드포인트 불가 — 재개할 것 없음 } if (!s || !IN_FLIGHT_STATES.includes(s.state)) return null; _inFlight = true; _currentApplyId = s.apply_id || null; if (host && _hostBound !== host) { if (_hostBound) _hostBound.removeEventListener('click', _onConfirmClick); host.addEventListener('click', _onConfirmClick); _hostBound = host; } if (s.state === 'CONFIRM_WAIT') _renderEth1Banner(host, s.confirm_remaining_s); else _renderApplying(host); _poll(host, null); // terminal 까지 폴링 이어감 (fire-and-forget) return s.state; } async function _api(path, opts) { const res = await fetch(path, opts); const body = await res.json().catch(() => ({})); if (!res.ok) throw new Error(body.error || `${path}: ${res.status}`); return body; } /** * 엔진 terminal status → User 평이 결과 { kind, text }. * kind: 'success' | 'warning' | 'error' (toast type 와 동일). * @param {string} state 엔진 terminal state * @param {Object} [status] 전체 status payload (country_pending 등 부가 신호 — 선택) */ export function mapTerminal(state, status) { switch (state) { case 'COMMITTED': // v1.8.1: country-code 변경은 deferred(재부팅 후 적용). 엔진은 COMMITTED+country_pending // 으로 알린다 — "적용됨" 으로 오인시키지 말 것(운영자가 재부팅 필요를 모름). if (status && status.country_pending) { return { kind: 'success', text: 'Saved. The Wi-Fi region change takes effect after the next reboot.' }; } return { kind: 'success', text: 'Network settings applied.' }; case 'NOOP': return { kind: 'success', text: 'Saved — nothing to apply.' }; case 'ROLLED_BACK': return { kind: 'warning', text: 'Could not apply — reverted to the previous settings.' }; case 'FAILED_VALIDATION': return { kind: 'warning', text: 'Could not apply — the settings were rejected. Reverted to the previous settings.' }; case 'FAILED_CRITICAL': return { kind: 'error', text: 'Apply failed — the device needs attention. Turn on Advanced / Debug view for details.' }; default: // M-6: 알 수 없는/비terminal 엔진 상태를 사용자 카피에 그대로 노출하지 않는다(평이 언어 정책). return { kind: 'warning', text: 'Apply finished with an unexpected result. Turn on Advanced / Debug view for details.' }; } } /** * eth1 관리 IP 변경 평이 재접속 배너 (CONFIRM_WAIT). ★ 절대 숨기지 않음. * delegated `data-net-confirm` 계약 재사용 — 재접속 후 [Keep this address] 로 확정. * @param {HTMLElement} host * @param {number|string} remaining 남은 초 */ function _renderEth1Banner(host, remaining) { if (!host) return; // M-9: 배너가 이미 떠 있으면 카운트다운 숫자만 textContent 로 교체(전체 재빌드 회피 — // 버튼/포커스 보존). textContent 경로는 XSS 안전이라 escape 불필요. const existing = host.querySelector('[data-eth1-reconnect]'); if (existing) { const countEl = existing.querySelector('[data-eth1-count]'); if (countEl) countEl.textContent = String(remaining ?? '?'); return; } const secs = escapeHtml(String(remaining ?? '?')); const applyId = escapeHtml(_currentApplyId || ''); host.innerHTML = ` `; } /** 진행 중 표시 (단순 스피너 1줄 — step/§용어 없음). */ function _renderApplying(host) { if (!host) return; host.innerHTML = `
Applying network settings…
`; } /** host 비우기 (terminal·정리 시). */ function _clearHost(host) { if (host) host.innerHTML = ''; } // eth1 [Keep this address] 위임 핸들러 — 기존 confirm 라우트 호출. 재렌더에도 살아남음. function _onConfirmClick(event) { const btn = event.target.closest && event.target.closest('[data-net-confirm]'); if (!btn) return; const applyId = btn.getAttribute('data-net-confirm') || _currentApplyId; _doConfirm(applyId); } async function _doConfirm(applyId) { try { await _api('api/network/apply/confirm', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apply_id: applyId || _currentApplyId }), }); // v1.11.10 (review #28): the confirm IS the terminal user action — invalidate the // still-running poll BEFORE clearing the host. Without this, the next status tick // reads the now-COMMITTED apply and fires a 2nd (duplicate) success toast on top of // this confirm toast. Bumping _epoch makes that tick fail its `epoch !== _epoch` // guard and resolve null silently; we then terminate the flow ourselves so a later // Save can apply again (the running tick no longer reaches the in-flight reset). _epoch++; if (_timer) { clearTimeout(_timer); _timer = null; } _inFlight = false; // M-7: 확정 성공 시 배너 즉시 제거 — 다음 폴링 tick(최대 1s) 을 기다리지 않는다. _clearHost(_hostBound); showToast('Address kept — network settings applied.', 'success'); } catch (e) { showToast('Could not keep the address — ' + e.message, 'error'); } } /** * User 모드 단순 적용 실행. * @param {Object} [opts] * @param {HTMLElement} [opts.host] 진행/eth1 배너를 그릴 컨테이너 (기본: #apply-flow-host) * @param {Function} [opts.onResult] terminal 결과 콜백 ({kind,text}) — 테스트/추가 UI 용 * @returns {Promise<{kind,text}|null>} terminal 결과 (시작 실패 시 null) */ export async function runSimpleApply(opts = {}) { const host = opts.host || document.getElementById('apply-flow-host') || null; const onResult = typeof opts.onResult === 'function' ? opts.onResult : null; // I-2: 이미 적용이 진행 중이면 두 번째 apply 를 시작하지 않는다(동시 엔진 세션/혼란 방지). // _saving 가드(handleSaveAll)가 1차 방어이나, 동기 이중 호출까지 막기 위해 // 가드 직후 즉시 in-flight 로 표시(첫 await 이전). if (_inFlight) { showToast('Network settings are already being applied — please wait.', 'info'); return null; } _inFlight = true; // eth1 confirm 위임 리스너를 host 에 1회 바인딩 (재방문 누적 방지). if (host && _hostBound !== host) { if (_hostBound) _hostBound.removeEventListener('click', _onConfirmClick); host.addEventListener('click', _onConfirmClick); _hostBound = host; } let started; try { started = await _api('api/network/apply', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dry_run: false, fields: {} }), }); } catch (e) { _inFlight = false; // 시작 실패 → 가드 해제(재시도 가능) // eth1-safety (v1.8.1): 409 "apply in flight" 는 이미 apply(가능성으로 eth1 CONFIRM_WAIT)가 // 대기 중이라는 뜻 — 죽은 에러 대신 그 흐름을 이어받아 confirm 배너를 띄운다. const resumed = await resumeIfConfirming({ host }); if (resumed) return null; showToast('Could not start applying network settings — ' + e.message, 'error'); return null; } _currentApplyId = started.apply_id || null; _renderApplying(host); return _poll(host, onResult); } function _poll(host, onResult) { if (_timer) { clearTimeout(_timer); _timer = null; } const epoch = ++_epoch; return new Promise((resolve) => { async function tick() { let s; try { s = await _api(`api/network/apply/status?id=${encodeURIComponent(_currentApplyId || '')}`); } catch (e) { // 폴링 일시 실패 — eth1 IP 변경 중 단절 가능. epoch 유효하면 재시도. if (epoch === _epoch) _timer = _schedule(tick); return; } if (epoch !== _epoch) { resolve(null); return; } // stale generation if (s.state === 'CONFIRM_WAIT') { _renderEth1Banner(host, s.confirm_remaining_s); _timer = _schedule(tick); return; } if (TERMINAL.includes(s.state)) { _epoch++; // 이후 어떤 tick 도 무효화 _timer = null; _inFlight = false; // I-2: 진행 종료 — 다음 Save=apply 허용 _clearHost(host); const result = mapTerminal(s.state, s); showToast(result.text, result.kind); if (onResult) onResult(result); resolve(result); return; } // 진행 중 — 단순 표시 유지 후 재예약. _renderApplying(host); if (epoch === _epoch) _timer = _schedule(tick); } // 첫 tick 즉시 (테스트·반응성 — net-apply 는 1s 지연이나 여기선 즉시 첫 조회). tick(); }); }