auto commit

This commit is contained in:
bangdk 2024-11-26 16:05:19 +09:00
parent 06d388e854
commit 5de2dd5bb0
26 changed files with 3135 additions and 3 deletions

3
.gitignore vendored
View File

@ -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/*

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@ -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;

View 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();

View File

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

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

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

View File

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

View File

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

View File

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

View File

@ -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 />
&apos; &apos;
.
</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>
);
}

View File

@ -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>;

View File

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

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

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

View 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;

View File

@ -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.

View File

@ -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==

View File

@ -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.

View File

@ -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"}

View File

@ -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"}