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.
916 lines
42 KiB
916 lines
42 KiB
/**
|
|
* 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';
|
|
// 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';
|
|
// 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
|
|
// Telemetry Uplink: per-target /32 host routes for cloud telemetry/RTCM/update.
|
|
import uplinkPage from './pages/uplink.js';
|
|
// 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 = {
|
|
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
|
|
uplink: uplinkPage, // Telemetry Uplink — self-contained Save&Apply
|
|
};
|
|
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
|
|
'icon-nav-uplink': { name: 'upload', size: 16 }, // Telemetry Uplink (distinct from can-bus 'route')
|
|
};
|
|
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 ───────────────────────────────────────────
|
|
// Exported for direct test access (avoids needing to fire DOMContentLoaded).
|
|
export 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;
|
|
// B6: clear the guard flag — a successful load means Save All is safe.
|
|
state.configLoadFailed = 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');
|
|
// B6: set the guard flag — Save All must not run with empty defaults.
|
|
state.configLoadFailed = true;
|
|
// 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;
|
|
|
|
// B6: refuse to save when the initial config load failed — empty defaults would
|
|
// overwrite the real DB. Cleared by a successful loadAllData().
|
|
if (state.configLoadFailed) {
|
|
showToast('Configuration failed to load — reload the page before saving.', 'error');
|
|
const btn = document.getElementById('btn-save-all');
|
|
if (btn) btn.disabled = true;
|
|
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 = '<span>⌛</span> 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 = '<span>💾</span> 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());
|
|
}
|
|
|