/**
* 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());
}