Browse Source

feat: can 테이블 ui 수정

v2
최원형 3 months ago
parent
commit
3bd6667a76
  1. 337
      src/main/resources/static/css/style.css
  2. 1303
      src/main/resources/static/index.html
  3. 39
      src/main/resources/static/js/api.js
  4. 99
      src/main/resources/static/js/device/deviceData.js
  5. 63
      src/main/resources/static/js/device/deviceFile.js
  6. 88
      src/main/resources/static/js/device/ssid.js
  7. 103
      src/main/resources/static/js/main.js
  8. 69
      src/main/resources/static/js/navigation.js
  9. 30
      src/main/resources/static/js/protocol/can.js
  10. 28
      src/main/resources/static/js/protocol/modbus.js
  11. 28
      src/main/resources/static/js/protocol/opcua.js
  12. 147
      src/main/resources/static/js/protocol/protocolData.js
  13. 105
      src/main/resources/static/js/protocol/protocolValidation.js
  14. 74
      src/main/resources/static/js/state.js
  15. 187
      src/main/resources/static/js/utils/dom.js
  16. 13
      src/main/resources/static/js/utils/download.js
  17. 336
      src/main/resources/static/style.css
  18. 1
      web_configuration

337
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;}

1303
src/main/resources/static/index.html

File diff suppressed because it is too large

39
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);
}

99
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,
};
}

63
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');
});
});
}

88
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,
};
}

103
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);

69
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,
};
}

30
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',
});
}

28
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',
});
}

28
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',
});
}

147
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,
};
}

105
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;
}

74
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 || {}) };
}

187
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,
};
}

13
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);
}

336
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");

1
web_configuration

@ -1 +0,0 @@
Subproject commit fc5ef28ad8971d897f854b1bb8719b3423416195
Loading…
Cancel
Save