diff --git a/src/main/resources/static/css/style.css b/src/main/resources/static/css/style.css new file mode 100644 index 0000000..38a104f --- /dev/null +++ b/src/main/resources/static/css/style.css @@ -0,0 +1,337 @@ +* {margin: 0;padding: 0;box-sizing: border-box} +body {font-family: "Pretendard", sans-serif;min-height: 100vh;display: flex;background: #fff;color: #111} + +/* ───── Sidebar ───── */ +.sidebar {width: 260px;border-right: 1px solid #ddd;padding: 32px 24px;display: flex;flex-direction: column} +.sidebar h2 {font-size: 24px;margin-bottom: 32px} +details.nav { + margin-bottom: 24px +} + +.nav > summary { + list-style: none; + cursor: pointer; + display: flex; + align-items: center; + font-size: 20px +} + +.nav > summary::before { + content: ""; + width: 8px; + height: 8px; + margin-right: 8px; + border-left: 2px solid #111; + border-bottom: 2px solid #111; + transform: rotate(-135deg); + transition: .2s +} + +.nav[open] > summary::before { + transform: rotate(-45deg) +} + +.nav-items { + display: flex; + flex-direction: column; + margin-top: 10px; + padding-left: 16px; + gap: 6px +} + +.nav-items button { + background: none; + border: none; + cursor: pointer; + font-size: 16px; + text-align: left; + padding: 4px 0 +} + +/* ───── Top-bar ───── */ +.topbar { + height: 68px; + background: #d9d9d9; + display: flex; + align-items: center; + padding: 0 32px; + font-size: 28px; + font-weight: 500; + box-shadow: 0 2px 4px rgba(0, 0, 0, .15) inset +} + +/* ───── Main Board ───── */ +.content { + flex: 1; + display: flex; + flex-direction: column +} + +.board { + flex: 1; + overflow: auto; + padding: 40px; + display: flex; + flex-direction: column; + gap: 32px +} + +.card { + background: #fff; + border: 1px solid #e5e5e5; + box-shadow: 0 2px 4px rgba(0, 0, 0, .15) inset; + padding: 32px; + border-radius: 8px +} + +.card h3 { + font-size: 24px; + margin-bottom: 24px +} + +/* SSID rows */ +.ssid-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.ssid-row input.ssid-name, +.ssid-row input.ssid-pw { + width: 200px; + padding: 6px 8px; + font-size: 16px; + border: 1px solid #aaa; + border-radius: 4px; +} + +.ssid-row .toggle-pw { + padding: 6px 10px; + font-size: 14px; + border: 1px solid #aaa; + background-color: #eee; + border-radius: 4px; + cursor: pointer; + min-width: 60px; + text-align: center; +} +.ssid-row .toggle-pw:hover { + background-color: #cecece; +} +.ssid-row .toggle-pw:active { + background-color: #bdbdbd; + transform: scale(0.97); +} + +.ssid-row select { + width: 100px; + padding: 6px 8px; + font-size: 16px; + border: 1px solid #aaa; + border-radius: 4px; +} + +.ssid-row .delete-ssid { + padding: 6px 10px; + font-size: 14px; + background-color: #ffdddd; + border: 1px solid #aaa; + border-radius: 4px; + cursor: pointer; +} + +.ssid-row .delete-ssid:hover { + background-color: #ffaaaa; +} +.ssid-row .delete-ssid:active{ + background-color: #fd9e9e; + transform: scale(0.97); +} + +.icon-button { + font-size: 10px; + background-color: #f0f0f0; + border: none; + padding: 8px 12px; + border-radius: 6px; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.icon-button:hover { + background-color: #dcdcdc; +} + +.icon-button:active { + background-color: #c0c0c0; +} + +.ssid-country-code { + display: flex; + align-items: center; + gap: 12px; + margin-top: 16px; +} + +.ssid-country-code label { + min-width: 100px; + font-weight: 600; +} + +.ssid-country-code input { + width: 80px; + padding: 6px 8px; + font-size: 16px; + border: 1px solid #aaa; + border-radius: 4px; + text-transform: uppercase; +} + +.separate-setting-card { + margin-top: 16px; +} + +.field-hint { + color: #666; + font-size: 13px; +} +/* 3-column grids (Network / Server) */ +.grid-3-col { + display: grid; + grid-template-columns: 180px 220px 100px; /* SSID와 유사하게 축소 */ + align-items: center; + gap: 12px; + margin: 8px 0; +} + +.grid-3-col input { + padding: 6px 8px; + font-size: 16px; + border: 1px solid #aaa; + border-radius: 4px; /* 기존 1px → 4px 통일 */ + width: 100%; /* grid item 내에서만 퍼지므로 유지 가능 */ +} + +.grid-3-col input::placeholder { + color: #999; +} +/* radio */ +.radio-group {display: flex;align-items: center;gap: 24px} + +.radio-label {display: inline-flex;align-items: center;gap: 4px;cursor: pointer} +.radio-label input {margin: 0} +/* I/O */ +.io-top {display: grid;grid-template-columns:repeat(auto-fit, minmax(360px, 1fr));gap: 40px;margin-bottom: 32px} +.io-bot {display: grid; /* Ethernet·LTE와 같은 2열(가변) 그리드 */grid-template-columns:repeat(auto-fit, minmax(360px, 1fr));gap: 40px;} +.io-group {display: flex;flex-direction: column;gap: 8px} +.io-group h4 {font-size: 20px;font-weight: 600;margin-bottom: 16px;display: flex;align-items: center;gap: 8px} +.io-group h4::before {content: "";width: 8px;height: 8px;background: #aaa;display: inline-block} +.io-fieldset {border: 1px solid #bbb;padding: 24px 32px;border-radius: 4px;display: flex;flex-direction: column;gap: 16px;width: 100%} +.io-row {display: grid;grid-template-columns:120px 12px 1fr;align-items: center;gap: 8px} +.io-row input, .io-row select {padding: 4px 6px;border: 1px solid #aaa;border-radius: 4px;font-size: 14px;width: 50%} +.io-row span.label {font-size: 14px;color: #333;display: block;width: 100%;} +.io-row span.label-inline {font-size: 14px;color: #333;display: inline;width: auto} +.io-row span.colon {font-size: 14px;color: #333;text-align: center;} +.reg-io-grid {display: grid;grid-template-columns: 1fr 1fr;gap: 4px} +.reg-io-grid .io-fieldset .io-row {display: grid;grid-template-columns: 140px 220px;align-items: center;gap: 4px;margin-bottom: 8px;} + +/* config 내부 라벨 */ +.reg-io-grid .io-fieldset .io-row span.label {font-size: 14px;color: #333;} +.reg-io-grid .io-fieldset .io-row input, +.reg-io-grid .io-fieldset .io-row select {padding: 4px 6px;border: 1px solid #aaa;border-radius: 4px;font-size: 14px;width: 100%;box-sizing: border-box;} +.reg-io-grid .io-fieldset .io-row.checkbox-group {display: flex;align-items: center;gap: 16px;grid-column: 2 / 3;} +.reg-io-grid .io-fieldset .io-row .checkbox-with-text {display: flex;align-items: center;gap: 10px;width: 100%;} +.reg-io-grid .io-fieldset .io-row .checkbox-with-text input[type="checkbox"] {width: auto;margin: 0;} + +/* 비활성 input 시각화 */ +.disabled {background: #f5f5f5;cursor: not-allowed} +/* Footer Buttons */ +.actions {display: flex;gap: 24px;justify-content: center;padding: 20px} +.actions button {min-width: 140px;padding: 10px 18px;font-size: 16px;border: 1px solid #bbb;background: #d9d9d9;border-radius: 4px;cursor: pointer} +.actions button:hover {background-color: #bdbdbd;} +.actions button:active {background-color: #bdbdbd; transform: scale(0.97);} +.config-actions{display: flex;gap: 24px;justify-content: center;background: #f7f7f7;border: 1px solid #bbb;border-radius: 4px;margin: 24px 0 0 0;padding: 20px 0;} +.config-actions button{min-width:140px;padding:10px 18px;font-size:16px;border:1px solid #bbb;background:#d9d9d9;border-radius:4px;cursor:pointer} +.topbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 32px; + height: 68px; + background: #d9d9d9; + font-size: 28px; + font-weight: 500; + box-shadow: 0 2px 4px rgba(0, 0, 0, .15) inset; +} + +.save-file { + font-size: 16px; + padding: 8px 16px; + background-color: #4caf50; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; +} +.save-file:hover { + background-color: #45a049;; +} +.save-file:active { + background-color: #3e8e41; + transform: scale(0.97); +} +/* MODBUS 화면 심플 스타일 */ +#page-modbus .card {background: #f7f7f7;border: 1px solid #bbb;box-shadow: none;padding: 24px;border-radius: 4px;display: flex;flex-direction: column;} +#page-modbus table {width: 100%;border-collapse: collapse;background: #fff;border: 1px solid #bbb;} +#page-modbus th, #page-modbus td {border: 1px solid #bbb;padding: 6px 8px;text-align: left;background: #fff;} +#page-modbus th {background: #e5e5e5;font-weight: 500;} +#page-modbus tbody td {height: 34px;vertical-align: middle;} +#page-modbus .modbus-input-area {display: grid;grid-template-columns: 200px 200px 200px 200px;gap: 20px 20px;margin: 24px auto 16px auto;background: #eaeaea;padding: 16px;border-radius: 4px;margin-left: auto;margin-right: auto;} +#page-modbus .input-row {display: contents;} +#page-modbus .modbus-input-area label.nowrap {white-space: nowrap;} +#page-modbus .modbus-input-area label {min-width: auto;color: #444;white-space: normal;} +#page-modbus .modbus-input-area input {background: #e0e0e0;border: none;border-radius: 2px;padding: 4px 8px;min-width: auto;} +#page-modbus .modbus-actions {display: flex;gap: 24px;justify-content: center;margin: 16px 0 0 0;} +#page-modbus .modbus-actions button {background: #d3d3d3;border: 1px solid #aaa;border-radius: 2px;padding: 8px 24px;font-size: 16px;cursor: pointer;} +/* 하단 버튼 영역 */ +#modbus-bottom-actions {display: flex;gap: 24px;justify-content: center;background: #f7f7f7;border: 1px solid #bbb;border-radius: 4px;margin: 24px 0 0 0;padding: 20px 0;} +#modbus-bottom-actions button {background: #e0e0e0;border: 1px solid #aaa;border-radius: 2px;padding: 10px 18px;font-size: 16px;min-width: 140px;cursor: pointer;} + +/* OPC_UA 화면 */ +#page-opcua .card {background: #f7f7f7;border: 1px solid #bbb;box-shadow: none;padding: 24px;border-radius: 4px;display: flex;flex-direction: column;} +#page-opcua table {width: 100%;border-collapse: collapse;background: #fff;border: 1px solid #bbb;} +#page-opcua th, #page-opcua td {border: 1px solid #bbb;padding: 6px 8px;text-align: left;background: #fff;} +#page-opcua th {background: #e5e5e5;font-weight: 500;} +#page-opcua tbody td {height: 34px;vertical-align: middle;} +#page-opcua .opcua-input-area {display: grid;grid-template-columns: 200px 200px 200px 200px;gap: 20px 20px;margin: 24px auto 16px auto;background: #eaeaea;padding: 16px;border-radius: 4px;margin-left: auto;margin-right: auto;} +#page-opcua .input-row {display: contents;} +#page-opcua .opcua-input-area label.nowrap {white-space: nowrap;} +#page-opcua .opcua-input-area label {min-width: auto;color: #444;white-space: normal;} +#page-opcua .opcua-input-area input {background: #e0e0e0;border: none;border-radius: 2px;padding: 4px 8px;min-width: auto;} +#page-opcua .opcua-actions {display: flex;gap: 24px;justify-content: center;margin: 16px 0 0 0;} +#page-opcua .opcua-actions button {background: #d3d3d3;border: 1px solid #aaa;border-radius: 2px;padding: 8px 24px;font-size: 16px;cursor: pointer;} +#opcUa-bottom-actions {display: flex;gap: 24px;justify-content: center;background: #f7f7f7;border: 1px solid #bbb;border-radius: 4px;margin: 24px 0 0 0;padding: 20px 0;} +#opcUa-bottom-actions button {background: #e0e0e0;border: 1px solid #aaa;border-radius: 2px;padding: 10px 18px;font-size: 16px;min-width: 140px;cursor: pointer;} + +/* CAN 화면 스타일 */ +#page-can .card {background: #f7f7f7;border: 1px solid #bbb;box-shadow: none;padding: 24px;border-radius: 4px;display: flex;flex-direction: column;} +#page-can table {width: 100%;border-collapse: collapse;background: #fff;border: 1px solid #bbb;} +#page-can th, #page-can td {border: 1px solid #bbb;padding: 6px 8px;text-align: left;background: #fff;} +#page-can th {background: #e5e5e5;font-weight: 500;} +#page-can tbody td {height: 34px;vertical-align: middle;} +#page-can tbody td > span:empty::before {content: "\00a0";} +#page-can .can-input-area {display: grid;grid-template-columns: 200px 200px 200px 200px;gap: 20px 20px;margin: 24px auto 16px auto;background: #eaeaea;padding: 16px;border-radius: 4px;margin-left: auto;margin-right: auto;} +#page-can .input-row {display: contents;} +#page-can .can-input-area label.nowrap {white-space: nowrap;} +#page-can .can-input-area label {min-width: auto;color: #444;white-space: normal;} +#page-can .can-input-area input {background: #e0e0e0;border: none;border-radius: 2px;padding: 4px 8px;min-width: auto;} +#page-can .can-actions {display: flex;gap: 24px;justify-content: center;margin: 16px 0 0 0;} +#page-can .can-actions button {background: #d3d3d3;border: 1px solid #aaa;border-radius: 2px;padding: 8px 24px;font-size: 16px;cursor: pointer;} +#can-bottom-actions {display: flex;gap: 24px;justify-content: center;background: #f7f7f7;border: 1px solid #bbb;border-radius: 4px;margin: 24px 0 0 0;padding: 20px 0;} +#can-bottom-actions button {background: #e0e0e0;border: 1px solid #aaa;border-radius: 2px;padding: 10px 18px;font-size: 16px;min-width: 140px;cursor: pointer;} + +.selected-row {background: #d0e6ff !important;} +.selected-row > td {background: #d0e6ff !important;} + +.file-upload-section{padding: 10px;margin: 10px;text-align: center;} diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index d070140..24018b7 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -1,13 +1,13 @@ - + Device Configuration UI - + - + - +
SSID @@ -38,7 +38,7 @@
- +

SSID List

@@ -55,7 +55,7 @@
- +

Region

@@ -78,7 +78,7 @@
- + - + - + - +
@@ -606,1278 +613,8 @@ - - + + + diff --git a/src/main/resources/static/js/api.js b/src/main/resources/static/js/api.js new file mode 100644 index 0000000..12ab0d7 --- /dev/null +++ b/src/main/resources/static/js/api.js @@ -0,0 +1,39 @@ +async function fetchJson(url) { + const response = await fetch(url); + + if (response.status === 204) { + return null; + } + + if (!response.ok) { + throw new Error(`Request failed: ${response.status}`); + } + + return response.json(); +} + +async function postJson(url, payload) { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + return response; +} + +export function loadDeviceConfig() { + return fetchJson('/setting/get-device'); +} + +export function loadProtocolConfig() { + return fetchJson('/setting/get-protocol'); +} + +export function saveDeviceConfig(payload) { + return postJson('/setting/device', payload); +} + +export function saveProtocolConfig(payload) { + return postJson('/setting/protocol', payload); +} diff --git a/src/main/resources/static/js/device/deviceData.js b/src/main/resources/static/js/device/deviceData.js new file mode 100644 index 0000000..d7ec9dc --- /dev/null +++ b/src/main/resources/static/js/device/deviceData.js @@ -0,0 +1,99 @@ +import { cloneDeviceDefaults } from '../state.js'; +import { byId, queryAll } from '../utils/dom.js'; + +const NUMBER_FIELDS = new Set([ + 'can_baudrate', + 'rs485_baudrate', + 'log_max_size', + 'log_max_duration', +]); + +export function createDeviceDataController({ ssidController }) { + const netFields = ['wifi_ip', 'wifi_netmask', 'wifi_gateway', 'wifi_dns1', 'wifi_dns2'] + .map((id) => byId(id)) + .filter(Boolean); + + function toggleNetInputs() { + const isStatic = document.querySelector('input[name="ipMode"]:checked')?.value === 'static'; + + netFields.forEach((element) => { + element.disabled = !isStatic; + element.classList.toggle('disabled', !isStatic); + }); + } + + function bindEvents() { + queryAll('input[name="ipMode"]').forEach((radio) => { + radio.addEventListener('change', toggleNetInputs); + }); + } + + function loadFromData(data = cloneDeviceDefaults()) { + const radio = document.querySelector( + `input[name="ipMode"][value="${data.wifi_static === 'on' ? 'static' : 'dhcp'}"]`, + ); + + if (radio) { + radio.checked = true; + } + + Object.entries(data).forEach(([key, value]) => { + const element = byId(key); + if (element) { + element.value = value ?? ''; + } + }); + + ssidController.loadRows(data.WIFI_SSID || []); + toggleNetInputs(); + } + + function collectDeviceData() { + const result = cloneDeviceDefaults(); + + result.wifi_static = document.querySelector('input[name="ipMode"]:checked')?.value === 'static' + ? 'on' + : 'off'; + + Object.keys(result).forEach((key) => { + const element = byId(key); + if (!element) { + return; + } + + const value = element.value.trim(); + result[key] = NUMBER_FIELDS.has(key) ? (value === '' ? null : Number(value)) : value; + }); + + result.WIFI_SSID = ssidController.gatherRows(); + + if (typeof result.wifi_country_code === 'string') { + result.wifi_country_code = result.wifi_country_code.trim().toUpperCase(); + } + + return Object.entries(result).reduce((acc, [key, value]) => { + if (value === null) { + return acc; + } + + if (typeof value === 'string' && value.trim() === '') { + return acc; + } + + acc[key] = value; + return acc; + }, {}); + } + + function initialize() { + bindEvents(); + loadFromData(); + } + + return { + collectDeviceData, + initialize, + loadFromData, + toggleNetInputs, + }; +} diff --git a/src/main/resources/static/js/device/deviceFile.js b/src/main/resources/static/js/device/deviceFile.js new file mode 100644 index 0000000..a2af545 --- /dev/null +++ b/src/main/resources/static/js/device/deviceFile.js @@ -0,0 +1,63 @@ +import { queryAll } from '../utils/dom.js'; +import { downloadJson } from '../utils/download.js'; + +function bindLocalJsonUpload(wrapperSelector, inputSelector, buttonSelector, onLoad) { + queryAll(wrapperSelector).forEach((wrapper) => { + const input = wrapper.querySelector(inputSelector); + const button = wrapper.querySelector(buttonSelector); + const fileType = wrapper.dataset.fileType; + + if (!input || !button) { + return; + } + + button.addEventListener('click', () => { + input.value = ''; + input.click(); + }); + + input.addEventListener('change', (event) => { + const [file] = event.target.files; + if (!file) { + return; + } + + const reader = new FileReader(); + reader.onload = (loadEvent) => { + try { + onLoad(JSON.parse(loadEvent.target.result)); + } catch (error) { + alert(`Error uploading file: ${error.message} (${fileType})`); + } finally { + input.value = ''; + } + }; + reader.readAsText(file); + }); + }); +} + +export function bindDeviceFileActions({ onLoadLocal, getDownloadData }) { + bindLocalJsonUpload('.file-upload-wrapper-device', '.upload-input-device', '.select-file-btn-device', onLoadLocal); + + queryAll('.download-device-file').forEach((button) => { + button.addEventListener('click', () => { + downloadJson(getDownloadData(), 'deviceConfig.json'); + }); + }); +} + +export function bindProtocolFileActions({ onLoadLocal, getDownloadData }) { + bindLocalJsonUpload( + '.file-upload-wrapper-protocol', + '.upload-input-protocol', + '.select-file-btn-protocol', + onLoadLocal, + ); + + queryAll('.download-protocol-file').forEach((button) => { + button.addEventListener('click', () => { + downloadJson(getDownloadData(), 'protocolConfig.json'); + }); + }); +} diff --git a/src/main/resources/static/js/device/ssid.js b/src/main/resources/static/js/device/ssid.js new file mode 100644 index 0000000..c9b5a82 --- /dev/null +++ b/src/main/resources/static/js/device/ssid.js @@ -0,0 +1,88 @@ +import { cloneTemplateRow } from '../utils/dom.js'; + +export function createSsidController({ listElement, templateElement, addButton, countryCodeInput }) { + function createRow(values = { wifi_ssid: '', wifi_passwd: '', wifi_security: 'none' }) { + const row = cloneTemplateRow(templateElement); + + row.querySelector('.ssid-name').value = values.wifi_ssid ?? ''; + row.querySelector('.ssid-pw').value = values.wifi_passwd ?? ''; + row.querySelector('select').value = (values.wifi_security ?? 'none').toLowerCase(); + + const deleteButton = document.createElement('button'); + deleteButton.type = 'button'; + deleteButton.textContent = 'Delete'; + deleteButton.dataset.action = 'delete-ssid'; + deleteButton.title = 'delete ssid'; + deleteButton.classList.add('delete-ssid'); + + row.appendChild(deleteButton); + return row; + } + + function addRow(values) { + listElement.appendChild(createRow(values)); + } + + function removeRow(row) { + row?.remove(); + } + + function gatherRows() { + return Array.from(listElement.querySelectorAll('.ssid-row')).reduce((acc, row) => { + const wifi_ssid = row.querySelector('.ssid-name').value.trim(); + const wifi_passwd = row.querySelector('.ssid-pw').value.trim(); + const wifi_security = row.querySelector('select').value; + + if (!wifi_ssid && !wifi_passwd) { + return acc; + } + + if (wifi_ssid) { + acc.push({ wifi_ssid, wifi_passwd, wifi_security }); + } + + return acc; + }, []); + } + + function loadRows(rows = []) { + listElement.innerHTML = ''; + + rows.forEach((row) => addRow(row)); + + if (!listElement.children.length) { + addRow(); + } + } + + function bindEvents() { + addButton?.addEventListener('click', () => addRow()); + + listElement.addEventListener('click', (event) => { + if (event.target.classList.contains('toggle-pw')) { + const passwordInput = event.target.closest('.ssid-row').querySelector('.ssid-pw'); + passwordInput.type = passwordInput.type === 'password' ? 'text' : 'password'; + event.target.textContent = passwordInput.type === 'password' ? 'show' : 'hide'; + return; + } + + if (event.target.dataset.action === 'delete-ssid') { + removeRow(event.target.closest('.ssid-row')); + } + }); + + countryCodeInput?.addEventListener('input', () => { + countryCodeInput.value = countryCodeInput.value + .replace(/[^a-zA-Z]/g, '') + .slice(0, 2) + .toUpperCase(); + }); + } + + return { + addRow, + bindEvents, + gatherRows, + loadRows, + }; +} diff --git a/src/main/resources/static/js/main.js b/src/main/resources/static/js/main.js new file mode 100644 index 0000000..e55bbb7 --- /dev/null +++ b/src/main/resources/static/js/main.js @@ -0,0 +1,103 @@ +import { loadDeviceConfig, loadProtocolConfig, saveDeviceConfig, saveProtocolConfig } from './api.js'; +import { createNavigation } from './navigation.js'; +import { createDeviceDataController } from './device/deviceData.js'; +import { bindDeviceFileActions, bindProtocolFileActions } from './device/deviceFile.js'; +import { createSsidController } from './device/ssid.js'; +import { createCanController } from './protocol/can.js'; +import { createModbusController } from './protocol/modbus.js'; +import { createOpcUaController } from './protocol/opcua.js'; +import { createProtocolDataController } from './protocol/protocolData.js'; +import { isValidRegisterConfig } from './protocol/protocolValidation.js'; +import { byId, queryAll } from './utils/dom.js'; + +async function initializeApp() { + const navigation = createNavigation(); + const ssidController = createSsidController({ + listElement: byId('ssid-list'), + templateElement: byId('ssid-template'), + addButton: byId('add-ssid'), + countryCodeInput: byId('wifi_country_code'), + }); + + const deviceController = createDeviceDataController({ ssidController }); + const protocolController = createProtocolDataController({ + navigation, + modbusController: createModbusController(), + opcUaController: createOpcUaController(), + canController: createCanController(), + }); + + navigation.initialize(); + ssidController.bindEvents(); + deviceController.initialize(); + protocolController.initialize(); + + bindDeviceFileActions({ + onLoadLocal: (data) => deviceController.loadFromData(data), + getDownloadData: () => deviceController.collectDeviceData(), + }); + + bindProtocolFileActions({ + onLoadLocal: (data) => { + if (!isValidRegisterConfig(data)) { + alert('The contents of the selected file are not in a valid protocol setting file format.'); + return; + } + + protocolController.loadFromRegisterJson(data); + }, + getDownloadData: () => protocolController.collectProtocolData(), + }); + + queryAll('.save-file').forEach((button) => { + button.addEventListener('click', async () => { + const devicePayload = deviceController.collectDeviceData(); + const protocolPayload = protocolController.normalizeRegisterForPost( + protocolController.collectProtocolData(), + ); + + if (!isValidRegisterConfig(protocolPayload)) { + alert('Register settings are currently invalid. Cannot be saved.'); + return; + } + + try { + const [deviceResponse, protocolResponse] = await Promise.all([ + saveDeviceConfig(devicePayload), + saveProtocolConfig(protocolPayload), + ]); + + if (deviceResponse.ok && protocolResponse.ok) { + alert('saved successfully'); + return; + } + + alert('save failure'); + console.error('device response:', deviceResponse.status, 'protocol response:', protocolResponse.status); + } catch (error) { + console.error('Error during transfer:', error); + alert('save failure'); + } + }); + }); + + try { + const deviceData = await loadDeviceConfig(); + if (deviceData) { + deviceController.loadFromData(deviceData); + } + } catch (error) { + console.error('Device load failed:', error); + } + + try { + const protocolData = await loadProtocolConfig(); + if (protocolData) { + protocolController.loadFromRegisterJson(protocolData); + } + } catch (error) { + console.error('Protocol load failed:', error); + } +} + +document.addEventListener('DOMContentLoaded', initializeApp); diff --git a/src/main/resources/static/js/navigation.js b/src/main/resources/static/js/navigation.js new file mode 100644 index 0000000..ece1325 --- /dev/null +++ b/src/main/resources/static/js/navigation.js @@ -0,0 +1,69 @@ +import { byId, queryAll } from './utils/dom.js'; + +export function createNavigation() { + const pages = { + ssid: byId('page-ssid'), + network: byId('page-network'), + io: byId('page-io'), + log: byId('page-log'), + reg: byId('page-reg'), + modbus: byId('page-modbus'), + opcua: byId('page-opcua'), + can: byId('page-can'), + }; + + const titleElement = byId('page-title'); + const sidebarButtons = queryAll('.nav-items button'); + + function showPage(pageName) { + Object.values(pages).forEach((page) => { + if (page) { + page.style.display = 'none'; + } + }); + + if (pages[pageName]) { + pages[pageName].style.display = 'block'; + } + + const sourceButton = document.querySelector(`.nav-items button[data-page="${pageName}"]`); + titleElement.textContent = sourceButton?.textContent ?? pageName; + } + + function toggleProtocolMenus(protocolValue, canInputValue) { + const modbusSidebarBtn = byId('modbusSidebarBtn'); + const opcUaSidebarBtn = byId('opcUaSidebarBtn'); + const canSidebarBtn = byId('canSidebarBtn'); + + if (modbusSidebarBtn) { + modbusSidebarBtn.style.display = protocolValue === 'MODBUS' ? 'block' : 'none'; + } + + if (opcUaSidebarBtn) { + opcUaSidebarBtn.style.display = protocolValue === 'OPC_UA' ? 'block' : 'none'; + } + + if (canSidebarBtn) { + canSidebarBtn.style.display = canInputValue === 'on' ? 'block' : 'none'; + } + + const protocolDetails = queryAll('details.nav')[1]; + if (protocolDetails) { + protocolDetails.open = true; + } + } + + function initialize() { + sidebarButtons.forEach((button) => { + button.addEventListener('click', () => showPage(button.dataset.page)); + }); + + showPage('ssid'); + } + + return { + initialize, + showPage, + toggleProtocolMenus, + }; +} diff --git a/src/main/resources/static/js/protocol/can.js b/src/main/resources/static/js/protocol/can.js new file mode 100644 index 0000000..da68174 --- /dev/null +++ b/src/main/resources/static/js/protocol/can.js @@ -0,0 +1,30 @@ +import { byId, createTableEditor } from '../utils/dom.js'; + +export function createCanController() { + return createTableEditor({ + listElement: byId('can-field-list'), + templateElement: byId('can-row-template'), + selectors: { + field: '.can-field-display', + id: '.can-id-display', + odt: '.can-odt-display', + dv: '.can-dv-display', + shift: '.can-shift-display', + expr: '.can-expr-display', + mask: '.can-mask-display', + }, + inputIds: { + field: 'can_field_input', + id: 'can_id_input', + odt: 'can_odt_input', + dv: 'can_dv_input', + shift: 'can_shift_input', + expr: 'can_expr_input', + mask: 'can_mask_input', + }, + addButtonId: 'can_add_btn', + modifyButtonId: 'can_modify_btn', + deleteButtonId: 'can_delete_btn', + emptyInputSelector: '.can-input-area input, .can-input-area select', + }); +} diff --git a/src/main/resources/static/js/protocol/modbus.js b/src/main/resources/static/js/protocol/modbus.js new file mode 100644 index 0000000..037f713 --- /dev/null +++ b/src/main/resources/static/js/protocol/modbus.js @@ -0,0 +1,28 @@ +import { byId, createTableEditor } from '../utils/dom.js'; + +export function createModbusController() { + return createTableEditor({ + listElement: byId('modbus-field-list'), + templateElement: byId('modbus-row-template'), + selectors: { + field: '.field-display', + addr: '.addr-display', + idt: '.idt-display', + odt: '.odt-display', + dv: '.dv-display', + expr: '.expr-display', + }, + inputIds: { + field: 'modbus_field_input', + addr: 'modbus_addr_input', + idt: 'modbus_idt_input', + odt: 'modbus_odt_input', + dv: 'modbus_dv_input', + expr: 'modbus_expr_input', + }, + addButtonId: 'modbus_add_btn', + modifyButtonId: 'modbus_modify_btn', + deleteButtonId: 'modbus_delete_btn', + emptyInputSelector: '.modbus-input-area input, .modbus-input-area select', + }); +} diff --git a/src/main/resources/static/js/protocol/opcua.js b/src/main/resources/static/js/protocol/opcua.js new file mode 100644 index 0000000..a1c0d5b --- /dev/null +++ b/src/main/resources/static/js/protocol/opcua.js @@ -0,0 +1,28 @@ +import { byId, createTableEditor } from '../utils/dom.js'; + +export function createOpcUaController() { + return createTableEditor({ + listElement: byId('opcua-field-list'), + templateElement: byId('opcua-row-template'), + selectors: { + field: '.opcua-field-display', + addr: '.opcua-addr-display', + ns: '.opcua-ns-display', + odt: '.opcua-odt-display', + dv: '.opcua-dv-display', + expr: '.opcua-expr-display', + }, + inputIds: { + field: 'opcua_field_input', + addr: 'opcua_addr_input', + ns: 'opcua_ns_input', + odt: 'opcua_odt_input', + dv: 'opcua_dv_input', + expr: 'opcua_expr_input', + }, + addButtonId: 'opcua_add_btn', + modifyButtonId: 'opcua_modify_btn', + deleteButtonId: 'opcua_delete_btn', + emptyInputSelector: '.opcua-input-area input, .opcua-input-area select', + }); +} diff --git a/src/main/resources/static/js/protocol/protocolData.js b/src/main/resources/static/js/protocol/protocolData.js new file mode 100644 index 0000000..a68ae33 --- /dev/null +++ b/src/main/resources/static/js/protocol/protocolData.js @@ -0,0 +1,147 @@ +import { REGISTER_ID_MAP, getLastLoadedRegisterData, setLastLoadedRegisterData } from '../state.js'; +import { byId } from '../utils/dom.js'; + +export const normProtocol = (value) => String(value ?? '').trim().replace(/[-\s]+/g, '_').toUpperCase(); +export const getOpcArr = (data) => data?.OPC_UA ?? data?.['OPC-UA'] ?? []; + +const checkboxIds = ['ai0', 'ai1', 'ai2', 'ai3', 'di0', 'di1', 'di2', 'di3']; + +export function normalizeRegisterForPost(register) { + const normalized = JSON.parse(JSON.stringify(register || {})); + const toUpperSnake = (value) => String(value ?? '').trim().toUpperCase().replace(/[-\s]+/g, '_'); + + if (normalized.protocol) { + normalized.protocol = toUpperSnake(normalized.protocol); + } + + if (normalized.can_input) { + normalized.can_input = String(normalized.can_input).trim().toUpperCase(); + } + + if (normalized.MEID !== undefined && normalized.MEID !== null) { + const meid = String(normalized.MEID).trim(); + normalized.MEID = /^\d+$/.test(meid) ? Number(meid) : meid; + } + + ['dev_type', 'equipment', 'speed_data'].forEach((key) => { + if (normalized[key]) { + normalized[key] = toUpperSnake(normalized[key]); + } + }); + + Object.keys(normalized).forEach((key) => { + if (normalized[key] === '') { + delete normalized[key]; + } + }); + + if (normalized['OPC-UA'] && !normalized.OPC_UA) { + normalized.OPC_UA = normalized['OPC-UA']; + delete normalized['OPC-UA']; + } + + return normalized; +} + +export function createProtocolDataController({ navigation, modbusController, opcUaController, canController }) { + const protocolSelect = byId('reg_protocol'); + const canInputSelect = byId('reg_can_input'); + + function syncProtocolMenus() { + navigation.toggleProtocolMenus(protocolSelect?.value, canInputSelect?.value); + } + + function bindEvents() { + protocolSelect?.addEventListener('change', syncProtocolMenus); + canInputSelect?.addEventListener('change', syncProtocolMenus); + } + + function collectProtocolData() { + const register = getLastLoadedRegisterData(); + + Object.entries(REGISTER_ID_MAP).forEach(([htmlId, dtoKey]) => { + const element = byId(htmlId); + if (element) { + register[dtoKey] = element.value !== null && element.value !== undefined + ? element.value.trim() + : ''; + } + }); + + checkboxIds.forEach((id) => { + const checkbox = byId(id); + register[id] = checkbox && checkbox.type === 'checkbox' ? (checkbox.checked ? '1' : '0') : '0'; + }); + + register.MODBUS = modbusController.serializeRows('field'); + register.OPC_UA = opcUaController.serializeRows('field'); + register.CAN = canController.serializeRows('field'); + + return register; + } + + function loadFromRegisterJson(register) { + if (!register) { + return; + } + + setLastLoadedRegisterData(register); + + Object.entries(REGISTER_ID_MAP).forEach(([htmlId, dtoKey]) => { + const element = byId(htmlId); + if (!element) { + return; + } + + if (Object.prototype.hasOwnProperty.call(register, dtoKey)) { + const value = register[dtoKey]; + if (element.type === 'checkbox') { + element.checked = value === '1' || value === true; + } else { + element.value = value ?? ''; + } + return; + } + + if (element.type === 'checkbox') { + element.checked = false; + } else { + element.value = ''; + } + }); + + checkboxIds.forEach((id) => { + const checkbox = byId(id); + if (checkbox) { + checkbox.checked = register[id] === '1' || register[id] === true; + } + }); + + if (register.protocol && protocolSelect) { + const protocol = normProtocol(register.protocol); + protocolSelect.value = protocol; + setLastLoadedRegisterData({ ...register, protocol }); + } + + modbusController.renderRows(Array.isArray(register.MODBUS) ? register.MODBUS : []); + opcUaController.renderRows(Array.isArray(getOpcArr(register)) ? getOpcArr(register) : []); + canController.renderRows(Array.isArray(register.CAN) ? register.CAN : []); + syncProtocolMenus(); + } + + function initialize() { + modbusController.initialize(); + opcUaController.initialize(); + canController.initialize(); + bindEvents(); + syncProtocolMenus(); + } + + return { + collectProtocolData, + initialize, + loadFromRegisterJson, + normalizeRegisterForPost, + syncProtocolMenus, + }; +} diff --git a/src/main/resources/static/js/protocol/protocolValidation.js b/src/main/resources/static/js/protocol/protocolValidation.js new file mode 100644 index 0000000..b033ae2 --- /dev/null +++ b/src/main/resources/static/js/protocol/protocolValidation.js @@ -0,0 +1,105 @@ +import { getOpcArr, normProtocol } from './protocolData.js'; + +export function isValidRegisterConfig(data) { + if (typeof data !== 'object' || data === null) { + console.error('Validation Error: Data is not an object or is null.'); + return false; + } + + if (typeof data.protocol === 'string') { + data.protocol = normProtocol(data.protocol); + } + + const requiredFields = [ + 'protocol', + 'analog_input_level', + 'can_input', + 'dev_type', + 'dr_on', + 'equipment', + 'two_byte_order', + 'four_byte_order', + 'speed_data', + 'version', + 'equipment_id', + 'MEID', + ]; + + for (const field of requiredFields) { + if (!Object.prototype.hasOwnProperty.call(data, field)) { + console.error(`Validation Error: Missing required top-level field: '${field}'`); + return false; + } + } + + const validProtocols = ['MODBUS', 'OPC_UA', 'CAN', 'NONE']; + if (typeof data.protocol !== 'string' || !validProtocols.includes(data.protocol)) { + console.error(`Validation Error: Invalid protocol '${data.protocol}'`); + return false; + } + + if (typeof data.analog_input_level !== 'string' && typeof data.analog_input_level !== 'number') { + console.error("Validation Error: 'analog_input_level' is not a string or number."); + return false; + } + + if (data.protocol === 'MODBUS') { + if (!Array.isArray(data.MODBUS)) { + console.error("Validation Error: 'MODBUS' must be an array when protocol is MODBUS."); + return false; + } + + for (const [index, item] of data.MODBUS.entries()) { + for (const key of ['field', 'idt', 'addr', 'odt', 'dv']) { + if (!item || String(item[key] ?? '').trim() === '') { + console.error(`Validation Error: MODBUS row ${index} missing '${key}'.`); + return false; + } + } + } + } else if (Array.isArray(data.MODBUS) && data.MODBUS.length > 0) { + console.error("Validation Error: 'MODBUS' must be empty when protocol is not MODBUS."); + return false; + } + + if (data.protocol === 'OPC_UA') { + const opcRows = getOpcArr(data); + if (!Array.isArray(opcRows)) { + console.error("Validation Error: 'OPC_UA' must be an array when protocol is OPC_UA."); + return false; + } + + for (const [index, item] of opcRows.entries()) { + for (const key of ['field', 'ns', 'addr', 'odt', 'dv']) { + if (!item || String(item[key] ?? '').trim() === '') { + console.error(`Validation Error: OPC_UA row ${index} missing '${key}'.`); + return false; + } + } + } + } else if (data.OPC_UA && !Array.isArray(data.OPC_UA)) { + console.error("Validation Error: 'OPC_UA' must be an array if present."); + return false; + } + + if (String(data.can_input).toLowerCase() === 'on') { + if (!Array.isArray(data.CAN)) { + console.error("Validation Error: 'CAN' is required when can_input is on."); + return false; + } + + for (const [index, item] of data.CAN.entries()) { + for (const key of ['field', 'id', 'odt', 'dv']) { + if (!item || String(item[key] ?? '').trim() === '') { + console.error(`Validation Error: CAN row ${index} missing '${key}'.`); + return false; + } + } + } + } else if (Array.isArray(data.CAN) && data.CAN.length > 0) { + console.error("Validation Error: 'CAN' must be empty when can_input is off."); + return false; + } + + return true; +} diff --git a/src/main/resources/static/js/state.js b/src/main/resources/static/js/state.js new file mode 100644 index 0000000..8a26a2c --- /dev/null +++ b/src/main/resources/static/js/state.js @@ -0,0 +1,74 @@ +export const DEVICE_DEFAULTS = { + wifi_static: '', + wifi_ip: '', + wifi_netmask: '', + wifi_gateway: '', + wifi_dns1: '', + wifi_dns2: '', + wifi_country_code: '', + eth_ip: '', + eth_netmask: '', + eth_gateway: '', + protocol_server_ip: '', + protocol_server_port: null, + update_server_ip: '', + update_server_port: null, + rtcm_server_ip: '', + rtcm_server_port: null, + opc_ua_server_ip: '', + opc_ua_server_port: null, + modbus_server_ip: '', + modbus_server_port: null, + lte_server_ip: '', + lte_server_port: null, + can_bus_type: '', + can_baudrate: null, + rs485_mode: '', + rs485_baudrate: null, + rs485_databits: null, + rs485_parity: '', + rs485_stopbits: null, + lte_ip: '', + lte_netmask: '', + lte_gateway: '', + lte_port: null, + log_save: 'off', + log_max_size: null, + log_max_duration: null, + WIFI_SSID: [{ wifi_ssid: '', wifi_passwd: '', wifi_security: 'none' }], +}; + +export const REGISTER_ID_MAP = { + reg_analog_input_level: 'analog_input_level', + ai0: 'ai0', + ai1: 'ai1', + di0: 'di0', + di1: 'di1', + reg_can_input: 'can_input', + reg_device_type: 'dev_type', + reg_dr_on_off: 'dr_on', + reg_equipment: 'equipment', + reg_two_byte_order: 'two_byte_order', + reg_four_byte_order: 'four_byte_order', + reg_protocol: 'protocol', + reg_speed_data_source: 'speed_data', + reg_version: 'version', + reg_equipment_id: 'equipment_id', + reg_meid: 'MEID', +}; + +const appState = { + lastLoadedRegisterData: {}, +}; + +export function cloneDeviceDefaults() { + return JSON.parse(JSON.stringify(DEVICE_DEFAULTS)); +} + +export function getLastLoadedRegisterData() { + return { ...appState.lastLoadedRegisterData }; +} + +export function setLastLoadedRegisterData(data) { + appState.lastLoadedRegisterData = { ...(data || {}) }; +} diff --git a/src/main/resources/static/js/utils/dom.js b/src/main/resources/static/js/utils/dom.js new file mode 100644 index 0000000..b77c1ad --- /dev/null +++ b/src/main/resources/static/js/utils/dom.js @@ -0,0 +1,187 @@ +export function byId(id) { + return document.getElementById(id); +} + +export function queryAll(selector, root = document) { + return Array.from(root.querySelectorAll(selector)); +} + +export function setElementValue(element, value) { + if (!element) { + return; + } + + element.value = value ?? ''; +} + +export function cloneTemplateRow(templateElement) { + return templateElement.content.firstElementChild.cloneNode(true); +} + +export function createTableEditor({ + listElement, + templateElement, + selectors, + inputIds, + addButtonId, + modifyButtonId, + deleteButtonId, + emptyInputSelector = 'input', +}) { + let selectedRow = null; + + function resetSelection() { + selectedRow = null; + queryAll('tr', listElement).forEach((row) => row.classList.remove('selected-row')); + } + + function clearInputs() { + Object.values(inputIds).forEach((id) => { + const element = byId(id); + if (element) { + element.value = ''; + } + }); + + queryAll(emptyInputSelector).forEach((element) => { + if (!Object.values(inputIds).includes(element.id)) { + element.value = ''; + } + }); + } + + function fillRow(row, values = {}) { + Object.entries(selectors).forEach(([key, selector]) => { + const target = row.querySelector(selector); + if (target) { + target.textContent = values[key] ?? ''; + } + }); + } + + function createRow(values = {}) { + const row = cloneTemplateRow(templateElement); + fillRow(row, values); + return row; + } + + function ensureEmptyRow() { + if (!listElement.children.length) { + listElement.appendChild(createRow()); + } + } + + function renderRows(rows = []) { + listElement.innerHTML = ''; + + if (rows.length) { + rows.forEach((row) => listElement.appendChild(createRow(row))); + } else { + ensureEmptyRow(); + } + + resetSelection(); + } + + function rowToObject(row) { + const output = {}; + + Object.entries(selectors).forEach(([key, selector]) => { + const value = row.querySelector(selector)?.textContent?.trim(); + if (value) { + output[key] = value; + } + }); + + return output; + } + + function serializeRows(requiredKey) { + return queryAll('tr', listElement) + .map((row) => rowToObject(row)) + .filter((row) => row[requiredKey]); + } + + function getInputValues() { + return Object.entries(inputIds).reduce((acc, [key, id]) => { + acc[key] = byId(id)?.value ?? ''; + return acc; + }, {}); + } + + function loadInputsFromRow(row) { + Object.entries(inputIds).forEach(([key, id]) => { + const value = row.querySelector(selectors[key])?.textContent ?? ''; + setElementValue(byId(id), value); + }); + } + + function addRowFromInputs() { + const values = getInputValues(); + const firstRow = listElement.querySelector('tr'); + const firstRequired = firstRow?.querySelector(selectors.field)?.textContent?.trim(); + + if (firstRow && !firstRequired) { + fillRow(firstRow, values); + } else { + listElement.appendChild(createRow(values)); + } + + clearInputs(); + resetSelection(); + } + + function modifySelectedRow() { + if (!selectedRow) { + alert('Please select the row you want to modify'); + return; + } + + fillRow(selectedRow, getInputValues()); + } + + function deleteSelectedRow() { + if (!selectedRow) { + alert('Select the row you want to delete'); + return; + } + + selectedRow.remove(); + clearInputs(); + resetSelection(); + ensureEmptyRow(); + } + + function bindSelection() { + listElement.addEventListener('click', (event) => { + const row = event.target.closest('tr'); + if (!row) { + return; + } + + resetSelection(); + row.classList.add('selected-row'); + selectedRow = row; + loadInputsFromRow(row); + }); + } + + function bindActions() { + byId(addButtonId)?.addEventListener('click', addRowFromInputs); + byId(modifyButtonId)?.addEventListener('click', modifySelectedRow); + byId(deleteButtonId)?.addEventListener('click', deleteSelectedRow); + } + + function initialize() { + bindSelection(); + bindActions(); + ensureEmptyRow(); + } + + return { + clearInputs, + initialize, + renderRows, + serializeRows, + }; +} diff --git a/src/main/resources/static/js/utils/download.js b/src/main/resources/static/js/utils/download.js new file mode 100644 index 0000000..ad70af3 --- /dev/null +++ b/src/main/resources/static/js/utils/download.js @@ -0,0 +1,13 @@ +export function downloadJson(data, filename) { + const json = JSON.stringify(data, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); +} diff --git a/src/main/resources/static/style.css b/src/main/resources/static/style.css index 3f06eb1..55b06ed 100644 --- a/src/main/resources/static/style.css +++ b/src/main/resources/static/style.css @@ -1,335 +1 @@ -* {margin: 0;padding: 0;box-sizing: border-box} -body {font-family: "Pretendard", sans-serif;min-height: 100vh;display: flex;background: #fff;color: #111} - -/* ───── Sidebar ───── */ -.sidebar {width: 260px;border-right: 1px solid #ddd;padding: 32px 24px;display: flex;flex-direction: column} -.sidebar h2 {font-size: 24px;margin-bottom: 32px} -details.nav { - margin-bottom: 24px -} - -.nav > summary { - list-style: none; - cursor: pointer; - display: flex; - align-items: center; - font-size: 20px -} - -.nav > summary::before { - content: ""; - width: 8px; - height: 8px; - margin-right: 8px; - border-left: 2px solid #111; - border-bottom: 2px solid #111; - transform: rotate(-135deg); - transition: .2s -} - -.nav[open] > summary::before { - transform: rotate(-45deg) -} - -.nav-items { - display: flex; - flex-direction: column; - margin-top: 10px; - padding-left: 16px; - gap: 6px -} - -.nav-items button { - background: none; - border: none; - cursor: pointer; - font-size: 16px; - text-align: left; - padding: 4px 0 -} - -/* ───── Top-bar ───── */ -.topbar { - height: 68px; - background: #d9d9d9; - display: flex; - align-items: center; - padding: 0 32px; - font-size: 28px; - font-weight: 500; - box-shadow: 0 2px 4px rgba(0, 0, 0, .15) inset -} - -/* ───── Main Board ───── */ -.content { - flex: 1; - display: flex; - flex-direction: column -} - -.board { - flex: 1; - overflow: auto; - padding: 40px; - display: flex; - flex-direction: column; - gap: 32px -} - -.card { - background: #fff; - border: 1px solid #e5e5e5; - box-shadow: 0 2px 4px rgba(0, 0, 0, .15) inset; - padding: 32px; - border-radius: 8px -} - -.card h3 { - font-size: 24px; - margin-bottom: 24px -} - -/* SSID rows */ -.ssid-row { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 8px; -} - -.ssid-row input.ssid-name, -.ssid-row input.ssid-pw { - width: 200px; - padding: 6px 8px; - font-size: 16px; - border: 1px solid #aaa; - border-radius: 4px; -} - -.ssid-row .toggle-pw { - padding: 6px 10px; - font-size: 14px; - border: 1px solid #aaa; - background-color: #eee; - border-radius: 4px; - cursor: pointer; - min-width: 60px; - text-align: center; -} -.ssid-row .toggle-pw:hover { - background-color: #cecece; -} -.ssid-row .toggle-pw:active { - background-color: #bdbdbd; - transform: scale(0.97); -} - -.ssid-row select { - width: 100px; - padding: 6px 8px; - font-size: 16px; - border: 1px solid #aaa; - border-radius: 4px; -} - -.ssid-row .delete-ssid { - padding: 6px 10px; - font-size: 14px; - background-color: #ffdddd; - border: 1px solid #aaa; - border-radius: 4px; - cursor: pointer; -} - -.ssid-row .delete-ssid:hover { - background-color: #ffaaaa; -} -.ssid-row .delete-ssid:active{ - background-color: #fd9e9e; - transform: scale(0.97); -} - -.icon-button { - font-size: 10px; - background-color: #f0f0f0; - border: none; - padding: 8px 12px; - border-radius: 6px; - cursor: pointer; - transition: background-color 0.2s ease; -} - -.icon-button:hover { - background-color: #dcdcdc; -} - -.icon-button:active { - background-color: #c0c0c0; -} - -.ssid-country-code { - display: flex; - align-items: center; - gap: 12px; - margin-top: 16px; -} - -.ssid-country-code label { - min-width: 100px; - font-weight: 600; -} - -.ssid-country-code input { - width: 80px; - padding: 6px 8px; - font-size: 16px; - border: 1px solid #aaa; - border-radius: 4px; - text-transform: uppercase; -} - -.separate-setting-card { - margin-top: 16px; -} - -.field-hint { - color: #666; - font-size: 13px; -} -/* 3-column grids (Network / Server) */ -.grid-3-col { - display: grid; - grid-template-columns: 180px 220px 100px; /* SSID와 유사하게 축소 */ - align-items: center; - gap: 12px; - margin: 8px 0; -} - -.grid-3-col input { - padding: 6px 8px; - font-size: 16px; - border: 1px solid #aaa; - border-radius: 4px; /* 기존 1px → 4px 통일 */ - width: 100%; /* grid item 내에서만 퍼지므로 유지 가능 */ -} - -.grid-3-col input::placeholder { - color: #999; -} -/* radio */ -.radio-group {display: flex;align-items: center;gap: 24px} - -.radio-label {display: inline-flex;align-items: center;gap: 4px;cursor: pointer} -.radio-label input {margin: 0} -/* I/O */ -.io-top {display: grid;grid-template-columns:repeat(auto-fit, minmax(360px, 1fr));gap: 40px;margin-bottom: 32px} -.io-bot {display: grid; /* Ethernet·LTE와 같은 2열(가변) 그리드 */grid-template-columns:repeat(auto-fit, minmax(360px, 1fr));gap: 40px;} -.io-group {display: flex;flex-direction: column;gap: 8px} -.io-group h4 {font-size: 20px;font-weight: 600;margin-bottom: 16px;display: flex;align-items: center;gap: 8px} -.io-group h4::before {content: "";width: 8px;height: 8px;background: #aaa;display: inline-block} -.io-fieldset {border: 1px solid #bbb;padding: 24px 32px;border-radius: 4px;display: flex;flex-direction: column;gap: 16px;width: 100%} -.io-row {display: grid;grid-template-columns:120px 12px 1fr;align-items: center;gap: 8px} -.io-row input, .io-row select {padding: 4px 6px;border: 1px solid #aaa;border-radius: 4px;font-size: 14px;width: 50%} -.io-row span.label {font-size: 14px;color: #333;display: block;width: 100%;} -.io-row span.label-inline {font-size: 14px;color: #333;display: inline;width: auto} -.io-row span.colon {font-size: 14px;color: #333;text-align: center;} -.reg-io-grid {display: grid;grid-template-columns: 1fr 1fr;gap: 4px} -.reg-io-grid .io-fieldset .io-row {display: grid;grid-template-columns: 140px 220px;align-items: center;gap: 4px;margin-bottom: 8px;} - -/* config 내부 라벨 */ -.reg-io-grid .io-fieldset .io-row span.label {font-size: 14px;color: #333;} -.reg-io-grid .io-fieldset .io-row input, -.reg-io-grid .io-fieldset .io-row select {padding: 4px 6px;border: 1px solid #aaa;border-radius: 4px;font-size: 14px;width: 100%;box-sizing: border-box;} -.reg-io-grid .io-fieldset .io-row.checkbox-group {display: flex;align-items: center;gap: 16px;grid-column: 2 / 3;} -.reg-io-grid .io-fieldset .io-row .checkbox-with-text {display: flex;align-items: center;gap: 10px;width: 100%;} -.reg-io-grid .io-fieldset .io-row .checkbox-with-text input[type="checkbox"] {width: auto;margin: 0;} - -/* 비활성 input 시각화 */ -.disabled {background: #f5f5f5;cursor: not-allowed} -/* Footer Buttons */ -.actions {display: flex;gap: 24px;justify-content: center;padding: 20px} -.actions button {min-width: 140px;padding: 10px 18px;font-size: 16px;border: 1px solid #bbb;background: #d9d9d9;border-radius: 4px;cursor: pointer} -.actions button:hover {background-color: #bdbdbd;} -.actions button:active {background-color: #bdbdbd; transform: scale(0.97);} -.config-actions{display: flex;gap: 24px;justify-content: center;background: #f7f7f7;border: 1px solid #bbb;border-radius: 4px;margin: 24px 0 0 0;padding: 20px 0;} -.config-actions button{min-width:140px;padding:10px 18px;font-size:16px;border:1px solid #bbb;background:#d9d9d9;border-radius:4px;cursor:pointer} -.topbar { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0 32px; - height: 68px; - background: #d9d9d9; - font-size: 28px; - font-weight: 500; - box-shadow: 0 2px 4px rgba(0, 0, 0, .15) inset; -} - -.save-file { - font-size: 16px; - padding: 8px 16px; - background-color: #4caf50; - color: white; - border: none; - border-radius: 4px; - cursor: pointer; -} -.save-file:hover { - background-color: #45a049;; -} -.save-file:active { - background-color: #3e8e41; - transform: scale(0.97); -} -/* MODBUS 화면 심플 스타일 */ -#page-modbus .card {background: #f7f7f7;border: 1px solid #bbb;box-shadow: none;padding: 24px;border-radius: 4px;display: flex;flex-direction: column;} -#page-modbus table {width: 100%;border-collapse: collapse;background: #fff;border: 1px solid #bbb;} -#page-modbus th, #page-modbus td {border: 1px solid #bbb;padding: 6px 8px;text-align: left;background: #fff;} -#page-modbus th {background: #e5e5e5;font-weight: 500;} -#page-modbus tbody td {height: 34px;vertical-align: middle;} -#page-modbus .modbus-input-area {display: grid;grid-template-columns: 200px 200px 200px 200px;gap: 20px 20px;margin: 24px auto 16px auto;background: #eaeaea;padding: 16px;border-radius: 4px;margin-left: auto;margin-right: auto;} -#page-modbus .input-row {display: contents;} -#page-modbus .modbus-input-area label.nowrap {white-space: nowrap;} -#page-modbus .modbus-input-area label {min-width: auto;color: #444;white-space: normal;} -#page-modbus .modbus-input-area input {background: #e0e0e0;border: none;border-radius: 2px;padding: 4px 8px;min-width: auto;} -#page-modbus .modbus-actions {display: flex;gap: 24px;justify-content: center;margin: 16px 0 0 0;} -#page-modbus .modbus-actions button {background: #d3d3d3;border: 1px solid #aaa;border-radius: 2px;padding: 8px 24px;font-size: 16px;cursor: pointer;} -/* 하단 버튼 영역 */ -#modbus-bottom-actions {display: flex;gap: 24px;justify-content: center;background: #f7f7f7;border: 1px solid #bbb;border-radius: 4px;margin: 24px 0 0 0;padding: 20px 0;} -#modbus-bottom-actions button {background: #e0e0e0;border: 1px solid #aaa;border-radius: 2px;padding: 10px 18px;font-size: 16px;min-width: 140px;cursor: pointer;} - -/* OPC_UA 화면 */ -#page-opcua .card {background: #f7f7f7;border: 1px solid #bbb;box-shadow: none;padding: 24px;border-radius: 4px;display: flex;flex-direction: column;} -#page-opcua table {width: 100%;border-collapse: collapse;background: #fff;border: 1px solid #bbb;} -#page-opcua th, #page-opcua td {border: 1px solid #bbb;padding: 6px 8px;text-align: left;background: #fff;} -#page-opcua th {background: #e5e5e5;font-weight: 500;} -#page-opcua tbody td {height: 34px;vertical-align: middle;} -#page-opcua .opcua-input-area {display: grid;grid-template-columns: 200px 200px 200px 200px;gap: 20px 20px;margin: 24px auto 16px auto;background: #eaeaea;padding: 16px;border-radius: 4px;margin-left: auto;margin-right: auto;} -#page-opcua .input-row {display: contents;} -#page-opcua .opcua-input-area label.nowrap {white-space: nowrap;} -#page-opcua .opcua-input-area label {min-width: auto;color: #444;white-space: normal;} -#page-opcua .opcua-input-area input {background: #e0e0e0;border: none;border-radius: 2px;padding: 4px 8px;min-width: auto;} -#page-opcua .opcua-actions {display: flex;gap: 24px;justify-content: center;margin: 16px 0 0 0;} -#page-opcua .opcua-actions button {background: #d3d3d3;border: 1px solid #aaa;border-radius: 2px;padding: 8px 24px;font-size: 16px;cursor: pointer;} -#opcUa-bottom-actions {display: flex;gap: 24px;justify-content: center;background: #f7f7f7;border: 1px solid #bbb;border-radius: 4px;margin: 24px 0 0 0;padding: 20px 0;} -#opcUa-bottom-actions button {background: #e0e0e0;border: 1px solid #aaa;border-radius: 2px;padding: 10px 18px;font-size: 16px;min-width: 140px;cursor: pointer;} - -/* CAN 화면 스타일 */ -#page-can .card {background: #f7f7f7;border: 1px solid #bbb;box-shadow: none;padding: 24px;border-radius: 4px;display: flex;flex-direction: column;} -#page-can table {width: 100%;border-collapse: collapse;background: #fff;border: 1px solid #bbb;} -#page-can th, #page-can td {border: 1px solid #bbb;padding: 6px 8px;text-align: left;background: #fff;} -#page-can th {background: #e5e5e5;font-weight: 500;} -#page-can .can-input-area {display: grid;grid-template-columns: 200px 200px 200px 200px;gap: 20px 20px;margin: 24px auto 16px auto;background: #eaeaea;padding: 16px;border-radius: 4px;margin-left: auto;margin-right: auto;} -#page-can .input-row {display: contents;} -#page-can .can-input-area label.nowrap {white-space: nowrap;} -#page-can .can-input-area label {min-width: auto;color: #444;white-space: normal;} -#page-can .can-input-area input {background: #e0e0e0;border: none;border-radius: 2px;padding: 4px 8px;min-width: auto;} -#page-can .can-actions {display: flex;gap: 24px;justify-content: center;margin: 16px 0 0 0;} -#page-can .can-actions button {background: #d3d3d3;border: 1px solid #aaa;border-radius: 2px;padding: 8px 24px;font-size: 16px;cursor: pointer;} -#can-bottom-actions {display: flex;gap: 24px;justify-content: center;background: #f7f7f7;border: 1px solid #bbb;border-radius: 4px;margin: 24px 0 0 0;padding: 20px 0;} -#can-bottom-actions button {background: #e0e0e0;border: 1px solid #aaa;border-radius: 2px;padding: 10px 18px;font-size: 16px;min-width: 140px;cursor: pointer;} - -.selected-row {background: #d0e6ff !important;} -.selected-row > td {background: #d0e6ff !important;} - -.file-upload-section{padding: 10px;margin: 10px;text-align: center;} +@import url("./css/style.css"); diff --git a/web_configuration b/web_configuration deleted file mode 160000 index fc5ef28..0000000 --- a/web_configuration +++ /dev/null @@ -1 +0,0 @@ -Subproject commit fc5ef28ad8971d897f854b1bb8719b3423416195