You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

308 lines
15 KiB

/**
* 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<string|null>} 재개한 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 = `
<div class="info-banner info-banner--warning apply-flow__eth1" data-eth1-reconnect role="alert">
<strong>Connection address changed.</strong>
If you changed this device's IP, this page will lose its connection.
Reconnect at <span class="text-mono">http://&lt;new IP&gt;:9090</span> within
<span data-eth1-count>${secs}</span>s and keep this address otherwise it reverts automatically.
<button class="btn btn--primary btn--sm" data-net-confirm="${applyId}">Keep this address</button>
</div>`;
}
/** 진행 중 표시 (단순 스피너 1줄 — step/§용어 없음). */
function _renderApplying(host) {
if (!host) return;
host.innerHTML = `
<div class="info-banner apply-flow__progress" role="status" aria-live="polite">
Applying network settings
</div>`;
}
/** 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();
});
}