diff --git a/.gitignore b/.gitignore index de4b16b..fb01baf 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,7 @@ shared/ # MQTT ignored files (무시할 파일들) fems-mqtt/data/ -fems-mqtt/log/ \ No newline at end of file +fems-mqtt/log/ + +fems-realtime-api/node_modules/ +fems-realtime-api/logs/* \ No newline at end of file diff --git a/fems-api/src/controllers/app/device/device.controller.js b/fems-api/src/controllers/app/device/device.controller.js new file mode 100644 index 0000000..10c6294 --- /dev/null +++ b/fems-api/src/controllers/app/device/device.controller.js @@ -0,0 +1,194 @@ +// src/controllers/app/device/device.controller.js +const express = require("express"); +const router = express.Router(); +const authMiddleware = require("../../../middleware/auth.middleware"); +const roleCheck = require("../../../middleware/roleCheck.middleware"); +const deviceService = require("../../../services/device.service"); +const logger = require("../../../config/logger"); +const { body, query } = require("express-validator"); +const validate = require("../../../middleware/validator.middleware"); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authMiddleware); +router.use(roleCheck(["super_admin", "company_admin", "branch_admin", "user"])); + +// 데이터 포인트 유효성 검사 규칙 +const dataPointValidationRules = [ + body("DataPoints.*.tag_name").notEmpty().withMessage("Tag name is required"), + body("DataPoints.*.data_type") + .isIn([ + "BOOL", + "INT16", + "UINT16", + "INT32", + "UINT32", + "FLOAT", + "DOUBLE", + "STRING", + ]) + .withMessage("Valid data type is required"), + body("DataPoints.*.address").notEmpty().withMessage("Address is required"), + body("DataPoints.*.register_type") + .optional() + .isIn(["COIL", "DISCRETE_INPUT", "HOLDING_REGISTER", "INPUT_REGISTER"]), + body("DataPoints.*.scan_rate") + .isInt({ min: 100 }) + .withMessage("Scan rate must be at least 100ms"), + body("DataPoints.*.scale_factor").optional().isFloat(), + body("DataPoints.*.offset").optional().isFloat(), +]; + +// 디바이스 등록 (데이터 포인트 포함) +router.post( + "/", + [ + // 디바이스 기본 정보 validation + body("device_type").notEmpty().withMessage("Device type is required"), + body("device_name").notEmpty().withMessage("Device name is required"), + body("location").optional().isString(), + body("manufacturer").optional().isString(), + body("model").optional().isString(), + + // 연결 설정 validation + body("DeviceConnection") + .isObject() + .withMessage("Connection settings are required"), + body("DeviceConnection.protocol") + .isIn(["MODBUS_TCP", "MODBUS_RTU", "OPC_UA", "MQTT", "BACNET"]) + .withMessage("Valid protocol is required"), + body("DeviceConnection.ip_address") + .if(body("DeviceConnection.protocol").equals("MODBUS_TCP")) + .isIP() + .withMessage("Valid IP address is required for Modbus TCP"), + body("DeviceConnection.port") + .if(body("DeviceConnection.protocol").equals("MODBUS_TCP")) + .isInt({ min: 1, max: 65535 }) + .withMessage("Valid port number is required"), + + // 데이터 포인트 validation + ...dataPointValidationRules, + + validate, + ], + async (req, res, next) => { + try { + const deviceData = { + ...req.body, + status: "ACTIVE", + }; + + const device = await deviceService.createDevice(deviceData); + res.status(201).json(device); + } catch (error) { + logger.error("Failed to create device:", error); + next(error); + } + } +); + +// 디바이스 수정 (데이터 포인트 포함) +router.put( + "/:id", + [ + // 디바이스 기본 정보 validation + body("device_name").optional().isString(), + body("location").optional().isString(), + body("status") + .optional() + .isIn(["ACTIVE", "INACTIVE", "MAINTENANCE", "ERROR"]), + + // 연결 설정 validation (수정 시) + body("DeviceConnection").optional().isObject(), + body("DeviceConnection.protocol") + .optional() + .isIn(["MODBUS_TCP", "MODBUS_RTU", "OPC_UA", "MQTT", "BACNET"]), + + // 데이터 포인트 validation + ...dataPointValidationRules, + + validate, + ], + async (req, res, next) => { + try { + const deviceId = req.params.id; + const updateData = req.body; + + const updated = await deviceService.updateDevice(deviceId, updateData); + + if (!updated) { + return res.status(404).json({ + message: "Device not found", + }); + } + + res.json(updated); + } catch (error) { + logger.error("Failed to update device:", error); + next(error); + } + } +); + +// 디바이스 목록 조회 +router.get( + "/", + [ + query("device_type").optional().isString(), + query("status") + .optional() + .isIn(["ACTIVE", "INACTIVE", "MAINTENANCE", "ERROR"]), + validate, + ], + async (req, res, next) => { + try { + const filters = req.query; + const devices = await deviceService.getDevices(filters); + res.json(devices); + } catch (error) { + logger.error("Failed to get devices:", error); + next(error); + } + } +); + +// 디바이스 상세 조회 +router.get("/:id", async (req, res, next) => { + try { + const deviceId = req.params.id; + const device = await deviceService.getDeviceDetail(deviceId); + + if (!device) { + return res.status(404).json({ + message: "Device not found", + }); + } + + res.json(device); + } catch (error) { + logger.error("Failed to get device detail:", error); + next(error); + } +}); + +// 디바이스 삭제 +router.delete("/:id", async (req, res, next) => { + try { + const deviceId = req.params.id; + const result = await deviceService.deleteDevice(deviceId); + + if (!result) { + return res.status(404).json({ + message: "Device not found", + }); + } + + res.status(200).json({ + message: "Device deleted successfully", + }); + } catch (error) { + logger.error("Failed to delete device:", error); + next(error); + } +}); + +module.exports = router; diff --git a/fems-api/src/models/DataPoint.js b/fems-api/src/models/DataPoint.js new file mode 100644 index 0000000..dc34273 --- /dev/null +++ b/fems-api/src/models/DataPoint.js @@ -0,0 +1,94 @@ +// src/models/DataPoint.js +const { Model, DataTypes } = require("sequelize"); + +class DataPoint extends Model { + static init(sequelize) { + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + tag_name: { + type: DataTypes.STRING(100), + allowNull: false, + comment: "데이터 포인트 태그명", + }, + description: { + type: DataTypes.STRING(200), + comment: "설명", + }, + data_type: { + type: DataTypes.ENUM( + "BOOL", + "INT16", + "UINT16", + "INT32", + "UINT32", + "FLOAT", + "DOUBLE", + "STRING" + ), + allowNull: false, + comment: "데이터 타입", + }, + address: { + type: DataTypes.STRING(50), + allowNull: false, + comment: "메모리 주소 또는 레지스터 번호", + }, + register_type: { + type: DataTypes.ENUM( + "COIL", + "DISCRETE_INPUT", + "HOLDING_REGISTER", + "INPUT_REGISTER" + ), + comment: "Modbus 레지스터 타입", + }, + scan_rate: { + type: DataTypes.INTEGER, + defaultValue: 1000, + comment: "스캔 주기 (ms)", + }, + scale_factor: { + type: DataTypes.FLOAT, + defaultValue: 1, + comment: "스케일 팩터", + }, + offset: { + type: DataTypes.FLOAT, + defaultValue: 0, + comment: "오프셋", + }, + unit: { + type: DataTypes.STRING(20), + comment: "단위", + }, + is_active: { + type: DataTypes.BOOLEAN, + defaultValue: true, + comment: "활성화 여부", + }, + created_at: DataTypes.DATE, + updated_at: DataTypes.DATE, + }, + { + sequelize, + modelName: "DataPoint", + tableName: "data_points", + timestamps: true, + updatedAt: "updated_at", + createdAt: "created_at", + } + ); + return this; + } + + static associate(models) { + this.belongsTo(models.Device, { foreignKey: "deviceId" }); + } +} + +module.exports = DataPoint; diff --git a/fems-api/src/models/Device.js b/fems-api/src/models/Device.js new file mode 100644 index 0000000..8158db6 --- /dev/null +++ b/fems-api/src/models/Device.js @@ -0,0 +1,86 @@ +// src/models/Device.js +const { Model, DataTypes } = require("sequelize"); + +class Device extends Model { + static init(sequelize) { + return super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + device_type: { + type: DataTypes.STRING(50), + allowNull: false, + comment: "디바이스 유형 (PLC, PowerMeter, Sensor 등)", + }, + device_name: { + type: DataTypes.STRING(100), + allowNull: false, + comment: "디바이스 표시 이름", + }, + manufacturer: { + type: DataTypes.STRING(100), + comment: "제조사", + }, + model: { + type: DataTypes.STRING(100), + comment: "모델명", + }, + serial_number: { + type: DataTypes.STRING(100), + comment: "시리얼 번호", + }, + location: { + type: DataTypes.STRING(100), + comment: "설치 위치", + }, + description: { + type: DataTypes.TEXT, + comment: "설명", + }, + status: { + type: DataTypes.ENUM("ACTIVE", "INACTIVE", "MAINTENANCE", "ERROR"), + defaultValue: "ACTIVE", + comment: "디바이스 상태", + }, + created_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + updated_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + // 새로 추가할 필드들 + companyId: { + type: DataTypes.UUID, + comment: "회사 ID", + }, + branchId: { + type: DataTypes.UUID, + comment: "지점 ID", + }, + }, + { + sequelize, + modelName: "Device", + tableName: "devices", + timestamps: true, + updatedAt: "updated_at", + createdAt: "created_at", + } + ); + } + + static associate(models) { + this.belongsTo(models.Company, { foreignKey: "companyId" }); + this.belongsTo(models.Branch, { foreignKey: "branchId" }); + this.hasOne(models.DeviceConnection, { foreignKey: "deviceId" }); + this.hasMany(models.DataPoint, { foreignKey: "deviceId" }); + this.hasMany(models.DeviceStatus, { foreignKey: "deviceId" }); + } +} + +module.exports = Device; diff --git a/fems-api/src/models/DeviceConnection.js b/fems-api/src/models/DeviceConnection.js new file mode 100644 index 0000000..4a88fb3 --- /dev/null +++ b/fems-api/src/models/DeviceConnection.js @@ -0,0 +1,70 @@ +// src/models/DeviceConnection.js +const { Model, DataTypes } = require("sequelize"); + +class DeviceConnection extends Model { + static init(sequelize) { + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + protocol: { + type: DataTypes.ENUM( + "MODBUS_TCP", + "MODBUS_RTU", + "OPC_UA", + "MQTT", + "BACNET" + ), + allowNull: false, + comment: "통신 프로토콜", + }, + ip_address: { + type: DataTypes.STRING(45), + comment: "IP 주소", + }, + port: { + type: DataTypes.INTEGER, + comment: "포트 번호", + }, + unit_id: { + type: DataTypes.INTEGER, + comment: "Modbus Unit ID", + }, + connection_timeout: { + type: DataTypes.INTEGER, + defaultValue: 1000, + comment: "연결 타임아웃 (ms)", + }, + retry_interval: { + type: DataTypes.INTEGER, + defaultValue: 5000, + comment: "재시도 간격 (ms)", + }, + protocol_settings: { + type: DataTypes.JSONB, + comment: "프로토콜별 추가 설정", + }, + created_at: DataTypes.DATE, + updated_at: DataTypes.DATE, + }, + { + sequelize, + modelName: "DeviceConnection", + tableName: "device_connections", + timestamps: true, + updatedAt: "updated_at", + createdAt: "created_at", + } + ); + return this; + } + + static associate(models) { + this.belongsTo(models.Device, { foreignKey: "deviceId" }); + } +} + +module.exports = DeviceConnection; diff --git a/fems-api/src/models/DeviceStatus.js b/fems-api/src/models/DeviceStatus.js new file mode 100644 index 0000000..c769d30 --- /dev/null +++ b/fems-api/src/models/DeviceStatus.js @@ -0,0 +1,35 @@ +// src/models/DeviceStatus.js +const { Model, DataTypes } = require("sequelize"); + +class DeviceStatus extends Model { + static init(sequelize) { + super.init( + { + time: { + type: DataTypes.DATE, + allowNull: false, + }, + status: { + type: DataTypes.STRING(20), + allowNull: false, + }, + status_detail: { + type: DataTypes.JSONB, + }, + }, + { + sequelize, + modelName: "DeviceStatus", + tableName: "device_status", + timestamps: false, + } + ); + return this; + } + + static associate(models) { + this.belongsTo(models.Device, { foreignKey: "deviceId" }); + } +} + +module.exports = DeviceStatus; diff --git a/fems-api/src/routes/app.js b/fems-api/src/routes/app.js index 8407ccb..323cc7e 100644 --- a/fems-api/src/routes/app.js +++ b/fems-api/src/routes/app.js @@ -15,6 +15,7 @@ const equipmentPartsController = require("../controllers/app/equipmentParts/equi const departmentController = require("../controllers/app/department/department.controller"); const healthController = require("../controllers/app/health/health.controller"); const companiesController = require("../controllers/admin/companies/companies.controller"); +const deviceController = require("../controllers/app/device/device.controller"); router.use("/health", healthController); router.use("/auth", authController); @@ -29,5 +30,6 @@ router.use("/parts", partsController); router.use("/equipment-parts", equipmentPartsController); router.use("/department", departmentController); router.use("/companies", companiesController); +router.use("/devices", deviceController); module.exports = router; diff --git a/fems-api/src/services/device.service.js b/fems-api/src/services/device.service.js new file mode 100644 index 0000000..0b00bb3 --- /dev/null +++ b/fems-api/src/services/device.service.js @@ -0,0 +1,396 @@ +// src/services/device.service.js +const { + Device, + DeviceConnection, + DataPoint, + DeviceStatus, +} = require("../models"); +const alertService = require("./alert.service"); +const logger = require("../config/logger"); + +class DeviceService { + // 디바이스 생성 (데이터 포인트 포함) + async createDevice(data) { + const transaction = await Device.sequelize.transaction(); + + try { + // 1. 디바이스 생성 + const device = await Device.create( + { + device_type: data.device_type, + device_name: data.device_name, + manufacturer: data.manufacturer, + model: data.model, + serial_number: data.serial_number || null, // null 처리 추가 + location: data.location, + description: data.description, + status: data.status, + }, + { transaction } + ); + + // 2. 연결 정보 생성 + if (data.DeviceConnection) { + // deviceId는 방금 생성한 device.id를 사용 + const connectionData = { + ...data.DeviceConnection, + deviceId: device.id, // 명시적으로 deviceId 설정 + protocol_settings: data.DeviceConnection.protocol_settings || {}, // 기본값 처리 + }; + + await DeviceConnection.create(connectionData, { transaction }); + } + + // 3. 데이터 포인트 생성 + if (data.DataPoints && Array.isArray(data.DataPoints)) { + await Promise.all( + data.DataPoints.map((point) => + DataPoint.create( + { + ...point, + deviceId: device.id, // 명시적으로 deviceId 설정 + is_active: true, + }, + { transaction } + ) + ) + ); + } + + await alertService.createAlert({ + type: "DEVICE_CREATED", + message: `Device created: ${device.device_name}`, + companyId: device.companyId, + transaction, + }); + + await transaction.commit(); + + // 생성된 디바이스 상세 정보 조회 + return this.getDeviceDetail(device.id); + } catch (error) { + await transaction.rollback(); + logger.error("Failed to create device:", error); + throw error; + } + } + + async updateDevice(id, data) { + let transaction; + + try { + transaction = await Device.sequelize.transaction(); + + // 1. 디바이스 존재 확인 + const device = await Device.findByPk(id, { + include: [ + { + model: DataPoint, + }, + ], + transaction, + }); + + if (!device) { + if (transaction) await transaction.rollback(); + return null; + } + + // 2. 디바이스 정보 업데이트 + await device.update( + { + device_type: data.device_type, + device_name: data.device_name, + manufacturer: data.manufacturer, + model: data.model, + serial_number: data.serial_number, + location: data.location, + description: data.description, + status: data.status, + }, + { transaction } + ); + + // 3. 연결 정보 업데이트 + if (data.DeviceConnection) { + await DeviceConnection.update(data.DeviceConnection, { + where: { deviceId: id }, + transaction, + }); + } + + // 4. 데이터 포인트 처리 + if (data.DataPoints && Array.isArray(data.DataPoints)) { + await this.processDataPoints( + id, + device.DataPoints, + data.DataPoints, + transaction + ); + } + + await alertService.createAlert({ + type: "DEVICE_UPDATED", + message: `Device updated: ${device.device_name}`, + companyId: device.companyId, + transaction, + }); + + // 트랜잭션 커밋 + await transaction.commit(); + transaction = null; // 커밋 후 트랜잭션 참조 제거 + + // // MQTT 메시지 발행 + // try { + // await this.publishDeviceUpdate(id, "updated"); + // } catch (mqttError) { + // logger.error("Failed to publish MQTT message:", mqttError); + // // MQTT 발행 실패는 전체 업데이트를 실패로 처리하지 않음 + // } + + logger.info("Device updated:", { id }); + + // 업데이트된 디바이스 상세 정보 조회 + return this.getDeviceDetail(id); + } catch (error) { + logger.error("Failed to update device:", { + id, + error: error.message, + stack: error.stack, + }); + + // 트랜잭션이 존재하고 아직 완료되지 않았다면 롤백 + if (transaction && !transaction.finished) { + try { + await transaction.rollback(); + } catch (rollbackError) { + logger.error("Rollback failed:", rollbackError); + } + } + + throw error; + } + } + + // processDataPoints 메서드 개선 + async processDataPoints(deviceId, existingPoints, newPoints, transaction) { + try { + // 1. 기존 포인트와 새로운 포인트 매핑 + const existingPointMap = new Map( + existingPoints.map((point) => [point.id, point]) + ); + + const newPointMap = new Map( + newPoints.filter((point) => point.id).map((point) => [point.id, point]) + ); + + // 2. 삭제할 포인트 처리 + const pointsToDelete = existingPoints.filter( + (point) => !newPointMap.has(point.id) + ); + + if (pointsToDelete.length > 0) { + await DataPoint.update( + { is_active: false }, + { + where: { + id: pointsToDelete.map((p) => p.id), + deviceId: deviceId, + }, + transaction, + } + ); + } + + // 3. 추가할 포인트 처리 + const pointsToAdd = newPoints.filter( + (point) => !point.id || !existingPointMap.has(point.id) + ); + + if (pointsToAdd.length > 0) { + await Promise.all( + pointsToAdd.map((point) => { + const { id, ...pointData } = point; + return DataPoint.create( + { + ...pointData, + deviceId: deviceId, + }, + { transaction } + ); + }) + ); + } + + // 4. 수정할 포인트 처리 + const pointsToUpdate = newPoints.filter( + (point) => point.id && existingPointMap.has(point.id) + ); + + await Promise.all( + pointsToUpdate.map((point) => { + const { id, ...updateData } = point; + return DataPoint.update(updateData, { + where: { + id: id, + deviceId: deviceId, + }, + transaction, + }); + }) + ); + + logger.info("Data points processed:", { + deviceId, + deleted: pointsToDelete.length, + added: pointsToAdd.length, + updated: pointsToUpdate.length, + }); + } catch (error) { + logger.error("Failed to process data points:", { + deviceId, + error: error.message, + stack: error.stack, + }); + throw error; + } + } + + // 디바이스 목록 조회 + async getDevices(filters = {}) { + try { + const where = {}; + if (filters.device_type) where.device_type = filters.device_type; + if (filters.status) where.status = filters.status; + + const devices = await Device.findAll({ + where, + include: [ + { + model: DeviceConnection, + attributes: { exclude: ["protocol_settings"] }, + }, + { + model: DataPoint, + where: { is_active: true }, + required: false, + }, + ], + order: [["created_at", "DESC"]], + }); + + return devices; + } catch (error) { + logger.error("Failed to get devices:", error); + throw error; + } + } + + // 디바이스 상세 조회 + async getDeviceDetail(id) { + try { + const device = await Device.findByPk(id, { + include: [ + { + model: DeviceConnection, + attributes: { exclude: ["protocol_settings"] }, + }, + { + model: DataPoint, + where: { is_active: true }, + required: false, + }, + { + model: DeviceStatus, + limit: 1, + order: [["time", "DESC"]], + required: false, + }, + ], + }); + + return device; + } catch (error) { + logger.error("Failed to get device detail:", error); + throw error; + } + } + + async deleteDevice(id) { + const transaction = await Device.sequelize.transaction(); + + try { + // 1. 디바이스 존재 확인 + const device = await Device.findByPk(id, { transaction }); + + if (!device) { + await transaction.rollback(); + return false; + } + + // 2. 순차적으로 관련 데이터 모두 삭제 + try { + // 데이터 포인트 삭제 + await DataPoint.destroy({ + where: { deviceId: id }, + transaction, + }); + + // 디바이스 연결 정보 삭제 + await DeviceConnection.destroy({ + where: { deviceId: id }, + transaction, + }); + + // 디바이스 상태 이력 삭제 + await DeviceStatus.destroy({ + where: { deviceId: id }, + transaction, + }); + + // 디바이스 삭제 + await device.destroy({ transaction }); + + await alertService.createAlert({ + type: "DEVICE_DELETED", + message: `Device deleted: ${device.device_name}`, + companyId: device.companyId, + transaction, + }); + + // 트랜잭션 커밋 + await transaction.commit(); + + logger.info("Device deleted successfully:", { id }); + + return true; + } catch (error) { + logger.error(`Error during delete operation: ${error.message}`); + await transaction.rollback(); + throw error; + } + } catch (error) { + // 트랜잭션이 아직 active 상태인 경우에만 롤백 + if (transaction && !transaction.finished) { + await transaction.rollback(); + } + logger.error("Failed to delete device:", { + id, + error: error.message, + stack: error.stack, + }); + throw error; + } + } + + // 로깅 헬퍼 메서드 추가 + async logDeleteOperation(id, step, success = true, error = null) { + logger.info(`Delete operation step [${step}]`, { + deviceId: id, + success, + error: error ? error.message : null, + }); + } +} + +module.exports = new DeviceService(); diff --git a/fems-api/src/utils/initialSetup/dataSetup.js b/fems-api/src/utils/initialSetup/dataSetup.js index 17e5fb1..5d4652b 100644 --- a/fems-api/src/utils/initialSetup/dataSetup.js +++ b/fems-api/src/utils/initialSetup/dataSetup.js @@ -15,6 +15,7 @@ const { maintenanceLogDefinitions, equipmentDataTemplate, } = require("./setupData"); +const { initializeDevices } = require("./deviceInitializer"); const logger = require("../../config/logger"); const { createMaintenanceData } = require("./maintenanceSetup"); @@ -32,6 +33,9 @@ async function createInitialData(companyId, branchId) { // 정비 관련 데이터 생성 추가 await createMaintenanceData(companyId, branchId); + // 3. 디바이스 관련 데이터 생성 + await initializeDevices(companyId, branchId); + logger.info("Initial development data created successfully"); } catch (error) { logger.error("Error creating initial data:", error); diff --git a/fems-api/src/utils/initialSetup/deviceInitializer.js b/fems-api/src/utils/initialSetup/deviceInitializer.js new file mode 100644 index 0000000..74dc01b --- /dev/null +++ b/fems-api/src/utils/initialSetup/deviceInitializer.js @@ -0,0 +1,263 @@ +// src/utils/initialSetup/deviceInitializer.js +const logger = require("../config/logger"); +const { Device, DeviceConnection, DataPoint } = require("../models"); + +const initializeDevices = async () => { + try { + // 전력계 초기 데이터 + const powerMeter = { + device: { + device_type: "PowerMeter", + device_name: "power_meter_001", + manufacturer: "PowerTech", + model: "PM-2133", + location: "전기실 1", + description: "전력계 1 주요 전력 사용량 측정", + status: "ACTIVE", + companyId, + branchId, + }, + connection: { + protocol: "MODBUS_TCP", + ip_address: "192.168.0.3", + port: 1502, + unit_id: 1, + connection_timeout: 1000, + retry_interval: 2000, + }, + dataPoints: [ + { + tag_name: "instant_power", + description: "순시 전력", + data_type: "FLOAT", + address: "1000", + register_type: "HOLDING_REGISTER", + scan_rate: 1000, + unit: "kW", + }, + { + tag_name: "cumulative_power", + description: "적산 전력량", + data_type: "FLOAT", + address: "1002", + register_type: "HOLDING_REGISTER", + scan_rate: 1000, + unit: "kWh", + }, + { + tag_name: "voltage", + description: "전압", + data_type: "FLOAT", + address: "1004", + register_type: "HOLDING_REGISTER", + scan_rate: 1000, + unit: "V", + }, + { + tag_name: "current", + description: "전류", + data_type: "FLOAT", + address: "1006", + register_type: "HOLDING_REGISTER", + scan_rate: 1000, + unit: "A", + }, + { + tag_name: "power_factor", + description: "역률", + data_type: "FLOAT", + address: "1008", + register_type: "HOLDING_REGISTER", + scan_rate: 1000, + unit: "PF", + }, + ], + }; + + // PLC 초기 데이터 + const plc = { + device: { + device_type: "PLC", + device_name: "PLC0001", + manufacturer: "LSIS", + model: "XGB-DN32H", + location: "생산동 1층", + description: "생산라인 PLC 메인 생산라인 제어", + status: "ACTIVE", + companyId, + branchId, + }, + connection: { + protocol: "MODBUS_TCP", + ip_address: "192.168.0.8", + port: 2004, + unit_id: 1, + connection_timeout: 1000, + retry_interval: 2000, + }, + dataPoints: [ + // 공정 변수 + { + tag_name: "temperature", + description: "공정 온도", + data_type: "FLOAT", + address: "100", + register_type: "HOLDING_REGISTER", + scan_rate: 1000, + scale_factor: 0.1, + unit: "°C", + }, + { + tag_name: "pressure", + description: "공정 압력", + data_type: "FLOAT", + address: "102", + register_type: "HOLDING_REGISTER", + scan_rate: 1000, + scale_factor: 0.01, + unit: "bar", + }, + { + tag_name: "flow_rate", + description: "유량", + data_type: "FLOAT", + address: "104", + register_type: "HOLDING_REGISTER", + scan_rate: 1000, + scale_factor: 0.1, + unit: "L/min", + }, + { + tag_name: "motor_speed", + description: "모터 속도", + data_type: "UINT16", + address: "106", + register_type: "HOLDING_REGISTER", + scan_rate: 1000, + unit: "RPM", + }, + // 생산 카운터 + { + tag_name: "total_production", + description: "총 생산량", + data_type: "UINT32", + address: "200", + register_type: "HOLDING_REGISTER", + scan_rate: 1000, + }, + { + tag_name: "daily_production", + description: "일일 생산량", + data_type: "UINT32", + address: "202", + register_type: "HOLDING_REGISTER", + scan_rate: 1000, + }, + { + tag_name: "defect_count", + description: "불량 수량", + data_type: "UINT32", + address: "204", + register_type: "HOLDING_REGISTER", + scan_rate: 1000, + }, + { + tag_name: "operation_time", + description: "운전 시간", + data_type: "UINT32", + address: "206", + register_type: "HOLDING_REGISTER", + scan_rate: 1000, + unit: "min", + }, + // 상태 비트 + { + tag_name: "running", + description: "운전 상태", + data_type: "BOOL", + address: "0", + register_type: "COIL", + scan_rate: 1000, + }, + { + tag_name: "auto_mode", + description: "자동 모드", + data_type: "BOOL", + address: "1", + register_type: "COIL", + scan_rate: 1000, + }, + { + tag_name: "error_state", + description: "에러 상태", + data_type: "BOOL", + address: "2", + register_type: "COIL", + scan_rate: 1000, + }, + { + tag_name: "emergency_stop", + description: "비상 정지", + data_type: "BOOL", + address: "3", + register_type: "COIL", + scan_rate: 1000, + }, + { + tag_name: "maintenance_mode", + description: "유지보수 모드", + data_type: "BOOL", + address: "4", + register_type: "COIL", + scan_rate: 1000, + }, + ], + }; + + // 디바이스 생성 함수 + const createDeviceWithDetails = async (deviceData) => { + try { + // 디바이스 생성 + const device = await Device.create(deviceData.device); + logger.info( + `Created device: ${device.id} for company: ${companyId}, branch: ${branchId}` + ); + + // 연결 정보 생성 + await DeviceConnection.create({ + deviceId: device.id, + ...deviceData.connection, + }); + logger.info(`Created connection for device: ${device.id}`); + + // 데이터 포인트 생성 + const dataPointPromises = deviceData.dataPoints.map((point) => + DataPoint.create({ + deviceId: device.id, + ...point, + }) + ); + await Promise.all(dataPointPromises); + + logger.info(`Created data points for device: ${device.id}`); + return device; + } catch (error) { + logger.error(`Failed to create device details: ${error.message}`); + throw error; + } + }; + + // 디바이스 생성 실행 + await createDeviceWithDetails(powerMeter); + await createDeviceWithDetails(plc); + + logger.info("Device initialization completed successfully"); + } catch (error) { + logger.error("Failed to initialize devices:", error); + throw error; + } +}; + +module.exports = { + initializeDevices, +}; diff --git a/fems-app/src/app/(equipment)/devices/[id]/page.tsx b/fems-app/src/app/(equipment)/devices/[id]/page.tsx new file mode 100644 index 0000000..c72bac4 --- /dev/null +++ b/fems-app/src/app/(equipment)/devices/[id]/page.tsx @@ -0,0 +1,102 @@ +// src/app/devices/list/[id]/page.tsx +"use client"; + +import React from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { DeviceForm } from "../components/DeviceForm"; +import { api } from "@/lib/api"; +import { DataPoint, Device } from "@/types/device"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { AlertCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +export default function EditDevicePage({ params }: { params: { id: string } }) { + const router = useRouter(); + + // 디바이스 정보 조회 (데이터 포인트 포함) + const { + data: device, + isError, + isLoading, + } = useQuery<Device>({ + queryKey: ["device", params.id], + queryFn: async () => { + const { data } = await api.get(`/api/v1/app/devices/${params.id}`); + console.log(data); + return { + ...data, + // 백엔드에서 받은 데이터 포인트를 프론트엔드 형식으로 변환 + data_points: data.DataPoints?.map((point: DataPoint) => ({ + id: point.id, + tag_name: point.tag_name || "", + description: point.description || "", + data_type: point.data_type, + address: point.address, + register_type: point.register_type || "HOLDING_REGISTER", + scan_rate: point.scan_rate || 1000, + scale_factor: point.scale_factor || 1, + offset: point.offset || 0, + unit: point.unit || "", + is_active: point.is_active !== false, + })), + // connection 데이터도 정규화 + connection: { + protocol: data.DeviceConnection?.protocol || "MODBUS_TCP", + ip_address: data.DeviceConnection?.ip_address || "", + port: data.DeviceConnection?.port || 502, + unit_id: data.DeviceConnection?.unit_id || 1, + connection_timeout: data.DeviceConnection?.connection_timeout || 1000, + retry_interval: data.DeviceConnection?.retry_interval || 5000, + protocol_settings: data.DeviceConnection?.protocol_settings || {}, + }, + }; + }, + // staleTime: 0, // 항상 최신 데이터를 가져오도록 설정 + // cacheTime: 0, // 캐시를 사용하지 않음 + }); + + if (isLoading) { + return ( + <div className="container mx-auto py-6"> + <div>디바이스 정보를 불러오는 중...</div> + </div> + ); + } + + if (isError) { + return ( + <div className="container mx-auto py-6"> + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + 디바이스 정보를 불러오는데 실패했습니다. + </AlertDescription> + </Alert> + <Button + className="mt-4" + variant="outline" + onClick={() => router.push("/devices/list")} + > + 목록으로 돌아가기 + </Button> + </div> + ); + } + + return ( + <div className="container mx-auto py-6"> + <div className="mb-6"> + <h1 className="text-3xl font-bold">디바이스 수정</h1> + <p className="text-muted-foreground">디바이스 정보를 수정합니다.</p> + </div> + {device && ( + <DeviceForm + initialData={device} + onCancel={() => router.push("/devices/list")} + onSuccess={() => router.push("/devices/list")} + /> + )} + </div> + ); +} diff --git a/fems-app/src/app/(equipment)/devices/components/Detail/DeviceDetailDialog.tsx b/fems-app/src/app/(equipment)/devices/components/Detail/DeviceDetailDialog.tsx new file mode 100644 index 0000000..23c114a --- /dev/null +++ b/fems-app/src/app/(equipment)/devices/components/Detail/DeviceDetailDialog.tsx @@ -0,0 +1,239 @@ +// src/app/devices/components/Detail/DeviceDetailDialog.tsx +"use client"; + +import React from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Wifi, WifiOff, AlertCircle, Terminal, Settings2 } from "lucide-react"; +import { DataPoint, Device } from "@/types/device"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/lib/api"; + +interface DeviceDetailDialogProps { + device: Device | null; + isOpen: boolean; + onClose: () => void; +} + +export function DeviceDetailDialog({ + device, + isOpen, + onClose, +}: DeviceDetailDialogProps) { + // Fetch device details + const { data: deviceDetail } = useQuery({ + queryKey: ["device", device?.id], + queryFn: async () => { + if (!device) return null; + const { data } = await api.get(`/api/v1/app/devices/${device.id}`); + return { + ...data, + // connection 데이터 정규화 + connection: { + protocol: data.DeviceConnection?.protocol || "MODBUS_TCP", + ip_address: data.DeviceConnection?.ip_address || "", + port: data.DeviceConnection?.port || 502, + unit_id: data.DeviceConnection?.unit_id || 1, + connection_timeout: data.DeviceConnection?.connection_timeout || 1000, + retry_interval: data.DeviceConnection?.retry_interval || 5000, + protocol_settings: data.DeviceConnection?.protocol_settings || {}, + }, + // 데이터 포인트 정규화 + data_points: data.DataPoints?.map((point: DataPoint) => ({ + id: point.id, + tag_name: point.tag_name || "", + description: point.description || "", + data_type: point.data_type, + address: point.address, + register_type: point.register_type || "HOLDING_REGISTER", + scan_rate: point.scan_rate || 1000, + scale_factor: point.scale_factor || 1, + offset: point.offset || 0, + unit: point.unit || "", + is_active: point.is_active !== false, + })), + }; + }, + enabled: !!device && isOpen, + }); + + if (!device || !deviceDetail) return null; + + const statusMap = { + ACTIVE: { label: "활성", icon: Wifi, variant: "default" }, + INACTIVE: { label: "비활성", icon: WifiOff, variant: "secondary" }, + MAINTENANCE: { label: "유지보수", icon: Settings2, variant: "outline" }, + ERROR: { label: "오류", icon: AlertCircle, variant: "destructive" }, + } as const; + + const protocolMap = { + MODBUS_TCP: "Modbus TCP", + MODBUS_RTU: "Modbus RTU", + OPC_UA: "OPC UA", + MQTT: "MQTT", + BACNET: "BACnet", + } as const; + + const status = statusMap[deviceDetail.status as keyof typeof statusMap]; + const StatusIcon = status.icon; + + return ( + <Dialog open={isOpen} onOpenChange={onClose}> + <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>디바이스 상세 정보</DialogTitle> + <DialogDescription> + 선택한 디바이스의 상세 정보를 확인합니다. + </DialogDescription> + </DialogHeader> + + <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> + {/* 기본 정보 */} + <Card> + <CardHeader> + <CardTitle>기본 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div className="grid grid-cols-2 gap-2"> + <div className="text-sm text-muted-foreground">디바이스 ID</div> + <div>{deviceDetail.id}</div> + + <div className="text-sm text-muted-foreground">디바이스명</div> + <div>{deviceDetail.device_name}</div> + + <div className="text-sm text-muted-foreground">유형</div> + <div>{deviceDetail.device_type}</div> + + <div className="text-sm text-muted-foreground">상태</div> + <div> + <Badge variant={status.variant}> + <StatusIcon className="w-3 h-3 mr-1" /> + {status.label} + </Badge> + </div> + + <div className="text-sm text-muted-foreground">설치 위치</div> + <div>{deviceDetail.location}</div> + + <div className="text-sm text-muted-foreground">설명</div> + <div>{deviceDetail.description || "-"}</div> + </div> + </CardContent> + </Card> + + {/* 하드웨어 정보 */} + <Card> + <CardHeader> + <CardTitle>하드웨어 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div className="grid grid-cols-2 gap-2"> + <div className="text-sm text-muted-foreground">제조사</div> + <div>{deviceDetail.manufacturer}</div> + + <div className="text-sm text-muted-foreground">모델</div> + <div>{deviceDetail.model}</div> + + <div className="text-sm text-muted-foreground">시리얼 번호</div> + <div>{deviceDetail.serial_number || "-"}</div> + </div> + </CardContent> + </Card> + + {/* 통신 설정 */} + <Card> + <CardHeader> + <CardTitle>통신 설정</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div className="grid grid-cols-2 gap-2"> + <div className="text-sm text-muted-foreground">프로토콜</div> + <div> + {deviceDetail.connection && ( + <Badge variant="outline"> + <Terminal className="w-3 h-3 mr-1" /> + { + protocolMap[ + deviceDetail.connection + .protocol as keyof typeof protocolMap + ] + } + </Badge> + )} + </div> + + {deviceDetail.connection?.ip_address && ( + <> + <div className="text-sm text-muted-foreground">IP 주소</div> + <div>{deviceDetail.connection.ip_address}</div> + + <div className="text-sm text-muted-foreground">포트</div> + <div>{deviceDetail.connection.port}</div> + + <div className="text-sm text-muted-foreground">Unit ID</div> + <div>{deviceDetail.connection.unit_id}</div> + </> + )} + + {deviceDetail.connection && ( + <> + <div className="text-sm text-muted-foreground"> + 연결 타임아웃 + </div> + <div>{deviceDetail.connection.connection_timeout}ms</div> + + <div className="text-sm text-muted-foreground"> + 재시도 간격 + </div> + <div>{deviceDetail.connection.retry_interval}ms</div> + </> + )} + </div> + </CardContent> + </Card> + + {/* 데이터 포인트 */} + <Card> + <CardHeader> + <CardTitle>데이터 포인트</CardTitle> + </CardHeader> + <CardContent> + {deviceDetail.data_points && + deviceDetail.data_points.length > 0 ? ( + <div className="space-y-4"> + {deviceDetail.data_points.map((point: DataPoint) => ( + <div key={point.id} className="border rounded-lg p-3"> + <div className="flex justify-between items-start"> + <div className="font-medium">{point.tag_name}</div> + <Badge variant="outline">{point.data_type}</Badge> + </div> + <div className="text-sm text-muted-foreground mt-1"> + 주소: {point.address} + {point.register_type && ` (${point.register_type})`} + </div> + <div className="text-sm mt-1"> + 스캔 주기: {point.scan_rate}ms + {point.unit && ` | 단위: ${point.unit}`} + </div> + </div> + ))} + </div> + ) : ( + <div className="text-muted-foreground text-center py-4"> + 등록된 데이터 포인트가 없습니다. + </div> + )} + </CardContent> + </Card> + </div> + </DialogContent> + </Dialog> + ); +} diff --git a/fems-app/src/app/(equipment)/devices/components/DeviceForm/DataPointFormFields.tsx b/fems-app/src/app/(equipment)/devices/components/DeviceForm/DataPointFormFields.tsx new file mode 100644 index 0000000..13ee66a --- /dev/null +++ b/fems-app/src/app/(equipment)/devices/components/DeviceForm/DataPointFormFields.tsx @@ -0,0 +1,249 @@ +// src/app/devices/list/components/DeviceForm/DataPointFormFields.tsx +import React from "react"; +import { UseFormReturn } from "react-hook-form"; +import { + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { DeviceFormData } from "./schema"; + +interface DataPointFormFieldsProps { + form: UseFormReturn<DeviceFormData>; + index: number; + isSubmitting: boolean; +} + +export function DataPointFormFields({ + form, + index, + isSubmitting, +}: DataPointFormFieldsProps) { + return ( + <div className="space-y-8 max-w-6xl mx-auto"> + <div className="space-y-4"> + <div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-2"> + {/* 태그명 */} + <FormField + control={form.control} + name={`data_points.${index}.tag_name`} + render={({ field }) => ( + <FormItem> + <FormLabel>태그명</FormLabel> + <FormControl> + <Input + placeholder="예: temperature_sensor_01" + {...field} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 데이터 타입 */} + <FormField + control={form.control} + name={`data_points.${index}.data_type`} + render={({ field }) => ( + <FormItem> + <FormLabel>데이터 타입</FormLabel> + <Select + onValueChange={field.onChange} + value={field.value} + disabled={isSubmitting} + > + <FormControl> + <SelectTrigger> + <SelectValue placeholder="데이터 타입 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="BOOL">Boolean</SelectItem> + <SelectItem value="INT16">Int16</SelectItem> + <SelectItem value="UINT16">UInt16</SelectItem> + <SelectItem value="INT32">Int32</SelectItem> + <SelectItem value="UINT32">UInt32</SelectItem> + <SelectItem value="FLOAT">Float</SelectItem> + <SelectItem value="DOUBLE">Double</SelectItem> + <SelectItem value="STRING">String</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 레지스터 타입 */} + <FormField + control={form.control} + name={`data_points.${index}.register_type`} + render={({ field }) => ( + <FormItem> + <FormLabel>레지스터 타입</FormLabel> + <Select + onValueChange={field.onChange} + value={field.value} + disabled={isSubmitting} + > + <FormControl> + <SelectTrigger> + <SelectValue placeholder="레지스터 타입 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="COIL">Coil</SelectItem> + <SelectItem value="DISCRETE_INPUT"> + Discrete Input + </SelectItem> + <SelectItem value="HOLDING_REGISTER"> + Holding Register + </SelectItem> + <SelectItem value="INPUT_REGISTER"> + Input Register + </SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 주소 */} + <FormField + control={form.control} + name={`data_points.${index}.address`} + render={({ field }) => ( + <FormItem> + <FormLabel>주소</FormLabel> + <FormControl> + <Input + placeholder="예: 40001" + {...field} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 스캔 주기 */} + <FormField + control={form.control} + name={`data_points.${index}.scan_rate`} + render={({ field }) => ( + <FormItem> + <FormLabel>스캔 주기 (ms)</FormLabel> + <FormControl> + <Input + type="number" + min="100" + placeholder="예: 1000" + {...field} + onChange={(e) => field.onChange(Number(e.target.value))} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 스케일 팩터 */} + <FormField + control={form.control} + name={`data_points.${index}.scale_factor`} + render={({ field }) => ( + <FormItem> + <FormLabel>스케일 팩터</FormLabel> + <FormControl> + <Input + type="number" + step="0.001" + placeholder="예: 1" + {...field} + onChange={(e) => field.onChange(Number(e.target.value))} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 오프셋 */} + <FormField + control={form.control} + name={`data_points.${index}.offset`} + render={({ field }) => ( + <FormItem> + <FormLabel>오프셋</FormLabel> + <FormControl> + <Input + type="number" + step="0.001" + placeholder="예: 0" + {...field} + onChange={(e) => field.onChange(Number(e.target.value))} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 단위 */} + <FormField + control={form.control} + name={`data_points.${index}.unit`} + render={({ field }) => ( + <FormItem> + <FormLabel>단위</FormLabel> + <FormControl> + <Input + placeholder="예: °C" + {...field} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* 설명 */} + <FormField + control={form.control} + name={`data_points.${index}.description`} + render={({ field }) => ( + <FormItem> + <FormLabel>설명</FormLabel> + <FormControl> + <Input + placeholder="예: 온도 센서 01" + {...field} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> + ); +} diff --git a/fems-app/src/app/(equipment)/devices/components/DeviceForm/DeviceFormFields.tsx b/fems-app/src/app/(equipment)/devices/components/DeviceForm/DeviceFormFields.tsx new file mode 100644 index 0000000..d116f29 --- /dev/null +++ b/fems-app/src/app/(equipment)/devices/components/DeviceForm/DeviceFormFields.tsx @@ -0,0 +1,365 @@ +// src/app/devices/list/components/DeviceForm/DeviceFormFields.tsx +import React from "react"; +import { UseFormReturn } from "react-hook-form"; +import { + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { DeviceFormData } from "./schema"; + +interface DeviceFormFieldsProps { + form: UseFormReturn<DeviceFormData>; + isSubmitting: boolean; +} + +export function DeviceFormFields({ + form, + isSubmitting, +}: DeviceFormFieldsProps) { + // 프로토콜에 따른 필드 표시 여부 + + return ( + <div className="space-y-8 max-w-6xl mx-auto"> + {/* 기본 정보 섹션 */} + <div className="space-y-4"> + <h3 className="text-lg font-medium border-b pb-2">기본 정보</h3> + <div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"> + {/* 디바이스명 */} + <FormField + control={form.control} + name="device_name" + render={({ field }) => ( + <FormItem> + <FormLabel>디바이스명</FormLabel> + <FormControl> + <Input + placeholder="디바이스명을 입력하세요" + {...field} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 디바이스 유형 */} + <FormField + control={form.control} + name="device_type" + render={({ field }) => ( + <FormItem> + <FormLabel>디바이스 유형</FormLabel> + <Select + onValueChange={field.onChange} + value={field.value} + disabled={isSubmitting} + > + <FormControl> + <SelectTrigger> + <SelectValue placeholder="디바이스 유형을 선택하세요" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="PLC">PLC</SelectItem> + <SelectItem value="PowerMeter">전력계</SelectItem> + <SelectItem value="Sensor">센서</SelectItem> + <SelectItem value="Gateway">게이트웨이</SelectItem> + <SelectItem value="Other">기타</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 제조사 */} + <FormField + control={form.control} + name="manufacturer" + render={({ field }) => ( + <FormItem> + <FormLabel>제조사</FormLabel> + <FormControl> + <Input + placeholder="제조사를 입력하세요" + {...field} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 모델명 */} + <FormField + control={form.control} + name="model" + render={({ field }) => ( + <FormItem> + <FormLabel>모델명</FormLabel> + <FormControl> + <Input + placeholder="모델명을 입력하세요" + {...field} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 시리얼 번호 */} + <FormField + control={form.control} + name="serial_number" + render={({ field }) => ( + <FormItem> + <FormLabel>시리얼 번호</FormLabel> + <FormControl> + <Input + placeholder="시리얼 번호를 입력하세요" + {...field} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 설치 위치 */} + <FormField + control={form.control} + name="location" + render={({ field }) => ( + <FormItem> + <FormLabel>설치 위치</FormLabel> + <FormControl> + <Input + placeholder="설치 위치를 입력하세요" + {...field} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* 설명 */} + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>설명</FormLabel> + <FormControl> + <Textarea + placeholder="디바이스에 대한 설명을 입력하세요" + {...field} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* 통신 설정 섹션 */} + <div className="space-y-4"> + <h3 className="text-lg font-medium border-b pb-2">통신 설정</h3> + <div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"> + {/* 통신 프로토콜 */} + <FormField + control={form.control} + name="connection.protocol" + render={({ field }) => ( + <FormItem> + <FormLabel>통신 프로토콜</FormLabel> + <Select + onValueChange={field.onChange} + value={field.value || "MODBUS_TCP"} + disabled={isSubmitting} + > + <FormControl> + <SelectTrigger> + <SelectValue placeholder="프로토콜을 선택하세요" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="MODBUS_TCP">Modbus TCP</SelectItem> + <SelectItem value="MODBUS_RTU">Modbus RTU</SelectItem> + <SelectItem value="OPC_UA">OPC UA</SelectItem> + <SelectItem value="MQTT">MQTT</SelectItem> + <SelectItem value="BACNET">BACnet</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* IP 주소 - 항상 표시 */} + <FormField + control={form.control} + name="connection.ip_address" + render={({ field }) => ( + <FormItem> + <FormLabel>IP 주소</FormLabel> + <FormControl> + <Input + placeholder="예: 192.168.0.100" + {...field} + value={field.value ?? ""} // null, undefined 모두 처리 + onChange={(e) => field.onChange(e.target.value || "")} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 포트 - 항상 표시 */} + <FormField + control={form.control} + name="connection.port" + render={({ field: { onChange, value, ...field } }) => ( + <FormItem> + <FormLabel>포트</FormLabel> + <FormControl> + <Input + type="number" + placeholder="예: 502" + {...field} + value={value || ""} // null 방지 + onChange={(e) => onChange(Number(e.target.value) || 502)} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Unit ID - 항상 표시 */} + <FormField + control={form.control} + name="connection.unit_id" + render={({ field: { onChange, value, ...field } }) => ( + <FormItem> + <FormLabel>Unit ID</FormLabel> + <FormControl> + <Input + type="number" + placeholder="예: 1" + {...field} + value={value || ""} // null 방지 + onChange={(e) => onChange(Number(e.target.value) || 1)} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 연결 타임아웃 */} + <FormField + control={form.control} + name="connection.connection_timeout" + render={({ field: { onChange, value, ...field } }) => ( + <FormItem> + <FormLabel>연결 타임아웃 (ms)</FormLabel> + <FormControl> + <Input + type="number" + placeholder="예: 1000" + {...field} + value={value || ""} // null 방지 + onChange={(e) => onChange(Number(e.target.value) || 1000)} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 재시도 간격 */} + <FormField + control={form.control} + name="connection.retry_interval" + render={({ field: { onChange, value, ...field } }) => ( + <FormItem> + <FormLabel>재시도 간격 (ms)</FormLabel> + <FormControl> + <Input + type="number" + placeholder="예: 5000" + {...field} + value={value || ""} // null 방지 + onChange={(e) => onChange(Number(e.target.value) || 5000)} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> + + {/* 상태 설정 섹션 */} + <div className="space-y-4"> + <h3 className="text-lg font-medium border-b pb-2">상태 설정</h3> + <div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"> + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel>상태</FormLabel> + <Select + onValueChange={field.onChange} + value={field.value} + disabled={isSubmitting} + > + <FormControl> + <SelectTrigger> + <SelectValue placeholder="상태를 선택하세요" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="ACTIVE">활성</SelectItem> + <SelectItem value="INACTIVE">비활성</SelectItem> + <SelectItem value="MAINTENANCE">유지보수</SelectItem> + <SelectItem value="ERROR">오류</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> + </div> + ); +} diff --git a/fems-app/src/app/(equipment)/devices/components/DeviceForm/index.tsx b/fems-app/src/app/(equipment)/devices/components/DeviceForm/index.tsx new file mode 100644 index 0000000..8cd3476 --- /dev/null +++ b/fems-app/src/app/(equipment)/devices/components/DeviceForm/index.tsx @@ -0,0 +1,295 @@ +// src/app/devices/list/components/DeviceForm/index.tsx +"use client"; + +import React from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Form } from "@/components/ui/form"; +import { Button } from "@/components/ui/button"; +import { DeviceFormFields } from "./DeviceFormFields"; +import { useDeviceForm } from "./useDeviceForm"; +import { deviceSchema } from "./schema"; +import { DataPointFormFields } from "./DataPointFormFields"; +import type { + DataPoint, + DeviceFormProps, + DeviceType, + DeviceStatus, + ProtocolType, + RegisterType, +} from "@/types/device"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import type { z } from "zod"; + +export function DeviceForm({ + initialData, + onCancel, + onSuccess, +}: DeviceFormProps) { + const [deleteIndex, setDeleteIndex] = React.useState<number | null>(null); + const { createDevice, updateDevice, isLoading } = useDeviceForm({ + deviceId: initialData?.id, + onSuccess, + }); + + // 기본값 설정을 처리하는 함수 + const getDefaultValues = () => { + if (initialData) { + return { + id: initialData.id || "", + device_name: initialData.device_name || "", + device_type: (initialData.device_type as DeviceType) || "PLC", + description: initialData.description || "", + manufacturer: initialData.manufacturer || "", + model: initialData.model || "", + serial_number: initialData.serial_number || "", + location: initialData.location || "", + status: (initialData.status as DeviceStatus) || "ACTIVE", + connection: { + protocol: + (initialData.connection?.protocol as ProtocolType) || "MODBUS_TCP", + ip_address: initialData.connection?.ip_address || "", + port: initialData.connection?.port || 502, + unit_id: initialData.connection?.unit_id || 1, + connection_timeout: + initialData.connection?.connection_timeout || 1000, + retry_interval: initialData.connection?.retry_interval || 5000, + protocol_settings: initialData.connection?.protocol_settings || {}, + }, + data_points: + (initialData.data_points || []).map((point: DataPoint) => ({ + id: point.id.toString(), + tag_name: point.tag_name || "", + description: point.description || "", + data_type: point.data_type as DataPoint["data_type"], + address: point.address || "", + register_type: point.register_type as RegisterType, + scan_rate: point.scan_rate || 1000, + scale_factor: point.scale_factor || 1, + offset: point.offset || 0, + unit: point.unit || "", + is_active: point.is_active !== false, + })) || [], + }; + } + + // 신규 생성시 기본값 + return { + device_name: "", + device_type: "PLC" as DeviceType, + description: "", + manufacturer: "", + model: "", + serial_number: "", + location: "", + status: "ACTIVE" as DeviceStatus, + connection: { + protocol: "MODBUS_TCP" as ProtocolType, + ip_address: "", + port: 502, + unit_id: 1, + connection_timeout: 1000, + retry_interval: 5000, + protocol_settings: {}, + }, + data_points: [], + }; + }; + + const form = useForm<z.infer<typeof deviceSchema>>({ + resolver: zodResolver(deviceSchema), + defaultValues: getDefaultValues(), + }); + + // 제출 핸들러 + const handleSubmit = async (data: z.infer<typeof deviceSchema>) => { + try { + if (initialData?.id) { + await updateDevice(data); + } else { + await createDevice(data); + } + } catch (error) { + console.error("Form submission error:", error); + } + }; + + const handleDeletePoint = (index: number) => { + const currentPoints = form.getValues("data_points"); + form.setValue( + "data_points", + currentPoints.filter((_, i) => i !== index) + ); + setDeleteIndex(null); + }; + + return ( + <Form {...form}> + <form + onSubmit={form.handleSubmit(handleSubmit)} + className="space-y-6 max-w-6xl mx-auto" + > + <DeviceFormFields form={form} isSubmitting={isLoading} /> + + {/* 데이터 포인트 섹션 */} + <div className="space-y-4"> + <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 border-b pb-2"> + <h3 className="text-lg font-medium">데이터 포인트</h3> + <Button + type="button" + onClick={() => { + const currentPoints = form.getValues("data_points") || []; + form.setValue("data_points", [ + ...currentPoints, + { + tag_name: "", + description: "", + data_type: "UINT16", + address: "", + register_type: "HOLDING_REGISTER", + scan_rate: 1000, + scale_factor: 1, + offset: 0, + unit: "", + is_active: true, + }, + ]); + }} + disabled={isLoading} + className="w-full sm:w-auto" + > + 데이터 포인트 추가 + </Button> + </div> + + {/* 데이터 포인트 그리드 */} + <div className="grid gap-4 grid-cols-1 md:grid-cols-2 xl:grid-cols-2"> + {form.watch("data_points")?.map((point, index) => ( + <div + key={index} + className="border rounded-lg bg-white shadow-sm hover:shadow-md transition-shadow" + > + <div className="border-b p-4"> + <div className="flex justify-between items-center"> + <h4 className="text-sm font-medium flex items-center gap-2"> + <span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary/10 text-primary text-xs"> + {index + 1} + </span> + <span> + 데이터 포인트: {point.tag_name || "새 포인트"} + </span> + </h4> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => setDeleteIndex(index)} + disabled={isLoading} + className="text-red-500 hover:text-red-700 hover:bg-red-50" + > + 삭제 + </Button> + </div> + </div> + <div className="p-4"> + <DataPointFormFields + form={form} + index={index} + isSubmitting={isLoading} + /> + </div> + </div> + ))} + </div> + + {/* 빈 상태 표시 */} + {(!form.watch("data_points") || + form.watch("data_points").length === 0) && ( + <div className="text-center py-8 border-2 border-dashed rounded-lg"> + <p className="text-gray-500"> + 등록된 데이터 포인트가 없습니다. + <br /> + 위의 '데이터 포인트 추가' 버튼을 클릭하여 + 추가해주세요. + </p> + </div> + )} + </div> + + {/* 하단 버튼 */} + <div className="flex flex-col sm:flex-row justify-end gap-4 pt-4 border-t"> + <Button + type="button" + variant="outline" + onClick={onCancel} + disabled={isLoading} + className="w-full sm:w-auto" + > + 취소 + </Button> + <Button + type="submit" + disabled={isLoading} + className="w-full sm:w-auto" + > + {isLoading ? ( + <span className="flex items-center justify-center gap-2"> + <span className="animate-spin">⏳</span> + 처리중... + </span> + ) : initialData?.id ? ( // id 존재 여부로 신규/수정 구분 + "수정하기" + ) : ( + "등록하기" + )} + </Button> + </div> + + {/* 삭제 확인 대화상자 */} + <AlertDialog + open={deleteIndex !== null} + onOpenChange={() => setDeleteIndex(null)} + > + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>데이터 포인트 삭제</AlertDialogTitle> + <AlertDialogDescription> + 이 데이터 포인트를 삭제하시겠습니까? + {deleteIndex !== null && + form.watch(`data_points.${deleteIndex}.tag_name`) && ( + <span className="mt-2 block font-medium text-gray-900"> + 태그명:{" "} + {form.watch(`data_points.${deleteIndex}.tag_name`)} + </span> + )} + <span className="mt-2 block text-red-600"> + 삭제한 데이터 포인트는 복구할 수 없습니다. + </span> + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>취소</AlertDialogCancel> + <AlertDialogAction + onClick={() => + deleteIndex !== null && handleDeletePoint(deleteIndex) + } + className="bg-red-600 hover:bg-red-700 focus:ring-red-600" + > + 삭제 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </form> + </Form> + ); +} diff --git a/fems-app/src/app/(equipment)/devices/components/DeviceForm/schema.ts b/fems-app/src/app/(equipment)/devices/components/DeviceForm/schema.ts new file mode 100644 index 0000000..fc49f35 --- /dev/null +++ b/fems-app/src/app/(equipment)/devices/components/DeviceForm/schema.ts @@ -0,0 +1,93 @@ +// src/app/devices/list/components/DeviceForm/schema.ts +import { z } from "zod"; +// import { +// DataPointType, +// DeviceType, +// ProtocolType, +// RegisterType, +// } from "@/types/device"; + +// 데이터 포인트 스키마 +export const dataPointSchema = z.object({ + id: z.string().optional(), + tag_name: z.string().min(1, "태그명을 입력해주세요"), + description: z.string().optional(), + data_type: z.enum( + [ + "BOOL", + "INT16", + "UINT16", + "INT32", + "UINT32", + "FLOAT", + "DOUBLE", + "STRING", + ] as const, + { + required_error: "데이터 타입을 선택해주세요", + } + ), + address: z.string().min(1, "주소를 입력해주세요"), + register_type: z + .enum([ + "COIL", + "DISCRETE_INPUT", + "HOLDING_REGISTER", + "INPUT_REGISTER", + ] as const) + .optional(), + scan_rate: z.number().min(100, "스캔 주기는 100ms 이상이어야 합니다"), + scale_factor: z.number().default(1), + offset: z.number().default(0), + unit: z.string().optional(), + is_active: z.boolean().default(true), +}); + +// 디바이스 연결 설정 스키마 +const deviceConnectionSchema = z.object({ + protocol: z.enum([ + "MODBUS_TCP", + "MODBUS_RTU", + "OPC_UA", + "MQTT", + "BACNET", + ] as const), + ip_address: z + .string() + .regex(/^(\d{1,3}\.){3}\d{1,3}$/, "유효한 IP 주소를 입력하세요"), + port: z.number().min(1).max(65535), + unit_id: z.number().optional(), + connection_timeout: z + .number() + .min(100, "연결 타임아웃은 100ms 이상이어야 합니다"), + retry_interval: z + .number() + .min(1000, "재시도 간격은 1000ms 이상이어야 합니다"), + protocol_settings: z.record(z.unknown()).optional(), +}); + +// 메인 디바이스 스키마 +export const deviceSchema = z.object({ + id: z.string().optional(), + device_name: z.string().min(1, "디바이스명을 입력해주세요"), + device_type: z.enum( + ["PLC", "PowerMeter", "Sensor", "Gateway", "Other"] as const, + { + required_error: "디바이스 유형을 선택해주세요", + } + ), + description: z.string().optional(), + manufacturer: z.string().min(1, "제조사를 입력해주세요"), + model: z.string().min(1, "모델명을 입력해주세요"), + serial_number: z.string().optional(), + location: z.string().min(1, "설치 위치를 입력해주세요"), + status: z + .enum(["ACTIVE", "INACTIVE", "MAINTENANCE", "ERROR"] as const) + .default("ACTIVE"), + connection: deviceConnectionSchema, + data_points: z.array(dataPointSchema).default([]), +}); + +// 타입 추출 +export type DeviceFormData = z.infer<typeof deviceSchema>; +export type DataPointFormData = z.infer<typeof dataPointSchema>; diff --git a/fems-app/src/app/(equipment)/devices/components/DeviceForm/useDeviceForm.ts b/fems-app/src/app/(equipment)/devices/components/DeviceForm/useDeviceForm.ts new file mode 100644 index 0000000..d2bf87f --- /dev/null +++ b/fems-app/src/app/(equipment)/devices/components/DeviceForm/useDeviceForm.ts @@ -0,0 +1,135 @@ +// src/app/devices/list/hooks/useDeviceForm.ts +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import { useToast } from "@/hooks/use-toast"; +import type { + DeviceInput, + DeviceResponse, + UseDeviceFormProps, + ApiError, + BackendDeviceInput, +} from "@/types/device"; + +export function useDeviceForm({ + deviceId, + onSuccess, + onError, +}: UseDeviceFormProps) { + const queryClient = useQueryClient(); + const { toast } = useToast(); + + const transformDeviceData = (formData: DeviceInput): BackendDeviceInput => { + const { connection, data_points, ...rest } = formData; + + // 포트 번호 유효성 검사 및 변환 + const validatePort = (port: number | undefined) => { + if (port === undefined) return 502; // 기본값 + return Math.min(Math.max(1, port), 65535); // 1~65535 범위로 제한 + }; + + // IP 주소 검증 + const validateIpAddress = (ip: string | undefined) => { + if (!ip) return ""; + // 간단한 IP 주소 형식 검증 + const ipPattern = /^(\d{1,3}\.){3}\d{1,3}$/; + if (!ipPattern.test(ip)) { + throw new Error("Invalid IP address format"); + } + return ip; + }; + + return { + ...rest, + // connection을 DeviceConnection으로 변환 + DeviceConnection: { + ...connection, + deviceId: formData.id, + protocol: connection.protocol || "MODBUS_TCP", + ip_address: validateIpAddress(connection.ip_address), + port: validatePort(connection.port), + unit_id: connection.unit_id || 1, + connection_timeout: connection.connection_timeout || 1000, + retry_interval: connection.retry_interval || 5000, + protocol_settings: connection.protocol_settings || {}, + }, + // DataPoints로 변환 + DataPoints: data_points?.map((point) => ({ + ...point, + deviceId: formData.id, + tag_name: point.tag_name.trim(), // 공백 제거 + data_type: point.data_type, + address: point.address.trim(), // 공백 제거 + register_type: point.register_type || "HOLDING_REGISTER", + scan_rate: Math.max(100, Number(point.scan_rate) || 1000), // 최소 100ms + scale_factor: Number(point.scale_factor) || 1, + offset: Number(point.offset) || 0, + unit: point.unit?.trim() || "", + is_active: point.is_active !== false, + })), + }; + }; + + // 생성 mutation + const createMutation = useMutation<DeviceResponse, ApiError, DeviceInput>({ + mutationFn: async (newDevice) => { + const transformedData = transformDeviceData(newDevice); + const { data } = await api.post("/api/v1/app/devices", transformedData); + return data; + }, + onSuccess: () => { + toast({ + title: "디바이스 등록 완료", + description: "새로운 디바이스가 성공적으로 등록되었습니다.", + }); + queryClient.invalidateQueries({ queryKey: ["devices"] }); + onSuccess?.(); + }, + onError: (error) => { + toast({ + title: "디바이스 등록 실패", + description: error.message || "오류가 발생했습니다.", + variant: "destructive", + }); + onError?.(error); + }, + }); + + // 수정 mutation + const updateMutation = useMutation<DeviceResponse, ApiError, DeviceInput>({ + mutationFn: async (updateData) => { + const transformedData = transformDeviceData(updateData); + const { data } = await api.put( + `/api/v1/app/devices/${deviceId}`, + transformedData + ); + return data; + }, + onSuccess: () => { + toast({ + title: "디바이스 수정 완료", + description: "디바이스 정보가 성공적으로 수정되었습니다.", + }); + // 디바이스 정보와 데이터 포인트 캐시를 함께 갱신 + if (deviceId) { + queryClient.invalidateQueries({ queryKey: ["device", deviceId] }); + } + onSuccess?.(); + }, + onError: (error) => { + toast({ + title: "디바이스 수정 실패", + description: error.message || "오류가 발생했습니다.", + variant: "destructive", + }); + onError?.(error); + }, + }); + + return { + createDevice: (data: DeviceInput) => createMutation.mutateAsync(data), + updateDevice: (data: DeviceInput) => updateMutation.mutateAsync(data), + isCreating: createMutation.isPending, + isUpdating: updateMutation.isPending, + isLoading: createMutation.isPending || updateMutation.isPending, + }; +} diff --git a/fems-app/src/app/(equipment)/devices/new/layout.tsx b/fems-app/src/app/(equipment)/devices/new/layout.tsx new file mode 100644 index 0000000..59ec4b6 --- /dev/null +++ b/fems-app/src/app/(equipment)/devices/new/layout.tsx @@ -0,0 +1,15 @@ +// src/app/devices/list/new/layout.tsx +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "새 디바이스 등록 | FEMS Edge", + description: "새로운 디바이스를 등록합니다.", +}; + +export default function NewDeviceLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/fems-app/src/app/(equipment)/devices/new/page.tsx b/fems-app/src/app/(equipment)/devices/new/page.tsx new file mode 100644 index 0000000..5ec43e1 --- /dev/null +++ b/fems-app/src/app/(equipment)/devices/new/page.tsx @@ -0,0 +1,52 @@ +// src/app/devices/list/new/page.tsx +"use client"; + +import React from "react"; +import { useRouter } from "next/navigation"; +import { DeviceForm } from "../components/DeviceForm"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { AlertCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useDeviceForm } from "../components/DeviceForm/useDeviceForm"; +import { Device } from "@/types/device"; + +export default function NewDevicePage() { + const router = useRouter(); + const { isCreating, isUpdating } = useDeviceForm({}); // Create and update states + const isError = isCreating || isUpdating; + const device: Device = {} as Device; + + return ( + <div className="container mx-auto py-6"> + <div className="mb-6"> + <h1 className="text-3xl font-bold">새 디바이스 등록</h1> + <p className="text-muted-foreground">새로운 디바이스를 등록합니다.</p> + </div> + + {/* 오류 발생시 표시할 Alert */} + {isError && ( + <> + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + 디바이스를 등록하는데 실패했습니다. + </AlertDescription> + </Alert> + <Button + className="mt-4" + variant="outline" + onClick={() => router.push("/devices/list")} + > + 목록으로 돌아가기 + </Button> + </> + )} + + <DeviceForm + initialData={device} + onSuccess={() => router.push("/devices/list")} + onCancel={() => router.push("/devices/list")} + /> + </div> + ); +} diff --git a/fems-app/src/app/(equipment)/devices/page.tsx b/fems-app/src/app/(equipment)/devices/page.tsx new file mode 100644 index 0000000..30d11fd --- /dev/null +++ b/fems-app/src/app/(equipment)/devices/page.tsx @@ -0,0 +1,228 @@ +// src/app/devices/list/page.tsx +"use client"; + +import React, { useCallback } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { DataTable } from "@/components/ui/data-table"; +import { api } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { useToast } from "@/hooks/use-toast"; +import { + Plus, + Edit, + Trash2, + Search, + Wifi, + WifiOff, + AlertCircle, +} from "lucide-react"; +import { ColumnDef } from "@tanstack/react-table"; +import { useAuthStore } from "@/stores/auth"; +import { AxiosError } from "axios"; +import { Badge } from "@/components/ui/badge"; +import { Device } from "@/types/device"; +import { DeviceDetailDialog } from "./components/Detail/DeviceDetailDialog"; +import { useRouter } from "next/navigation"; + +const DevicesPage = () => { + const router = useRouter(); + const { token } = useAuthStore(); + const [selectedDevice, setSelectedDevice] = React.useState<Device | null>( + null + ); + const [isDetailOpen, setIsDetailOpen] = React.useState(false); + const { toast } = useToast(); + const queryClient = useQueryClient(); + + // Navigation handlers + const handleNavigateToCreate = useCallback(() => { + router.push("/devices/list/new"); + }, [router]); + + const handleNavigateToEdit = useCallback( + (id: string) => { + router.push(`/devices/list/${id}`); + }, + [router] + ); + + // Fetch devices + const { data: devices, isLoading } = useQuery<Device[]>({ + queryKey: ["devices"], + queryFn: async () => { + const { data } = await api.get("/api/v1/app/devices"); + return data; + }, + enabled: !!token, + }); + + // Delete device mutation + const deleteMutation = useMutation({ + mutationFn: async (id: string) => { + await api.delete(`/api/v1/app/devices/${id}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["devices"] }); + toast({ + title: "디바이스 삭제", + description: "디바이스가 삭제되었습니다.", + }); + }, + onError: (error: AxiosError) => { + toast({ + title: "디바이스 삭제 실패", + description: (error.response?.data as { message: string }).message, + variant: "destructive", + }); + }, + }); + + // Handle device selection for detail view + const handleShowDetails = useCallback((device: Device) => { + setSelectedDevice(device); + setIsDetailOpen(true); + }, []); + + // Handle device deletion + const handleDelete = useCallback( + (id: string) => { + if (confirm("정말 삭제하시겠습니까?")) { + deleteMutation.mutate(id); + } + }, + [deleteMutation] + ); + + const columns: ColumnDef<Device>[] = React.useMemo( + () => [ + { + accessorKey: "id", + header: "디바이스 ID", + // Hide the column + hidden: true, + }, + { + accessorKey: "device_name", + header: "디바이스명", + }, + { + accessorKey: "device_type", + header: "유형", + cell: ({ row }) => ( + <Badge variant="outline">{row.original.device_type}</Badge> + ), + }, + { + accessorKey: "status", + header: "상태", + cell: ({ row }) => { + const statusMap = { + ACTIVE: { label: "활성", icon: Wifi, variant: "default" }, + INACTIVE: { label: "비활성", icon: WifiOff, variant: "secondary" }, + ERROR: { label: "오류", icon: AlertCircle, variant: "destructive" }, + } as const; + + const status = + statusMap[row.original.status as keyof typeof statusMap]; + const Icon = status.icon; + + return ( + <Badge variant={status.variant}> + <Icon className="w-3 h-3 mr-1" /> + {status.label} + </Badge> + ); + }, + }, + { + accessorKey: "location", + header: "위치", + }, + { + id: "actions", + header: "액션", + cell: ({ row }) => ( + <div className="flex items-center gap-2"> + <Button + variant="ghost" + size="sm" + onClick={() => handleShowDetails(row.original)} + > + <Search className="h-4 w-4" /> + </Button> + <Button + variant="ghost" + size="sm" + onClick={() => handleNavigateToEdit(row.original.id)} + > + <Edit className="h-4 w-4" /> + </Button> + <Button + variant="ghost" + size="sm" + onClick={() => handleDelete(row.original.id)} + > + <Trash2 className="h-4 w-4" /> + </Button> + </div> + ), + }, + ], + [handleNavigateToEdit, handleShowDetails, handleDelete] + ); + + if (isLoading) { + return ( + <div className="container mx-auto py-6"> + <div>Loading...</div> + </div> + ); + } + + return ( + <div className="container mx-auto py-6"> + {/* Header */} + <div className="flex justify-between items-center mb-6"> + <div className="space-y-1"> + <h1 className="text-3xl font-bold">디바이스 관리</h1> + <p className="text-muted-foreground"> + 현장 디바이스를 등록하고 관리합니다. + </p> + </div> + <Button onClick={handleNavigateToCreate}> + <Plus className="mr-2 h-4 w-4" /> + 디바이스 등록 + </Button> + </div> + + {/* Devices Table */} + <Card> + <CardHeader> + <CardTitle>디바이스 목록</CardTitle> + </CardHeader> + <CardContent> + {devices && devices.length > 0 ? ( + <DataTable columns={columns} data={devices} /> + ) : ( + <div className="text-center py-12 text-muted-foreground"> + 등록된 디바이스가 없습니다. + </div> + )} + </CardContent> + </Card> + + {/* Device Detail Dialog */} + <DeviceDetailDialog + device={selectedDevice} + isOpen={isDetailOpen} + onClose={() => { + setIsDetailOpen(false); + setSelectedDevice(null); + }} + /> + </div> + ); +}; + +export default DevicesPage; diff --git a/fems-app/src/components/layout/SideNav.tsx b/fems-app/src/components/layout/SideNav.tsx index 8f9c1aa..d5d8372 100644 --- a/fems-app/src/components/layout/SideNav.tsx +++ b/fems-app/src/components/layout/SideNav.tsx @@ -85,6 +85,7 @@ const getMenuItems = ( { title: "정비 관리", href: "/maintenance", icon: Wrench }, { title: "부품 관리", href: "/parts", icon: Puzzle }, { title: "작업자 관리", href: "/personnel", icon: Users }, + { title: "디바이스", href: "/devices", icon: Sliders }, ], }, { diff --git a/fems-mqtt/data/mosquitto.db b/fems-mqtt/data/mosquitto.db index 2ec3747..5cb1b02 100644 Binary files a/fems-mqtt/data/mosquitto.db and b/fems-mqtt/data/mosquitto.db differ diff --git a/fems-mqtt/data/passwd b/fems-mqtt/data/passwd index 296b6f8..2f493bd 100644 --- a/fems-mqtt/data/passwd +++ b/fems-mqtt/data/passwd @@ -1,2 +1,2 @@ -fems:$7$101$ovh8p4Iy2vad6wam$5KugLDFKCLXl0fNtZYcEkx60rKMcNLInv128xmB4IhsW6sQa+7wyzqLNouqGLa7Fn4C0Yo5ic4PvKdT19sxR9A== -nodered_user:$7$101$cH8eC8IS2J5wB2z8$v59/KuA28HlkFX8g5njwajyKLdhNnGypJSTl5RrGfyVzPykQys87s4B0xiTvNqVAmFcDobOUWxpqDQP9BOoHMA== +fems:$7$101$pX2RGILdPjEKoCX2$4XU2mxjlVo2mkgLIjOlBDk67gdFlRhx0lxvcb72PJDKCpvXGTCak+VeUpePNYqHbM/wTva3wPZqLI0WDaqOliA== +nodered_user:$7$101$/usoPKu8HDa2SrkT$3x8mSiR1TvcrGMSvYwCOrEJmVIlbEKf5BaoXkfu+RvUnOpQCvcAE9HZc9o+uAElQV3leBlpgrk7RACYd0DaNsQ== diff --git a/fems-mqtt/log/mosquitto.log b/fems-mqtt/log/mosquitto.log index d3d2b3e..fc57973 100755 --- a/fems-mqtt/log/mosquitto.log +++ b/fems-mqtt/log/mosquitto.log @@ -17661,3 +17661,200 @@ To fix this, use `chmod 0700 /mosquitto/config/passwd`. 1732601847: Client fems_realtime_39 closed its connection. 1732601847: mosquitto version 2.0.20 terminating 1732601847: Saving in-memory database to /mosquitto/data//mosquitto.db. +1732602814: mosquitto version 2.0.20 starting +1732602814: Config loaded from /mosquitto/config/mosquitto.conf. +1732602814: Opening ipv4 listen socket on port 1883. +1732602814: Opening ipv6 listen socket on port 1883. +1732602814: mosquitto version 2.0.20 running +1732602816: New connection from 172.19.0.7:54110 on port 1883. +1732602816: New client connected from 172.19.0.7:54110 as fems_realtime_40 (p2, c1, k60, u'fems'). +1732602844: New connection from ::1:58526 on port 1883. +1732602844: New client connected from ::1:58526 as auto-754A82FC-940A-907F-D4A8-FB6A77F24149 (p2, c1, k60, u'fems'). +1732602844: Client auto-754A82FC-940A-907F-D4A8-FB6A77F24149 closed its connection. +1732602874: New connection from ::1:35756 on port 1883. +1732602874: New client connected from ::1:35756 as auto-E0C2A639-0338-A735-0502-A55C21010132 (p2, c1, k60, u'fems'). +1732602874: Client auto-E0C2A639-0338-A735-0502-A55C21010132 closed its connection. +1732602904: New connection from ::1:36586 on port 1883. +1732602904: New client connected from ::1:36586 as auto-0943EDB3-90E7-BE73-8FD4-5F404F7FD980 (p2, c1, k60, u'fems'). +1732602904: Client auto-0943EDB3-90E7-BE73-8FD4-5F404F7FD980 closed its connection. +1732602934: New connection from ::1:39870 on port 1883. +1732602934: New client connected from ::1:39870 as auto-1601E100-AD13-F040-76E8-21CC77178F86 (p2, c1, k60, u'fems'). +1732602934: Client auto-1601E100-AD13-F040-76E8-21CC77178F86 closed its connection. +1732602964: New connection from ::1:34758 on port 1883. +1732602964: New client connected from ::1:34758 as auto-5AFB6C41-E0D9-C045-9559-45EBB665B291 (p2, c1, k60, u'fems'). +1732602964: Client auto-5AFB6C41-E0D9-C045-9559-45EBB665B291 closed its connection. +1732602994: New connection from ::1:59586 on port 1883. +1732602994: New client connected from ::1:59586 as auto-CD29980A-DD1B-D3D3-6726-DEBF789EE795 (p2, c1, k60, u'fems'). +1732602994: Client auto-CD29980A-DD1B-D3D3-6726-DEBF789EE795 closed its connection. +1732603024: New connection from ::1:57712 on port 1883. +1732603024: New client connected from ::1:57712 as auto-91916600-C085-90AF-4715-74C65FA37D47 (p2, c1, k60, u'fems'). +1732603024: Client auto-91916600-C085-90AF-4715-74C65FA37D47 closed its connection. +1732603054: New connection from ::1:54980 on port 1883. +1732603055: New client connected from ::1:54980 as auto-96CE561E-90AB-EDBF-209C-43E9591BB20F (p2, c1, k60, u'fems'). +1732603055: Client auto-96CE561E-90AB-EDBF-209C-43E9591BB20F closed its connection. +1732603085: New connection from ::1:45488 on port 1883. +1732603085: New client connected from ::1:45488 as auto-9FD18F2D-A2B3-F2B7-8758-4D263D89AAE8 (p2, c1, k60, u'fems'). +1732603085: Client auto-9FD18F2D-A2B3-F2B7-8758-4D263D89AAE8 closed its connection. +1732603115: New connection from ::1:60430 on port 1883. +1732603115: New client connected from ::1:60430 as auto-0D8C7443-EE3C-F235-6956-56B303E1643A (p2, c1, k60, u'fems'). +1732603115: Client auto-0D8C7443-EE3C-F235-6956-56B303E1643A closed its connection. +1732603145: New connection from ::1:42164 on port 1883. +1732603145: New client connected from ::1:42164 as auto-B61BFE79-FB48-9BFD-02DA-0814999E260E (p2, c1, k60, u'fems'). +1732603145: Client auto-B61BFE79-FB48-9BFD-02DA-0814999E260E closed its connection. +1732603175: New connection from ::1:51988 on port 1883. +1732603175: New client connected from ::1:51988 as auto-BE2F790E-BE6C-DE79-85EB-0BE1A4AB04AE (p2, c1, k60, u'fems'). +1732603175: Client auto-BE2F790E-BE6C-DE79-85EB-0BE1A4AB04AE closed its connection. +1732603205: New connection from ::1:36822 on port 1883. +1732603205: New client connected from ::1:36822 as auto-D8165230-B8F2-04A2-7F0A-476D72959620 (p2, c1, k60, u'fems'). +1732603205: Client auto-D8165230-B8F2-04A2-7F0A-476D72959620 closed its connection. +1732603235: New connection from ::1:50446 on port 1883. +1732603235: New client connected from ::1:50446 as auto-3B6CEFE6-1FD9-1DD6-A3EE-6C69EC82AC3B (p2, c1, k60, u'fems'). +1732603235: Client auto-3B6CEFE6-1FD9-1DD6-A3EE-6C69EC82AC3B closed its connection. +1732603265: New connection from ::1:46622 on port 1883. +1732603265: New client connected from ::1:46622 as auto-F76E3E39-5677-AA3A-8D38-75341055603C (p2, c1, k60, u'fems'). +1732603265: Client auto-F76E3E39-5677-AA3A-8D38-75341055603C closed its connection. +1732603295: New connection from ::1:41536 on port 1883. +1732603295: New client connected from ::1:41536 as auto-2B723323-33EF-DD68-8B02-6578A4306B82 (p2, c1, k60, u'fems'). +1732603295: Client auto-2B723323-33EF-DD68-8B02-6578A4306B82 closed its connection. +1732603325: New connection from ::1:49736 on port 1883. +1732603325: New client connected from ::1:49736 as auto-CCDFD4E5-9CC2-D93E-E524-75EEEAF9863F (p2, c1, k60, u'fems'). +1732603325: Client auto-CCDFD4E5-9CC2-D93E-E524-75EEEAF9863F closed its connection. +1732603355: New connection from ::1:52870 on port 1883. +1732603355: New client connected from ::1:52870 as auto-96B81626-9E64-9AF6-E8C2-BBDD66E60CDC (p2, c1, k60, u'fems'). +1732603355: Client auto-96B81626-9E64-9AF6-E8C2-BBDD66E60CDC closed its connection. +1732603385: New connection from ::1:43772 on port 1883. +1732603385: New client connected from ::1:43772 as auto-2B4B8286-D794-42DC-823A-CC3F59471AB3 (p2, c1, k60, u'fems'). +1732603385: Client auto-2B4B8286-D794-42DC-823A-CC3F59471AB3 closed its connection. +1732603416: New connection from ::1:44142 on port 1883. +1732603416: New client connected from ::1:44142 as auto-ACE35949-8840-FD41-66E0-FE9A35F7B0C4 (p2, c1, k60, u'fems'). +1732603416: Client auto-ACE35949-8840-FD41-66E0-FE9A35F7B0C4 closed its connection. +1732603446: New connection from ::1:41878 on port 1883. +1732603446: New client connected from ::1:41878 as auto-CD4C9897-E54E-9AFD-3E42-A53EBDD3A44A (p2, c1, k60, u'fems'). +1732603446: Client auto-CD4C9897-E54E-9AFD-3E42-A53EBDD3A44A closed its connection. +1732603476: New connection from ::1:42368 on port 1883. +1732603476: New client connected from ::1:42368 as auto-49879275-FCBC-92B9-C516-91C91CB42533 (p2, c1, k60, u'fems'). +1732603476: Client auto-49879275-FCBC-92B9-C516-91C91CB42533 closed its connection. +1732603506: New connection from ::1:37142 on port 1883. +1732603506: New client connected from ::1:37142 as auto-997C9096-DD2F-8F17-B6B3-FFA22F76E1C5 (p2, c1, k60, u'fems'). +1732603506: Client auto-997C9096-DD2F-8F17-B6B3-FFA22F76E1C5 closed its connection. +1732603536: New connection from ::1:44342 on port 1883. +1732603536: New client connected from ::1:44342 as auto-917626B3-543A-C17E-DD91-C41A2DAA860D (p2, c1, k60, u'fems'). +1732603536: Client auto-917626B3-543A-C17E-DD91-C41A2DAA860D closed its connection. +1732603566: New connection from ::1:40722 on port 1883. +1732603566: New client connected from ::1:40722 as auto-5B22E48E-0D64-DC2F-272B-CEA8705C3161 (p2, c1, k60, u'fems'). +1732603566: Client auto-5B22E48E-0D64-DC2F-272B-CEA8705C3161 closed its connection. +1732603596: New connection from ::1:48506 on port 1883. +1732603596: New client connected from ::1:48506 as auto-53073475-9D8E-15E6-7744-50D25B13C4D3 (p2, c1, k60, u'fems'). +1732603596: Client auto-53073475-9D8E-15E6-7744-50D25B13C4D3 closed its connection. +1732603626: New connection from ::1:43278 on port 1883. +1732603626: New client connected from ::1:43278 as auto-3CF226CF-4106-068D-B51C-26433B3C72CD (p2, c1, k60, u'fems'). +1732603626: Client auto-3CF226CF-4106-068D-B51C-26433B3C72CD closed its connection. +1732603656: New connection from ::1:41992 on port 1883. +1732603656: New client connected from ::1:41992 as auto-0BAEB8A4-BD28-0D71-22B1-491CD4FDA7E7 (p2, c1, k60, u'fems'). +1732603656: Client auto-0BAEB8A4-BD28-0D71-22B1-491CD4FDA7E7 closed its connection. +1732603686: New connection from ::1:44918 on port 1883. +1732603686: New client connected from ::1:44918 as auto-7384624C-68B2-5113-E5EA-0AC0F2296313 (p2, c1, k60, u'fems'). +1732603686: Client auto-7384624C-68B2-5113-E5EA-0AC0F2296313 closed its connection. +1732603716: New connection from ::1:48628 on port 1883. +1732603716: New client connected from ::1:48628 as auto-C41451CE-FE2A-3662-C1D3-23C583ED7DA2 (p2, c1, k60, u'fems'). +1732603716: Client auto-C41451CE-FE2A-3662-C1D3-23C583ED7DA2 closed its connection. +1732603746: New connection from ::1:35912 on port 1883. +1732603746: New client connected from ::1:35912 as auto-564B487B-FA69-67AB-B264-30D803421420 (p2, c1, k60, u'fems'). +1732603747: Client auto-564B487B-FA69-67AB-B264-30D803421420 closed its connection. +1732603777: New connection from ::1:33478 on port 1883. +1732603777: New client connected from ::1:33478 as auto-26E874FE-32D0-A240-C3A7-18CCADB4D852 (p2, c1, k60, u'fems'). +1732603777: Client auto-26E874FE-32D0-A240-C3A7-18CCADB4D852 closed its connection. +1732603807: New connection from ::1:45306 on port 1883. +1732603807: New client connected from ::1:45306 as auto-71BDD556-ED73-BB53-2B90-E0C91C33A631 (p2, c1, k60, u'fems'). +1732603807: Client auto-71BDD556-ED73-BB53-2B90-E0C91C33A631 closed its connection. +1732603837: New connection from ::1:58252 on port 1883. +1732603837: New client connected from ::1:58252 as auto-8D726B23-6879-88A4-145C-4E43536E49CB (p2, c1, k60, u'fems'). +1732603837: Client auto-8D726B23-6879-88A4-145C-4E43536E49CB closed its connection. +1732603867: New connection from ::1:47832 on port 1883. +1732603867: New client connected from ::1:47832 as auto-17FFB8C5-1041-EC11-C68B-F52581404BAE (p2, c1, k60, u'fems'). +1732603867: Client auto-17FFB8C5-1041-EC11-C68B-F52581404BAE closed its connection. +1732603897: New connection from ::1:49580 on port 1883. +1732603897: New client connected from ::1:49580 as auto-EE24A5EA-9CB2-EBA1-831C-97180FCE6D62 (p2, c1, k60, u'fems'). +1732603897: Client auto-EE24A5EA-9CB2-EBA1-831C-97180FCE6D62 closed its connection. +1732603927: New connection from ::1:52654 on port 1883. +1732603927: New client connected from ::1:52654 as auto-6EA0A671-30EB-2121-1244-41D77A72826D (p2, c1, k60, u'fems'). +1732603927: Client auto-6EA0A671-30EB-2121-1244-41D77A72826D closed its connection. +1732603957: New connection from ::1:59852 on port 1883. +1732603957: New client connected from ::1:59852 as auto-75013A3D-05A9-3F9E-29F1-4EC7E1469D2F (p2, c1, k60, u'fems'). +1732603957: Client auto-75013A3D-05A9-3F9E-29F1-4EC7E1469D2F closed its connection. +1732603987: New connection from ::1:48904 on port 1883. +1732603987: New client connected from ::1:48904 as auto-D20D97A5-E260-BFBB-2D52-2DD28C21C546 (p2, c1, k60, u'fems'). +1732603987: Client auto-D20D97A5-E260-BFBB-2D52-2DD28C21C546 closed its connection. +1732604017: New connection from ::1:36682 on port 1883. +1732604017: New client connected from ::1:36682 as auto-E59B14CD-F9B2-43BD-6ED3-4601E61ED8D5 (p2, c1, k60, u'fems'). +1732604018: Client auto-E59B14CD-F9B2-43BD-6ED3-4601E61ED8D5 closed its connection. +1732604048: New connection from ::1:57168 on port 1883. +1732604048: New client connected from ::1:57168 as auto-01616CFF-8E2F-D2F8-E935-59C20C83CAF5 (p2, c1, k60, u'fems'). +1732604048: Client auto-01616CFF-8E2F-D2F8-E935-59C20C83CAF5 closed its connection. +1732604078: New connection from ::1:41446 on port 1883. +1732604078: New client connected from ::1:41446 as auto-A8D32F7E-E362-15F6-ADEC-9FAFE25E96AC (p2, c1, k60, u'fems'). +1732604078: Client auto-A8D32F7E-E362-15F6-ADEC-9FAFE25E96AC closed its connection. +1732604108: New connection from ::1:45182 on port 1883. +1732604108: New client connected from ::1:45182 as auto-B839DEAF-7357-DD70-9755-01EAE665A9F5 (p2, c1, k60, u'fems'). +1732604108: Client auto-B839DEAF-7357-DD70-9755-01EAE665A9F5 closed its connection. +1732604138: New connection from ::1:56366 on port 1883. +1732604138: New client connected from ::1:56366 as auto-ED4C27B2-39FD-F202-D07A-C48047DD77EC (p2, c1, k60, u'fems'). +1732604138: Client auto-ED4C27B2-39FD-F202-D07A-C48047DD77EC closed its connection. +1732604168: New connection from ::1:45452 on port 1883. +1732604168: New client connected from ::1:45452 as auto-A50DB7E4-5193-DB6E-19BC-B3F606A397E0 (p2, c1, k60, u'fems'). +1732604168: Client auto-A50DB7E4-5193-DB6E-19BC-B3F606A397E0 closed its connection. +1732604198: New connection from ::1:45744 on port 1883. +1732604198: New client connected from ::1:45744 as auto-7B0FDA2A-1DC0-D742-1516-F6075FEED0AC (p2, c1, k60, u'fems'). +1732604198: Client auto-7B0FDA2A-1DC0-D742-1516-F6075FEED0AC closed its connection. +1732604228: New connection from ::1:37222 on port 1883. +1732604228: New client connected from ::1:37222 as auto-619F8BEF-D282-4CB3-4E7D-DDB997D3AD26 (p2, c1, k60, u'fems'). +1732604228: Client auto-619F8BEF-D282-4CB3-4E7D-DDB997D3AD26 closed its connection. +1732604258: New connection from ::1:52198 on port 1883. +1732604258: New client connected from ::1:52198 as auto-46F084CC-54C5-5CAF-5F3B-2881E680FE3B (p2, c1, k60, u'fems'). +1732604258: Client auto-46F084CC-54C5-5CAF-5F3B-2881E680FE3B closed its connection. +1732604288: New connection from ::1:46508 on port 1883. +1732604288: New client connected from ::1:46508 as auto-10688AF9-CB04-9137-1639-C24AF4BFF300 (p2, c1, k60, u'fems'). +1732604288: Client auto-10688AF9-CB04-9137-1639-C24AF4BFF300 closed its connection. +1732604318: New connection from ::1:35532 on port 1883. +1732604318: New client connected from ::1:35532 as auto-1816CCDF-E9B3-C5D8-4730-454783971FE1 (p2, c1, k60, u'fems'). +1732604318: Client auto-1816CCDF-E9B3-C5D8-4730-454783971FE1 closed its connection. +1732604348: New connection from ::1:55400 on port 1883. +1732604348: New client connected from ::1:55400 as auto-F97F5BE7-F898-2684-B9E3-5D8A4DC58194 (p2, c1, k60, u'fems'). +1732604348: Client auto-F97F5BE7-F898-2684-B9E3-5D8A4DC58194 closed its connection. +1732604379: New connection from ::1:40820 on port 1883. +1732604379: New client connected from ::1:40820 as auto-E3D92CF6-880F-C242-D857-7D9743488EE9 (p2, c1, k60, u'fems'). +1732604379: Client auto-E3D92CF6-880F-C242-D857-7D9743488EE9 closed its connection. +1732604409: New connection from ::1:43856 on port 1883. +1732604409: New client connected from ::1:43856 as auto-FF3D9A73-0327-58C8-1BF5-C13E4D4B3887 (p2, c1, k60, u'fems'). +1732604409: Client auto-FF3D9A73-0327-58C8-1BF5-C13E4D4B3887 closed its connection. +1732604439: New connection from ::1:40000 on port 1883. +1732604439: New client connected from ::1:40000 as auto-16233E17-161B-BDE2-FB6C-3AD33FD53B81 (p2, c1, k60, u'fems'). +1732604439: Client auto-16233E17-161B-BDE2-FB6C-3AD33FD53B81 closed its connection. +1732604469: New connection from ::1:55918 on port 1883. +1732604469: New client connected from ::1:55918 as auto-52183970-05BC-084B-DFBB-3D9203B6EB57 (p2, c1, k60, u'fems'). +1732604469: Client auto-52183970-05BC-084B-DFBB-3D9203B6EB57 closed its connection. +1732604499: New connection from ::1:53410 on port 1883. +1732604499: New client connected from ::1:53410 as auto-51B634AB-F25F-C151-BC2D-3E8E342F3476 (p2, c1, k60, u'fems'). +1732604499: Client auto-51B634AB-F25F-C151-BC2D-3E8E342F3476 closed its connection. +1732604529: New connection from ::1:32802 on port 1883. +1732604529: New client connected from ::1:32802 as auto-D9368501-4284-8BC0-5623-47C0F90C7698 (p2, c1, k60, u'fems'). +1732604529: Client auto-D9368501-4284-8BC0-5623-47C0F90C7698 closed its connection. +1732604559: New connection from ::1:32868 on port 1883. +1732604559: New client connected from ::1:32868 as auto-B593D0CF-C01F-1C77-970A-0A543BB0EB87 (p2, c1, k60, u'fems'). +1732604559: Client auto-B593D0CF-C01F-1C77-970A-0A543BB0EB87 closed its connection. +1732604589: New connection from ::1:46038 on port 1883. +1732604589: New client connected from ::1:46038 as auto-1BB57210-2946-DB35-2424-DF152AA62163 (p2, c1, k60, u'fems'). +1732604589: Client auto-1BB57210-2946-DB35-2424-DF152AA62163 closed its connection. +1732604615: Saving in-memory database to /mosquitto/data//mosquitto.db. +1732604619: New connection from ::1:42858 on port 1883. +1732604619: New client connected from ::1:42858 as auto-3CEA4C57-0514-8061-F3FA-B6610B355505 (p2, c1, k60, u'fems'). +1732604619: Client auto-3CEA4C57-0514-8061-F3FA-B6610B355505 closed its connection. +1732604649: New connection from ::1:37340 on port 1883. +1732604649: New client connected from ::1:37340 as auto-0EAEB5C6-4B5A-398E-C2D8-EFD939439679 (p2, c1, k60, u'fems'). +1732604649: Client auto-0EAEB5C6-4B5A-398E-C2D8-EFD939439679 closed its connection. +1732604679: New connection from ::1:56122 on port 1883. +1732604679: New client connected from ::1:56122 as auto-908E9122-3B50-6862-DF6E-200E71E4AAA2 (p2, c1, k60, u'fems'). +1732604679: Client auto-908E9122-3B50-6862-DF6E-200E71E4AAA2 closed its connection. +1732604709: New connection from ::1:51980 on port 1883. +1732604709: New client connected from ::1:51980 as auto-FE0AAE47-6009-8CA2-43D4-BEFC7E816FE0 (p2, c1, k60, u'fems'). +1732604709: Client auto-FE0AAE47-6009-8CA2-43D4-BEFC7E816FE0 closed its connection. diff --git a/fems-realtime-api/logs/info/info-2024-11-26.log b/fems-realtime-api/logs/info/info-2024-11-26.log index f0ffb43..d3c791e 100644 --- a/fems-realtime-api/logs/info/info-2024-11-26.log +++ b/fems-realtime-api/logs/info/info-2024-11-26.log @@ -82,3 +82,10 @@ {"environment":"development","level":"info","message":"Connected to MQTT broker","service":"fems-edge","timestamp":"2024-11-26 15:14:45"} {"environment":"development","level":"info","message":"Realtime backend server running on port 3004","service":"fems-edge","timestamp":"2024-11-26 15:14:45"} {"environment":"development","level":"info","message":"Subscribed to data/+/+/+/#","service":"fems-edge","timestamp":"2024-11-26 15:14:45"} +{"environment":"development","level":"info","message":"Successfully connected to TimescaleDB","service":"fems-edge","timestamp":"2024-11-26 15:33:35"} +{"environment":"development","level":"info","message":"Successfully connected to Redis","service":"fems-edge","timestamp":"2024-11-26 15:33:35"} +{"environment":"development","level":"info","message":"All services are connected","service":"fems-edge","timestamp":"2024-11-26 15:33:35"} +{"broker":"mqtt://fems-mqtt:1883","clientId":"fems_realtime_40","environment":"development","level":"info","message":"Connecting to MQTT broker...","service":"fems-edge","timestamp":"2024-11-26 15:33:36"} +{"environment":"development","level":"info","message":"Connected to MQTT broker","service":"fems-edge","timestamp":"2024-11-26 15:33:36"} +{"environment":"development","level":"info","message":"Realtime backend server running on port 3004","service":"fems-edge","timestamp":"2024-11-26 15:33:36"} +{"environment":"development","level":"info","message":"Subscribed to data/+/+/+/#","service":"fems-edge","timestamp":"2024-11-26 15:33:36"} diff --git a/fems-realtime-api/logs/system/system-2024-11-26.log b/fems-realtime-api/logs/system/system-2024-11-26.log index f0ffb43..d3c791e 100644 --- a/fems-realtime-api/logs/system/system-2024-11-26.log +++ b/fems-realtime-api/logs/system/system-2024-11-26.log @@ -82,3 +82,10 @@ {"environment":"development","level":"info","message":"Connected to MQTT broker","service":"fems-edge","timestamp":"2024-11-26 15:14:45"} {"environment":"development","level":"info","message":"Realtime backend server running on port 3004","service":"fems-edge","timestamp":"2024-11-26 15:14:45"} {"environment":"development","level":"info","message":"Subscribed to data/+/+/+/#","service":"fems-edge","timestamp":"2024-11-26 15:14:45"} +{"environment":"development","level":"info","message":"Successfully connected to TimescaleDB","service":"fems-edge","timestamp":"2024-11-26 15:33:35"} +{"environment":"development","level":"info","message":"Successfully connected to Redis","service":"fems-edge","timestamp":"2024-11-26 15:33:35"} +{"environment":"development","level":"info","message":"All services are connected","service":"fems-edge","timestamp":"2024-11-26 15:33:35"} +{"broker":"mqtt://fems-mqtt:1883","clientId":"fems_realtime_40","environment":"development","level":"info","message":"Connecting to MQTT broker...","service":"fems-edge","timestamp":"2024-11-26 15:33:36"} +{"environment":"development","level":"info","message":"Connected to MQTT broker","service":"fems-edge","timestamp":"2024-11-26 15:33:36"} +{"environment":"development","level":"info","message":"Realtime backend server running on port 3004","service":"fems-edge","timestamp":"2024-11-26 15:33:36"} +{"environment":"development","level":"info","message":"Subscribed to data/+/+/+/#","service":"fems-edge","timestamp":"2024-11-26 15:33:36"}