/** * app.js — Main Application Entry Point * * Orchestrates page navigation, data loading/saving, theming, * and import/export functionality. */ import { getDevice, getProtocol, saveDevice, saveProtocol, getMac } from './api.js'; import { APP_NAME, APP_VERSION } from './constants.js'; import { showToast } from './toast.js'; import { state, setDevice, setProtocol, buildDevicePayload, buildProtocolPayload, commitChangesAsBaseline } from './state.js'; import { clearAllFieldErrors, setPageError, clearAllPageErrors, focusErrorField } from './validator.js'; import { getDirtyPages, clearAllDirty, markDirty } from './page-dirty.js'; import { attachDirtyTracking } from './dirty-tracker.js'; import { renderSsidPage } from './pages/ssid.js'; import ssidPage from './pages/ssid.js'; // v1.5.0 P2: wifi.js (ssid.js rename) import wifiPage from './pages/wifi.js'; // v1.5.0 P2 T2: ethernet.js NEW (eth + LTE interface IP) import ethernetPage from './pages/ethernet.js'; import { renderNetworkPage } from './pages/network.js'; import networkPage from './pages/network.js'; // v1.5.0 P2 T3: server-setting.js (network.js rename) import serverSettingPage from './pages/server-setting.js'; import { renderIoPage } from './pages/io.js'; import ioPage from './pages/io.js'; import { renderLogPage } from './pages/log.js'; import logPage from './pages/log.js'; import { renderRegisterPage } from './pages/register.js'; import registerPage from './pages/register.js'; // v1.5.0 P3 T1: general-settings.js NEW (Equipment + Protocol + Odo, A/D ports 제거) import generalSettingsPage from './pages/general-settings.js'; // v1.5.0 P3 T2: sensor-io.js NEW (RS485 + Analog + Digital ports 통합) import sensorIoPage from './pages/sensor-io.js'; // v1.5.0 P3 T3: can-bus.js NEW (CAN bus config + CAN mapping 통합) import canBusPage from './pages/can-bus.js'; import { renderModbusPage } from './pages/modbus.js'; import modbusPage from './pages/modbus.js'; import { renderOpcuaPage } from './pages/opcua.js'; import opcuaPage from './pages/opcua.js'; import { renderCanPage } from './pages/can.js'; import canPage from './pages/can.js'; import { renderFirmwarePage } from './pages/firmware.js'; import firmwarePage from './pages/firmware.js'; import homePage, { setNavigate as setHomeNavigate } from './pages/home.js'; // v1.6.0: Network Apply & Status page import netApplyPage from './pages/net-apply.js'; import wifiApPage from './pages/wifi-ap.js'; // AP: page import // v1.5.0 P1: Lucide SVG icons + sidebar nested import { icon } from './icons.js'; // v1.5.0 P1: nav-guard — navigation dirty modal import { confirmNavigation } from './nav-guard.js'; // v1.7.0: Pending Changes — 전역 미저장/미적용 배지 + 패널 import { initPending, refreshUnapplied, getUnapplied, applyNeededCta, openPanel as openPendingPanel, renderBadge as renderPendingBadge } from './pending.js'; // v1.8.0: Advanced/Debug 보기 토글 상태 (User 기본 / Advanced 노출) import { isAdvanced, setAdvanced, onAdvancedChange, initFromStorage as initViewModeStorage } from './view-mode.js'; // v1.8.0: Simple Apply — User 모드 Save=즉시 적용 (네트워크) + eth1 평이 재접속 확인 import { runSimpleApply, resumeIfConfirming } from './apply-flow.js'; import { confirmModal } from './confirm-modal.js'; const DEBUG = location.hostname === 'localhost' || location.hostname === '127.0.0.1'; let _deviceMac = null; const PAGE_RENDERERS = { ssid: renderSsidPage, network: renderNetworkPage, io: renderIoPage, log: renderLogPage, register: renderRegisterPage, modbus: renderModbusPage, opcua: renderOpcuaPage, can: renderCanPage, firmware: renderFirmwarePage, }; const PAGES = { home: homePage, wifi: wifiPage, // v1.5.0 P2: canonical Wi-Fi page id ssid: wifiPage, // v1.5.0 P2 alias — legacy id redirects to wifi 'wifi-ap': wifiApPage, // AP: page ethernet: ethernetPage, // v1.5.0 P2 T2: canonical Ethernet page id io: ioPage, // v1.5.0 P2 T2: legacy + RS485/CAN 잔존 'server-setting': serverSettingPage, // v1.5.0 P2 T3: canonical Server Setting page id network: serverSettingPage, // v1.5.0 P2 T3 alias — legacy id redirects to server-setting log: logPage, general: generalSettingsPage, // v1.5.0 P3 T1: canonical General Settings page id register: generalSettingsPage, // v1.5.0 P3 T1 alias — legacy id redirects to general 'sensor-io': sensorIoPage, // v1.5.0 P3 T2: canonical Sensor I/O page id (no legacy alias — genuinely new) 'can-bus': canBusPage, // v1.5.0 P3 T3: canonical CAN-BUS page id can: canBusPage, // v1.5.0 P3 T3 alias — legacy id redirects to can-bus modbus: modbusPage, opcua: opcuaPage, firmware: firmwarePage, 'net-apply': netApplyPage, // v1.6.0: Network Apply & Status }; let activePage = null; let detachDirtyTracking = null; // v1.8.0 Task 4: 네트워크 도메인 페이지 id 집합. Save 시 이 중 하나라도 dirty 였다면 // "네트워크 변경" 으로 보고 User 모드에서 자동 apply 를 트리거한다 (netmodel.NETWORK_DEV_KEYS // 의 프론트 대응). // v1.8.1 (review I-1): 'opcua'/'modbus' 추가 — 이 페이지들이 opc_ua_server_ip/port, // modbus_server_ip/port (모두 netmodel.NETWORK_DEV_KEYS) 를 편집한다. 누락 시 OPC-UA/Modbus // 엔드포인트 변경이 User 모드 Save 에서 자동 적용되지 않고(드리프트 폴백은 503/파일부재 시 fail-open) // "저장됨" 토스트만 뜬 채 재부팅 전까지 OS 에 반영 안 되던 결함. const NETWORK_PAGES = new Set(['wifi', 'ssid', 'ethernet', 'server-setting', 'network', 'opcua', 'modbus']); // v1.8.2 (review prod-safety): 연결을 끊을 수 있는 "파괴적" 네트워크 페이지. eth1(관리 IP)·Wi-Fi // 변경은 인터페이스 재설정으로 현 세션이 끊길 수 있어 User 모드에서도 적용 전 평이한 확인을 둔다. // server-setting/opcua/modbus 는 엔드포인트(목적지)만 바꿔 인터페이스 재설정이 없으므로 비파괴적 — // 확인 없이 즉시 적용(단순 흐름 유지). const DISRUPTIVE_NETWORK_PAGES = new Set(['wifi', 'ssid', 'ethernet', 'network']); // Config pages owned by the imported device/protocol payload. An Import overwrites // these in-memory; flag them dirty so the nav-guard and beforeunload fire (otherwise // an imported-but-unsaved config is silently lost on navigate/reload). Canonical ids only // (no home/firmware/net-apply — those don't hold importable config). const IMPORTABLE_CONFIG_PAGES = [ 'wifi', 'ethernet', 'server-setting', 'general', 'sensor-io', 'can-bus', 'opcua', 'modbus', 'log', ]; // ───────────────────────────────────────────────────────────── // v1.5.0 P1: Sidebar nested infrastructure // (sidebar group toggle + localStorage + keyboard nav + icon 주입) // ───────────────────────────────────────────────────────────── const SIDEBAR_LS_KEY = 'wc.sidebar.groups'; /** * v1.5.0 P1: Sidebar icon 주입 — 모든 icon-* 슬롯에 inline SVG 채움. */ function injectSidebarIcons() { const map = { // v1.11.15: System group icon — real Lucide 'monitor' glyph (added to icons.js). 'icon-grp-system': { name: 'monitor', size: 16 }, 'icon-chev-system': { name: 'chevron-down', size: 14 }, 'icon-grp-firmware': { name: 'package', size: 16 }, 'icon-chev-firmware': { name: 'chevron-down', size: 14 }, 'icon-grp-network': { name: 'globe', size: 16 }, 'icon-grp-interface': { name: 'plug-zap', size: 16 }, 'icon-chev-network': { name: 'chevron-down', size: 14 }, 'icon-chev-interface': { name: 'chevron-down', size: 14 }, 'icon-nav-home': { name: 'home', size: 18 }, 'icon-nav-wifi': { name: 'wifi', size: 16 }, // v1.5.0 P2 'icon-nav-ethernet': { name: 'ethernet-port', size: 16 }, // v1.5.0 P2 T2 'icon-nav-server-setting': { name: 'server', size: 16 }, // v1.5.0 P2 T3 'icon-nav-general': { name: 'sliders-horizontal', size: 16 }, // v1.5.0 P3 T6 'icon-nav-sensor-io': { name: 'cable', size: 16 }, // v1.5.0 P3 T6 'icon-nav-can-bus': { name: 'route', size: 16 }, // v1.5.0 P3 T6 // icon-nav-register / icon-nav-can removed — nav entries migrated to general/can-bus (P3 T6) 'icon-nav-opcua': { name: 'link', size: 16 }, 'icon-nav-modbus': { name: 'share-2', size: 16 }, 'icon-nav-log': { name: 'file-text', size: 18 }, 'icon-nav-firmware': { name: 'package', size: 18 }, 'icon-nav-net-apply': { name: 'plug-zap', size: 16 }, // v1.6.0 'icon-nav-wifi-ap': { name: 'wifi', size: 16 }, // AP: nav icon }; Object.entries(map).forEach(([id, spec]) => { const el = document.getElementById(id); if (el) el.innerHTML = icon(spec.name, { size: spec.size }); }); } function loadSidebarState() { try { const raw = localStorage.getItem(SIDEBAR_LS_KEY); return raw ? JSON.parse(raw) : { system: true, network: true, 'interface-protocol': true, firmware: true }; } catch (e) { return { system: true, network: true, 'interface-protocol': true, firmware: true }; } } function saveSidebarState(state) { try { localStorage.setItem(SIDEBAR_LS_KEY, JSON.stringify(state)); } catch (e) { /* ignore quota */ } } function applySidebarState(state) { document.querySelectorAll('.nav-group__toggle').forEach(btn => { const groupEl = btn.closest('.nav-group'); if (!groupEl) return; const groupKey = groupEl.dataset.group; const expanded = state[groupKey] !== false; // default expanded btn.setAttribute('aria-expanded', String(expanded)); }); } function initSidebarGroups() { const state = loadSidebarState(); applySidebarState(state); document.querySelectorAll('.nav-group__toggle').forEach(btn => { btn.addEventListener('click', e => { e.preventDefault(); const expanded = btn.getAttribute('aria-expanded') === 'true'; const newState = !expanded; btn.setAttribute('aria-expanded', String(newState)); const groupEl = btn.closest('.nav-group'); if (groupEl) { state[groupEl.dataset.group] = newState; saveSidebarState(state); } }); btn.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); btn.click(); } }); }); } function initSidebarKeyboardNav() { const sidebar = document.getElementById('sidebar-nav'); if (!sidebar) return; sidebar.addEventListener('keydown', e => { const target = e.target; if (!target.classList || !target.classList.contains('nav-item')) return; const allLeaves = Array.from(sidebar.querySelectorAll( '.nav-item:not(.nav-item--disabled):not([style*="display:none"])' )); const idx = allLeaves.indexOf(target); if (idx < 0) return; if (e.key === 'ArrowDown') { e.preventDefault(); const next = allLeaves[(idx + 1) % allLeaves.length]; if (next) next.focus(); } else if (e.key === 'ArrowUp') { e.preventDefault(); const prev = allLeaves[(idx - 1 + allLeaves.length) % allLeaves.length]; if (prev) prev.focus(); } else if (e.key === 'Escape') { e.preventDefault(); const groupEl = target.closest('.nav-group'); if (!groupEl) return; const btn = groupEl.querySelector('.nav-group__toggle'); if (btn) { btn.setAttribute('aria-expanded', 'false'); const st = loadSidebarState(); st[groupEl.dataset.group] = false; saveSidebarState(st); btn.focus(); } } }); } // ─── Initialization ───────────────────────────────────────── document.addEventListener('DOMContentLoaded', async () => { initTheme(); initVersion(); initViewMode(); initNavigation(); // v1.7.1 Task 2: dashboard issue deep-links navigate via showPage (Dashboard is // read-only, so nav-guard is unnecessary here). setHomeNavigate((pageId) => showPage(pageId)); initSidebar(); injectSidebarIcons(); initSidebarGroups(); initSidebarKeyboardNav(); initActions(); initUnsavedWarning(); await loadAllData(); updateTabVisibility(); showPage('home'); // v1.7.0: Pending Changes 초기화 — 배지 wiring + 미적용 캐시·폴링. // baseline 은 loadAllData 의 setDevice/setProtocol 에서 이미 잡혔다. initPending({ rerender: () => showPage(state.currentPage), navigate: (p) => showPage(p), saveAll: () => handleSaveAll({ skipConfirm: true }), }); // ★ C1 (v1.8.1): 진행 중 apply 를 새 로드에서 이어받는다. eth1 관리 IP 변경은 현 연결을 끊어 // 재접속(=새 페이지 로드)을 강제하는데, 그 새 로드엔 runSimpleApply 호출 지점이 없어 // CONFIRM_WAIT 배너가 사라지고 확정 못 하면 TTL 만료 → 자동 롤백 → 운영자(새 IP) 단절. // 모드 무관(배너는 #apply-flow-host = page-container 밖, 평이 언어). fire-and-forget. resumeIfConfirming(); }); // ─── Data Loading ─────────────────────────────────────────── async function loadAllData() { try { if (DEBUG) console.log('[App] Loading data from API...'); const [device, protocol, mac] = await Promise.all([ getDevice(), getProtocol(), getMac(), ]); if (DEBUG) console.log('[App] Raw device response:', device); if (DEBUG) console.log('[App] Raw protocol response:', protocol); if (DEBUG) console.log('[App] Device MAC:', mac); _deviceMac = mac; setDevice(device); setProtocol(protocol); state.isDirty = false; if (DEBUG) console.log('[App] Data loaded. state.device:', state.device); if (DEBUG) console.log('[App] Data loaded. state.protocol:', state.protocol); } catch (e) { console.error('[App] Failed to load config:', e); showToast('Failed to load configuration. Check server connection.', 'error'); // Use defaults setDevice(null); setProtocol(null); } finally { document.getElementById('loading').style.display = 'none'; document.getElementById('page-container').style.display = 'block'; } } // ─── Page Navigation ──────────────────────────────────────── function showPage(pageId) { // v1.8.0 Task 3: Apply & Status 는 Advanced 전용. User 모드 진입 시 Dashboard 로 리다이렉트 // (직접 deeplink·이전 모드에서 머문 경우 모두 차단). nav 숨김(.advanced-only)과 이중 가드. if (pageId === 'net-apply' && !isAdvanced()) { pageId = 'home'; } const prevPage = state.currentPage; // v1.7.0: net-apply 이탈/진입 drift 재계산용 // Collect current page data before destroying DOM (prevents data loss on tab switch). // v1.5.5.7: 현재 page 가 dirty 일 때만 collect — Discard 직후 state 복원이 // 이 collector 에 의해 DOM 의 stale 값으로 덮어쓰여 무효화되던 결함 차단. // Save / dirty 아닌 nav / initial load 모두 정상 (collector 자체가 noop 또는 // handleSaveAll 안의 collectAllPagesData 가 별도로 호출). if ( state.pageDirty[state.currentPage] && window.__pageCollectors?.[state.currentPage] ) { window.__pageCollectors[state.currentPage](); } // 신규 인터페이스: destroy 호출 if (activePage?.destroy) { activePage.destroy(); } if (detachDirtyTracking) { detachDirtyTracking(); detachDirtyTracking = null; } state.currentPage = pageId; // Update nav active state document.querySelectorAll('.nav-item').forEach(el => { el.classList.toggle('active', el.dataset.page === pageId); }); // Render page const container = document.getElementById('page-container'); container.innerHTML = ''; container.style.animation = 'none'; void container.offsetHeight; container.style.animation = ''; const page = PAGES[pageId]; if (page && page.render && page.mount) { // 신규 인터페이스 page.render(container); page.mount(container); activePage = page; } else { // 레거시: 함수 직접 호출 const renderer = PAGE_RENDERERS[pageId]; if (renderer) renderer(container); activePage = null; } detachDirtyTracking = attachDirtyTracking(container, pageId); // v1.7.0: net-apply 진입/이탈 시 미적용(drift) 재계산 — 그 페이지에서 Apply/confirm/rollback 하면 // drift 가 변하므로 전역 배지를 최신화한다. (apply_engine 이벤트 훅 없이 페이지 전환으로 흡수.) if (pageId === 'net-apply' || prevPage === 'net-apply') { try { refreshUnapplied(); } catch (_) {} } // Close mobile sidebar document.getElementById('sidebar').classList.remove('open'); document.getElementById('sidebar-overlay').classList.remove('show'); } function initNavigation() { document.querySelectorAll('.nav-item').forEach(el => { el.addEventListener('click', async (e) => { e.preventDefault(); // Block disabled leaves (e.g. Firmware placeholder) if (el.classList.contains('disabled') || el.classList.contains('nav-item--disabled')) return; const pageId = el.dataset.page; const currentPage = state.currentPage; // v1.5.0 P1: nav-guard — confirm navigation if current page is dirty if (currentPage && currentPage !== pageId) { const ok = await confirmNavigation(currentPage); if (!ok) return; } showPage(pageId); }); }); document.getElementById('logo-home')?.addEventListener('click', async (e) => { e.preventDefault(); const currentPage = state.currentPage; if (currentPage && currentPage !== 'home') { const ok = await confirmNavigation(currentPage); if (!ok) return; } showPage('home'); }); } /** * Update sidebar tab visibility based on protocol settings. * Called when Register page changes protocol or can_input. */ export function updateTabVisibility() { const protocol = state.protocol?.protocol || 'NONE'; const canInput = state.protocol?.can_input || 'off'; const modbusTab = document.getElementById('nav-modbus'); const opcuaTab = document.getElementById('nav-opcua'); const canTab = document.getElementById('nav-can-bus'); if (modbusTab) modbusTab.classList.toggle('disabled', protocol !== 'MODBUS'); if (opcuaTab) opcuaTab.classList.toggle('disabled', protocol !== 'OPC_UA'); if (canTab) canTab.classList.toggle('disabled', canInput !== 'on'); } // ─── Mobile Sidebar ───────────────────────────────────────── function initSidebar() { const btn = document.getElementById('btn-menu'); const sidebar = document.getElementById('sidebar'); const overlay = document.getElementById('sidebar-overlay'); if (btn) { btn.addEventListener('click', () => { sidebar.classList.toggle('open'); overlay.classList.toggle('show'); }); } if (overlay) { overlay.addEventListener('click', () => { sidebar.classList.remove('open'); overlay.classList.remove('show'); }); } } // ─── Unsaved Changes Warning ──────────────────────────────── function initUnsavedWarning() { window.addEventListener('beforeunload', (e) => { if (state.isDirty) { e.preventDefault(); e.returnValue = ''; } }); } // ─── Actions: Save, Import, Export ────────────────────────── function initActions() { // Save All const saveBtn = document.getElementById('btn-save-all'); const saveMobile = document.getElementById('btn-save-mobile'); if (saveBtn) saveBtn.addEventListener('click', handleSaveAll); if (saveMobile) saveMobile.addEventListener('click', handleSaveAll); // Import const importBtn = document.getElementById('btn-import'); const fileInput = document.getElementById('file-import'); if (importBtn) importBtn.addEventListener('click', () => fileInput?.click()); if (fileInput) fileInput.addEventListener('change', handleImport); // Export const exportBtn = document.getElementById('btn-export'); if (exportBtn) exportBtn.addEventListener('click', handleExport); } /** * Validate ALL pages' state before saving. * Iterates PAGES map calling each page's validate(). * Returns array of error objects: { field, page, message } */ function validateBeforeSave() { const allErrors = []; clearAllFieldErrors(); clearAllPageErrors(); const seen = new Set(); for (const page of Object.values(PAGES)) { if (page && typeof page.validate === 'function' && !seen.has(page)) { seen.add(page); allErrors.push(...page.validate()); } } if (allErrors.length > 0) { // Mark sidebar error indicators const errorPages = new Set(allErrors.map(e => e.page).filter(Boolean)); errorPages.forEach(pageId => setPageError(pageId, true)); // Navigate to first error page and focus field const firstError = allErrors[0]; if (firstError.page && firstError.page !== state.currentPage) { showPage(firstError.page); } // Focus after potential page switch (give DOM time to render) setTimeout(() => focusErrorField(firstError), 50); } return allErrors; } let _saving = false; // Global lock to prevent concurrent saves /** * v1.7.0 — Save All 확인 단계를 Pending Changes 패널로 위임. * * 기존 showSaveAllConfirm 의 단순 "페이지명 나열 + Save/Cancel" 모달을 풍부한 Pending 패널 * (필드별 old→new diff + per-page/전체 되돌리기 + 전체 저장)로 대체한다. 실제 저장은 패널의 * [전체 저장] 버튼 → 주입된 saveAll (= handleSaveAll({skipConfirm:true})) 이 수행한다. * * handleSaveAll({skipConfirm:false}) 계약 보존: 이 경로는 패널을 열고 false 를 반환(이번 호출은 * 직접 저장하지 않음 — 사용자가 패널에서 확정). skipConfirm:true 경로는 종전대로 즉시 저장. */ function showSaveAllConfirm(_dirtyPages) { openPendingPanel(); return Promise.resolve(false); } async function saveSelfContainedPages(dirtyPagesBeforeSave) { for (const pageId of dirtyPagesBeforeSave) { const page = PAGES[pageId]; if (page && typeof page.saveSelfContained === 'function') { await page.saveSelfContained(); } } } /** * v1.5.4.3 U1 fix: exported + returns boolean (true on full success, false otherwise). * skipConfirm=true skips the dirty-pages confirm modal — used by nav-guard's Save button * (which is itself a confirm-level dialog; double-modal is the wrong UX). */ export async function handleSaveAll({ skipConfirm = false } = {}) { // Prevent concurrent save operations if (_saving) return false; // v1.5.0 P4b T3: Show confirm modal listing dirty pages if pageDirty matrix available if (!skipConfirm) { const dirtyPages = getDirtyPages(); if (dirtyPages.length > 0) { const proceed = await showSaveAllConfirm(dirtyPages); if (!proceed) return false; } } _saving = true; let saveSucceeded = false; const btn = document.getElementById('btn-save-all'); const mobileBtn = document.getElementById('btn-save-mobile'); if (btn) { btn.disabled = true; btn.innerHTML = ' Saving...'; } if (mobileBtn) mobileBtn.disabled = true; try { // Collect ALL pages' form data before saving (not just current page) collectAllPagesData(); // Validate ALL pages' state const errors = validateBeforeSave(); if (errors.length > 0) { const errorPages = new Set(errors.map(e => e.page).filter(Boolean)); const msg = errorPages.size > 1 ? `Validation failed in ${errorPages.size} pages: ${errors[0].message}` : `Validation failed: ${errors[0].message}`; showToast(msg, 'warning'); return false; } const dirtyPagesBeforeSave = getDirtyPages(); const devicePayload = buildDevicePayload(); const protocolPayload = buildProtocolPayload(); // Save sequentially (not parallel) to avoid SQLite DB locking conflicts. // v1.4.6.9 H7: 두 save의 성공 여부를 분리해 partial-failure 시 정확한 상태 보고. // 이전 패턴은 saveDevice 결과만 check하고 saveProtocol throw도 deviceRes false로 보였음 — // 또 saveProtocol throw 시 catch 분기로 빠져서 state.isDirty가 그대로 유지 (정상)이지만, // saveProtocol이 200으로 success:false 응답하면 그게 silent 통과되던 path를 명시 처리. const deviceRes = await saveDevice(devicePayload); const protocolRes = await saveProtocol(protocolPayload); const deviceOk = !!(deviceRes && deviceRes.success === true); const protocolOk = !!(protocolRes && protocolRes.success === true); if (deviceOk && protocolOk) { await saveSelfContainedPages(dirtyPagesBeforeSave); clearAllPageErrors(); const warnings = [ ...(Array.isArray(deviceRes.warnings) ? deviceRes.warnings : []), ...(Array.isArray(protocolRes.warnings) ? protocolRes.warnings : []), ]; // v1.8.0 Task 4: clearAllDirty 전에 "네트워크 페이지가 dirty 였는지" 포착. // v1.8.2: 파괴적(eth1/Wi-Fi) 변경 여부도 함께 포착 — User 모드 적용 전 확인 게이트용. const dirtyNetworkPages = dirtyPagesBeforeSave.filter(p => NETWORK_PAGES.has(p)); const networkPageWasDirty = dirtyNetworkPages.length > 0; const disruptiveChange = dirtyNetworkPages.some(p => DISRUPTIVE_NETWORK_PAGES.has(p)); clearAllDirty(); // v1.5.5.6: Save 성공 → 현재 state 를 새 Discard baseline 으로 채택 commitChangesAsBaseline(); // v1.7.0: Save(DB) 직후 미적용(drift) 재계산 — 네트워크 키 저장은 미적용 카운트를 올린다. // v1.7.1 fix: 미적용>0 이면 Apply-needed CTA 만 표시 (이중 토스트 방지). // CTA 자체가 "Saved. Apply needed …" 를 전달하므로 generic success toast 는 억제. // 미적용==0 (또는 drift 조회 실패) 이면 generic toast 를 그대로 표시. let needApply = false; try { await refreshUnapplied(); needApply = getUnapplied() > 0; } catch (_) { /* drift 조회 실패는 Save 성공에 영향 주지 않음 */ } // v1.8.1 (review I-1/minor): 자동 apply 판정은 "이번 Save 가 네트워크 페이지를 건드렸는가" // 로 결정론적으로 한다. 이전엔 `|| needApply`(전역 drift 캐시)도 OR 했으나, 그 캐시는 // 이번 Save 와 무관할 수 있어 (이전 미적용 잔여) 비네트워크 Save 에 apply 가 오발하거나, // 반대로 drift 조회 실패(503/파일부재) 시 네트워크 Save 를 놓쳤다. needApply 는 이제 // Advanced CTA 메시징 용도로만 쓴다. const networkChanged = networkPageWasDirty; const warnHead = () => { const head = warnings.slice(0, 3).join(' · '); const tail = warnings.length > 3 ? ` (+${warnings.length - 3} more)` : ''; return `Saved with warnings: ${head}${tail}`; }; if (!isAdvanced() && networkChanged) { // v1.8.0 ★ User 모드: Save=즉시 적용. 기존 apply 라우트(fields:{}) 자동 트리거 + // "적용 중…"→평이 결과. eth1 관리 IP 변경 시 평이 재접속 확인 배너(절대 숨기지 않음). if (warnings.length > 0) showToast(warnHead(), 'warning'); // v1.8.1 (review minor): 적용 종료 후 drift 재계산 — Advanced 배지/요약의 // stale 'Unapplied N'(이미 적용됐는데 미적용으로 표시) 방지. const triggerApply = () => runSimpleApply({ onResult: () => { refreshUnapplied() .then(() => { try { renderPendingBadge(); } catch (_) {} }) .catch(() => {}); }, }); // fire-and-forget — 자체 진행/결과 토스트 + eth1 배너 if (disruptiveChange) { // v1.8.2 (review prod-safety): 파괴적 변경(eth1/Wi-Fi)은 수동 flow 의 명시 Apply // 클릭 게이트가 사라졌으므로, 적용 전 평이 확인을 둔다. 취소 시 저장은 유지하고 // 적용만 미룬다(재부팅 시 반영). 비파괴적 변경은 종전대로 즉시 적용. confirmModal({ title: 'Apply network changes now?', message: "Saving will reconfigure this device's network connection. " + "This page may briefly lose its connection while the change is applied.", confirmLabel: 'Apply now', cancelLabel: 'Save only', }).then((ok) => { if (ok) triggerApply(); else showToast('Saved. Network changes take effect on the next reboot, ' + 'or Save again to apply now.', 'info'); }); } else { triggerApply(); } } else if (isAdvanced() && needApply) { // Advanced 모드: 기존 v1.7.1 동작 보존 — Apply-needed CTA (수동 Apply&Status 동선). // v1.8.1 (review I-2): 이 분기도 저장 경고를 노출(이전엔 CTA 만 떠 warnings 누락 — User 분기와 비대칭). if (warnings.length > 0) showToast(warnHead(), 'warning'); applyNeededCta(); } else if (warnings.length > 0) { showToast(warnHead(), 'warning'); } else { // 비네트워크 Save (또는 변경 없음): "저장됨 ✓". showToast('Configuration saved successfully.', 'success'); } saveSucceeded = true; } else { // v1.4.6.9 H7: 어느 쪽이 실패했는지 명시. state.isDirty는 그대로 유지 (재시도 가능). const failedParts = []; if (!deviceOk) failedParts.push('device'); if (!protocolOk) failedParts.push('protocol'); const msg = (deviceRes?.message || protocolRes?.message || `Save failed: ${failedParts.join(' + ')}`); showToast(msg, 'warning'); } } catch (e) { console.error('Save failed:', e); showToast('Save failed — ' + e.message, 'error'); } finally { _saving = false; if (btn) { btn.disabled = false; btn.innerHTML = '💾 Save All'; } if (mobileBtn) mobileBtn.disabled = false; } return saveSucceeded; } /** * Collect form data from ALL pages that have been rendered and registered. * Each page module registers a collector via window.__pageCollectors. * Only the currently displayed page will have DOM elements; others use state as-is. */ function collectAllPagesData() { if (!window.__pageCollectors) return; // Collect from the currently displayed page (it has active DOM elements) if (window.__pageCollectors[state.currentPage]) { window.__pageCollectors[state.currentPage](); } } // Expose for page modules to register their data collectors window.__pageCollectors = {}; function handleImport(e) { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { try { const data = JSON.parse(ev.target.result); // Schema validation: ensure recognizable format const isFormatA = data.device && typeof data.device === 'object'; const isFormatB = data.wifi || data.ssid_list || data.eth; const isFormatC = 'wifi_ip' in data || 'eth_ip' in data || 'WIFI_SSID' in data; if (!isFormatA && !isFormatB && !isFormatC) { showToast('Import failed — Unrecognized configuration file format.', 'error'); return; } // Confirm before overwriting — show meta info if available let confirmMsg = 'Current settings will be overwritten with the imported file.'; if (data.meta) { const m = data.meta; const parts = []; if (m.equipment) parts.push(`Equipment: ${m.equipment}`); if (m.protocol) parts.push(`Protocol: ${m.protocol}`); if (m.mac) parts.push(`MAC: ${m.mac}`); if (m.exported_at) parts.push(`Exported: ${m.exported_at.slice(0, 19).replace('T', ' ')}`); if (parts.length) confirmMsg += `\n\n${parts.join('\n')}`; } confirmMsg += '\n\nContinue?'; if (!confirm(confirmMsg)) { return; } // v1.4.6.8 C3: showPage가 새 DOM replace 전에 current-page collector를 호출하므로 // import으로 막 set된 state.device/protocol을 stale DOM 값이 overwrite. import 동안 // collector를 일시 detach → showPage(→mount()) 가 새 collector 등록 → 안전 복원. const pageId = state.currentPage; const prevCollector = window.__pageCollectors ? window.__pageCollectors[pageId] : null; if (window.__pageCollectors) { window.__pageCollectors[pageId] = null; } try { if (isFormatA) { // Format 1: Web configurator export format { device: {...}, protocol: {...} } setDevice(data.device); if (data.protocol) setProtocol(data.protocol); } else if (isFormatB) { // Format 2: Already nested device-only (no wrapper) setDevice(data); } else { // Format 3: Flat format from Java app.jar (wifi_ip, WIFI_SSID, etc.) setDevice(data); } clearAllPageErrors(); showPage(state.currentPage); // Re-render — mount() 가 collector 재등록 updateTabVisibility(); // Imported config is unsaved — flag dirty so nav-guard/beforeunload fire // (otherwise the imported-but-unsaved config is silently lost). state.isDirty = true; IMPORTABLE_CONFIG_PAGES.forEach(p => markDirty(p)); showToast('Configuration imported successfully.', 'success'); } catch (importErr) { // 안전 복원 (mount() 가 호출 안 된 path) if (window.__pageCollectors && prevCollector) { window.__pageCollectors[pageId] = prevCollector; } throw importErr; } } catch (err) { showToast('Import failed — Invalid JSON file format.', 'error'); } }; reader.readAsText(file); e.target.value = ''; // Reset } function handleExport() { collectAllPagesData(); const devicePayload = buildDevicePayload(); const protocolPayload = buildProtocolPayload(); // Equipment & protocol come from the Register page (state.protocol) // Keep raw values for meta (informational), sanitize separately for filename const equipmentRaw = state.protocol?.equipment || 'STS'; const protocolRaw = state.protocol?.protocol || 'NONE'; // Sanitize for filename safety: keep [A-Za-z0-9_-], cap at 32 chars const sanitizeForFile = (s, fallback) => { const cleaned = String(s).replace(/[^A-Za-z0-9_-]/g, '').slice(0, 32); return cleaned || fallback; }; const equipmentForFile = sanitizeForFile(equipmentRaw, 'STS'); const protocolForFile = sanitizeForFile(protocolRaw, 'NONE'); // MAC suffix: last 4 hex chars (uppercase, no colons), padded to 4 const macSuffix = _deviceMac ? _deviceMac.replace(/[^0-9a-fA-F]/g, '').slice(-4).toUpperCase().padStart(4, '0') : 'XXXX'; // Single timestamp shared by dateStr (filename) and exported_at (meta) const now = new Date(); const dateStr = now.toISOString().slice(0, 10); const data = { meta: { mac: _deviceMac, equipment: equipmentRaw, protocol: protocolRaw, exported_at: now.toISOString(), }, device: devicePayload, protocol: protocolPayload, }; const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${equipmentForFile}_${protocolForFile}_${macSuffix}_${dateStr}.json`; a.click(); URL.revokeObjectURL(url); showToast('Configuration exported successfully.', 'success'); } // ─── Theme ────────────────────────────────────────────────── function initTheme() { const saved = localStorage.getItem('theme') || 'dark'; document.documentElement.setAttribute('data-theme', saved); updateThemeIcon(saved); document.getElementById('btn-theme')?.addEventListener('click', () => { const current = document.documentElement.getAttribute('data-theme'); const next = current === 'dark' ? 'light' : 'dark'; document.documentElement.setAttribute('data-theme', next); localStorage.setItem('theme', next); updateThemeIcon(next); }); } function updateThemeIcon(theme) { const btn = document.getElementById('btn-theme'); if (btn) btn.textContent = theme === 'dark' ? '🌙' : '☀️'; } // ─── App Version ──────────────────────────────────────────── function initVersion() { const nameEl = document.getElementById('app-name'); if (nameEl) nameEl.textContent = APP_NAME; const verEl = document.getElementById('app-version'); if (verEl) verEl.textContent = APP_VERSION; } // ─── v1.8.0: Advanced/Debug view ──────────────────────────── /** * Advanced/Debug 보기 토글 초기화. * - localStorage 에서 hydrate → body.advanced 클래스 반영(.advanced-only/.user-only CSS 구동). * - 사이드바 footer 토글 wire (change → setAdvanced). * - 변경 시: body 클래스 갱신 + 현재 페이지 재렌더(Dashboard watchdog 게이팅) + * pending 배지 재렌더(미적용 카운트 모드 분기). User 모드에서 net-apply 에 머물러 있으면 home 으로 리다이렉트. */ function initViewMode() { initViewModeStorage(); reflectAdvancedClass(); const toggle = document.getElementById('advanced-view-toggle'); if (toggle) { toggle.checked = isAdvanced(); toggle.addEventListener('change', () => setAdvanced(toggle.checked)); } onAdvancedChange(() => { reflectAdvancedClass(); const tog = document.getElementById('advanced-view-toggle'); if (tog) tog.checked = isAdvanced(); // User 모드 진입 시 Advanced 전용 페이지(net-apply)에 머물러 있으면 Dashboard 로 이동. if (!isAdvanced() && state.currentPage === 'net-apply') { showPage('home'); } else { // 현재 페이지 재렌더 — Dashboard watchdog 카드 등 모드 종속 표면 갱신. showPage(state.currentPage); } // 전역 배지 재렌더 — User=미저장만 / Advanced=미저장+미적용. try { renderPendingBadge(); } catch (_) {} }); } /** body.advanced 클래스를 현재 모드에 맞춰 토글 (CSS .advanced-only/.user-only 구동). */ function reflectAdvancedClass() { document.body.classList.toggle('advanced', isAdvanced()); }