auto commit
This commit is contained in:
parent
06d388e854
commit
5de2dd5bb0
3
.gitignore
vendored
3
.gitignore
vendored
@ -21,3 +21,6 @@ shared/
|
|||||||
# MQTT ignored files (무시할 파일들)
|
# MQTT ignored files (무시할 파일들)
|
||||||
fems-mqtt/data/
|
fems-mqtt/data/
|
||||||
fems-mqtt/log/
|
fems-mqtt/log/
|
||||||
|
|
||||||
|
fems-realtime-api/node_modules/
|
||||||
|
fems-realtime-api/logs/*
|
194
fems-api/src/controllers/app/device/device.controller.js
Normal file
194
fems-api/src/controllers/app/device/device.controller.js
Normal file
@ -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;
|
94
fems-api/src/models/DataPoint.js
Normal file
94
fems-api/src/models/DataPoint.js
Normal file
@ -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;
|
86
fems-api/src/models/Device.js
Normal file
86
fems-api/src/models/Device.js
Normal file
@ -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;
|
70
fems-api/src/models/DeviceConnection.js
Normal file
70
fems-api/src/models/DeviceConnection.js
Normal file
@ -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;
|
35
fems-api/src/models/DeviceStatus.js
Normal file
35
fems-api/src/models/DeviceStatus.js
Normal file
@ -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;
|
@ -15,6 +15,7 @@ const equipmentPartsController = require("../controllers/app/equipmentParts/equi
|
|||||||
const departmentController = require("../controllers/app/department/department.controller");
|
const departmentController = require("../controllers/app/department/department.controller");
|
||||||
const healthController = require("../controllers/app/health/health.controller");
|
const healthController = require("../controllers/app/health/health.controller");
|
||||||
const companiesController = require("../controllers/admin/companies/companies.controller");
|
const companiesController = require("../controllers/admin/companies/companies.controller");
|
||||||
|
const deviceController = require("../controllers/app/device/device.controller");
|
||||||
|
|
||||||
router.use("/health", healthController);
|
router.use("/health", healthController);
|
||||||
router.use("/auth", authController);
|
router.use("/auth", authController);
|
||||||
@ -29,5 +30,6 @@ router.use("/parts", partsController);
|
|||||||
router.use("/equipment-parts", equipmentPartsController);
|
router.use("/equipment-parts", equipmentPartsController);
|
||||||
router.use("/department", departmentController);
|
router.use("/department", departmentController);
|
||||||
router.use("/companies", companiesController);
|
router.use("/companies", companiesController);
|
||||||
|
router.use("/devices", deviceController);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
396
fems-api/src/services/device.service.js
Normal file
396
fems-api/src/services/device.service.js
Normal file
@ -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();
|
@ -15,6 +15,7 @@ const {
|
|||||||
maintenanceLogDefinitions,
|
maintenanceLogDefinitions,
|
||||||
equipmentDataTemplate,
|
equipmentDataTemplate,
|
||||||
} = require("./setupData");
|
} = require("./setupData");
|
||||||
|
const { initializeDevices } = require("./deviceInitializer");
|
||||||
const logger = require("../../config/logger");
|
const logger = require("../../config/logger");
|
||||||
const { createMaintenanceData } = require("./maintenanceSetup");
|
const { createMaintenanceData } = require("./maintenanceSetup");
|
||||||
|
|
||||||
@ -32,6 +33,9 @@ async function createInitialData(companyId, branchId) {
|
|||||||
// 정비 관련 데이터 생성 추가
|
// 정비 관련 데이터 생성 추가
|
||||||
await createMaintenanceData(companyId, branchId);
|
await createMaintenanceData(companyId, branchId);
|
||||||
|
|
||||||
|
// 3. 디바이스 관련 데이터 생성
|
||||||
|
await initializeDevices(companyId, branchId);
|
||||||
|
|
||||||
logger.info("Initial development data created successfully");
|
logger.info("Initial development data created successfully");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error creating initial data:", error);
|
logger.error("Error creating initial data:", error);
|
||||||
|
263
fems-api/src/utils/initialSetup/deviceInitializer.js
Normal file
263
fems-api/src/utils/initialSetup/deviceInitializer.js
Normal file
@ -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,
|
||||||
|
};
|
102
fems-app/src/app/(equipment)/devices/[id]/page.tsx
Normal file
102
fems-app/src/app/(equipment)/devices/[id]/page.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
@ -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>;
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
15
fems-app/src/app/(equipment)/devices/new/layout.tsx
Normal file
15
fems-app/src/app/(equipment)/devices/new/layout.tsx
Normal file
@ -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;
|
||||||
|
}
|
52
fems-app/src/app/(equipment)/devices/new/page.tsx
Normal file
52
fems-app/src/app/(equipment)/devices/new/page.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
228
fems-app/src/app/(equipment)/devices/page.tsx
Normal file
228
fems-app/src/app/(equipment)/devices/page.tsx
Normal file
@ -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;
|
@ -85,6 +85,7 @@ const getMenuItems = (
|
|||||||
{ title: "정비 관리", href: "/maintenance", icon: Wrench },
|
{ title: "정비 관리", href: "/maintenance", icon: Wrench },
|
||||||
{ title: "부품 관리", href: "/parts", icon: Puzzle },
|
{ title: "부품 관리", href: "/parts", icon: Puzzle },
|
||||||
{ title: "작업자 관리", href: "/personnel", icon: Users },
|
{ title: "작업자 관리", href: "/personnel", icon: Users },
|
||||||
|
{ title: "디바이스", href: "/devices", icon: Sliders },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
Binary file not shown.
@ -1,2 +1,2 @@
|
|||||||
fems:$7$101$ovh8p4Iy2vad6wam$5KugLDFKCLXl0fNtZYcEkx60rKMcNLInv128xmB4IhsW6sQa+7wyzqLNouqGLa7Fn4C0Yo5ic4PvKdT19sxR9A==
|
fems:$7$101$pX2RGILdPjEKoCX2$4XU2mxjlVo2mkgLIjOlBDk67gdFlRhx0lxvcb72PJDKCpvXGTCak+VeUpePNYqHbM/wTva3wPZqLI0WDaqOliA==
|
||||||
nodered_user:$7$101$cH8eC8IS2J5wB2z8$v59/KuA28HlkFX8g5njwajyKLdhNnGypJSTl5RrGfyVzPykQys87s4B0xiTvNqVAmFcDobOUWxpqDQP9BOoHMA==
|
nodered_user:$7$101$/usoPKu8HDa2SrkT$3x8mSiR1TvcrGMSvYwCOrEJmVIlbEKf5BaoXkfu+RvUnOpQCvcAE9HZc9o+uAElQV3leBlpgrk7RACYd0DaNsQ==
|
||||||
|
@ -17661,3 +17661,200 @@ To fix this, use `chmod 0700 /mosquitto/config/passwd`.
|
|||||||
1732601847: Client fems_realtime_39 closed its connection.
|
1732601847: Client fems_realtime_39 closed its connection.
|
||||||
1732601847: mosquitto version 2.0.20 terminating
|
1732601847: mosquitto version 2.0.20 terminating
|
||||||
1732601847: Saving in-memory database to /mosquitto/data//mosquitto.db.
|
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.
|
||||||
|
@ -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":"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":"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":"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"}
|
||||||
|
@ -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":"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":"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":"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"}
|
||||||
|
Loading…
Reference in New Issue
Block a user