From b47627e8f96188061078fa634c4ca812d168ad43 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Wed, 13 Aug 2025 06:51:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(nas,macmini):=20=EC=88=98=EC=A7=91?= =?UTF-8?q?=EA=B8=B0=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20DB=20=EC=8A=A4=ED=82=A4=EB=A7=88=20=EB=8B=A8=EC=88=9C?= =?UTF-8?q?=ED=99=94\n\n-=20Mac=20Mini=20SSH=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=9B=90=EA=B2=A9=20=EC=88=98=EC=A7=91=EA=B8=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(/api/mac-mini/*)\n-=20DS1525+=20DSM=20API=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EC=88=98=EC=A7=91=EA=B8=B0=20=EA=B3=A8?= =?UTF-8?q?=EA=B2=A9=20=EC=B6=94=EA=B0=80=20(=EB=A1=9C=EA=B7=B8=EC=9D=B8/?= =?UTF-8?q?=EC=BD=94=EC=96=B4/=EB=94=94=EC=8A=A4=ED=81=AC=20=EC=88=98?= =?UTF-8?q?=EC=A7=91)\n-=20NAS/=EB=94=94=EC=8A=A4=ED=81=AC=20=EA=B0=84?= =?UTF-8?q?=EC=86=8C=ED=99=94=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20(nas=5Fdata,=20nas=5Fdisk=5Fdata)\n-=20README=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20(SSH=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95,=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8,=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EA=B5=AC=EC=84=B1)\n-=20Docker?= =?UTF-8?q?=20=ED=86=B5=ED=95=A9=20=ED=9B=84=20=EB=8F=99=EC=9E=91=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 74 +++++- package-lock.json | 164 +++++++++++- package.json | 2 + src/app.js | 9 + src/collectors/macMiniCollector.js | 364 +++++++++++++++++++++++++++ src/collectors/nasCollector.js | 185 ++++++++++++++ src/controllers/macMiniController.js | 240 ++++++++++++++++++ src/routes/index.js | 2 + src/routes/macMini.js | 20 ++ 9 files changed, 1045 insertions(+), 15 deletions(-) create mode 100644 src/collectors/macMiniCollector.js create mode 100644 src/collectors/nasCollector.js create mode 100644 src/controllers/macMiniController.js create mode 100644 src/routes/macMini.js diff --git a/README.md b/README.md index aff95f4..adc9ce3 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,20 @@ docker-compose logs -f docker-compose ps ``` -### 4. Tapo 기기 설정 +### 4. Mac Mini SSH 연결 설정 (집에서) + +```bash +# SSH 키 생성 (없는 경우) +ssh-keygen -t rsa + +# Mac Mini에 공개키 복사 +ssh-copy-id hyungiahn@192.168.1.122 + +# SSH 연결 테스트 +ssh hyungiahn@192.168.1.122 "echo 'SSH 연결 성공!'" +``` + +### 5. Tapo 기기 설정 ```bash # Tapo 기기 설정 파일 편집 @@ -147,6 +160,25 @@ PUT /api/devices/:id # 디바이스 업데이트 DELETE /api/devices/:id # 디바이스 삭제 ``` +### 🖥️ Mac Mini 시스템 모니터링 + +```bash +# 실시간 시스템 데이터 수집 (SSH 원격 실행) +GET /api/mac-mini/collect + +# 최근 데이터 조회 +GET /api/mac-mini/recent?limit=10 + +# 특정 기간 데이터 조회 +GET /api/mac-mini/history?startDate=2025-01-01&endDate=2025-01-02 + +# 시스템 상태 요약 +GET /api/mac-mini/summary + +# SSH 연결 테스트 +GET /api/mac-mini/test +``` + ### 💡 기본 사용 예제 ```bash @@ -172,6 +204,12 @@ curl -X POST http://localhost:9306/api/tapo/devices \ # 전력 소비 데이터 조회 curl http://localhost:9306/api/tapo/power + +# Mac Mini 시스템 상태 확인 +curl http://localhost:9306/api/mac-mini/summary + +# Mac Mini 실시간 데이터 수집 +curl http://localhost:9306/api/mac-mini/collect ``` ## 🗄️ 데이터베이스 @@ -191,9 +229,11 @@ curl http://localhost:9306/api/tapo/power ```yaml ✅ devices # 디바이스 관리 (3개 기본 데이터) ✅ users # 사용자 관리 (2개 기본 계정) -✅ power_consumption # 전력 소비 데이터 -✅ network_traffic # 네트워크 트래픽 데이터 -✅ system_resources # 시스템 리소스 데이터 +✅ mac_mini_data # Mac Mini 시스템 데이터 (SSH 원격 수집) +✅ nas_data # Synology NAS 데이터 (API 연동 예정) +✅ power_consumption # 전력 소비 데이터 (기존) +✅ network_traffic # 네트워크 트래픽 데이터 (기존) +✅ system_resources # 시스템 리소스 데이터 (기존) ``` ### 기본 데이터 @@ -211,12 +251,38 @@ curl http://localhost:9306/api/tapo/power ## 🔧 개발 가이드 +### 환경 변수 설정 + +`.env` 파일에 다음 설정을 추가하세요: + +```bash +# Mac Mini SSH 설정 (집에서 설정) +MAC_MINI_HOST=192.168.1.122 +MAC_MINI_USERNAME=hyungiahn +MAC_MINI_SSH_KEY=/Users/hyungiahn/.ssh/id_rsa +``` + +### 개발 환경별 작업 + +**외부에서 (VPN 환경):** +- ✅ 데이터베이스 구조 설계 +- ✅ API 엔드포인트 개발 +- ✅ SSH 수집기 코드 작성 +- 🚧 프론트엔드 개발 + +**집에서 (로컬 네트워크):** +- SSH 키 설정 및 연결 테스트 +- 실제 데이터 수집 테스트 +- NAS API 연동 +- 최종 통합 테스트 + ### 폴더 구조 ``` src/ ├── config/ # 설정 파일 ├── controllers/ # 컨트롤러 +├── collectors/ # 데이터 수집기 (SSH, API 연동) ├── middleware/ # 미들웨어 ├── models/ # 데이터베이스 모델 ├── routes/ # 라우터 diff --git a/package-lock.json b/package-lock.json index 79efccb..0909a5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "axios": "^1.11.0", "bcryptjs": "^2.4.3", "compression": "^1.7.4", "cors": "^2.8.5", @@ -22,6 +23,7 @@ "moment": "^2.29.4", "mysql2": "^3.6.0", "node-cron": "^3.0.2", + "node-ssh": "^13.2.1", "redis": "^4.6.7", "sequelize": "^6.32.1", "tp-link-tapo-connect": "^2.0.7", @@ -1728,6 +1730,15 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -1747,7 +1758,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, "node_modules/aws-ssl-profiles": { @@ -1760,12 +1770,14 @@ } }, "node_modules/axios": { - "version": "0.21.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", - "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", + "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.14.0" + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" } }, "node_modules/babel-jest": { @@ -1891,6 +1903,15 @@ "dev": true, "license": "MIT" }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/bcryptjs": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", @@ -2014,6 +2035,15 @@ "dev": true, "license": "MIT" }, + "node_modules/buildcheck": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz", + "integrity": "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2297,7 +2327,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -2422,6 +2451,20 @@ "node": ">= 0.10" } }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -2504,7 +2547,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -2715,7 +2757,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3252,7 +3293,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -3542,7 +3582,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -5229,6 +5268,13 @@ "node": ">=12" } }, + "node_modules/nan": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz", + "integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==", + "license": "MIT", + "optional": true + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -5271,6 +5317,38 @@ "dev": true, "license": "MIT" }, + "node_modules/node-ssh": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/node-ssh/-/node-ssh-13.2.1.tgz", + "integrity": "sha512-rfl4GWMygQfzlExPkQ2LWyya5n2jOBm5vhEnup+4mdw7tQhNpJWbP5ldr09Jfj93k5SfY5lxcn8od5qrQ/6mBg==", + "license": "MIT", + "dependencies": { + "is-stream": "^2.0.0", + "make-dir": "^3.1.0", + "sb-promise-queue": "^2.1.0", + "sb-scandir": "^3.1.0", + "shell-escape": "^0.2.0", + "ssh2": "^1.14.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/node-ssh/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/nodemon": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", @@ -5791,6 +5869,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -6103,11 +6187,31 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sb-promise-queue": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/sb-promise-queue/-/sb-promise-queue-2.1.1.tgz", + "integrity": "sha512-qXfdcJQMxMljxmPprn4Q4hl3pJmoljSCzUvvEBa9Kscewnv56n0KqrO6yWSrGLOL9E021wcGdPa39CHGKA6G0w==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/sb-scandir": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/sb-scandir/-/sb-scandir-3.1.1.tgz", + "integrity": "sha512-Q5xiQMtoragW9z8YsVYTAZcew+cRzdVBefPbb9theaIKw6cBo34WonP9qOCTKgyAmn/Ch5gmtAxT/krUgMILpA==", + "license": "MIT", + "dependencies": { + "sb-promise-queue": "^2.1.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -6307,6 +6411,12 @@ "node": ">=8" } }, + "node_modules/shell-escape": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/shell-escape/-/shell-escape-0.2.0.tgz", + "integrity": "sha512-uRRBT2MfEOyxuECseCZd28jC1AJ8hmqqneWQ4VWUTgCAFvb3wKU1jLqj6egC4Exrr88ogg3dp+zroH4wJuaXzw==", + "license": "MIT" + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -6480,6 +6590,23 @@ "node": ">= 0.6" } }, + "node_modules/ssh2": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.16.0.tgz", + "integrity": "sha512-r1X4KsBGedJqo7h8F5c4Ybpcr5RjyP+aWIG007uBPRjmdQWfEiVLzSK71Zji1B9sKxwaCvD8y8cwSkYrlLiRRg==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.20.0" + } + }, "node_modules/stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", @@ -6827,6 +6954,15 @@ "uuid": "^8.3.2" } }, + "node_modules/tp-link-tapo-connect/node_modules/axios": { + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", + "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.14.0" + } + }, "node_modules/triple-beam": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", @@ -6836,6 +6972,12 @@ "node": ">= 14.0.0" } }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 3b58e21..ff37414 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "lint:fix": "eslint src/ --fix" }, "dependencies": { + "axios": "^1.11.0", "bcryptjs": "^2.4.3", "compression": "^1.7.4", "cors": "^2.8.5", @@ -30,6 +31,7 @@ "moment": "^2.29.4", "mysql2": "^3.6.0", "node-cron": "^3.0.2", + "node-ssh": "^13.2.1", "redis": "^4.6.7", "sequelize": "^6.32.1", "tp-link-tapo-connect": "^2.0.7", diff --git a/src/app.js b/src/app.js index 08e1c94..656d689 100644 --- a/src/app.js +++ b/src/app.js @@ -74,6 +74,15 @@ app.listen(PORT, process.env.API_HOST || '0.0.0.0', async () => { logger.error('Failed to connect to database'); process.exit(1); } + + // Mac Mini 수집기 초기화 + try { + const macMiniCollector = require('./collectors/macMiniCollector'); + await macMiniCollector.initialize(); + logger.info('Mac Mini collector initialized successfully'); + } catch (error) { + logger.error('Failed to initialize Mac Mini collector:', error); + } }); // 프로세스 종료 핸들링 diff --git a/src/collectors/macMiniCollector.js b/src/collectors/macMiniCollector.js new file mode 100644 index 0000000..24cc404 --- /dev/null +++ b/src/collectors/macMiniCollector.js @@ -0,0 +1,364 @@ +const { NodeSSH } = require('node-ssh'); +const logger = require('../utils/logger'); +const { Sequelize, DataTypes } = require('sequelize'); +const sequelize = require('../config/database'); + +class MacMiniCollector { + constructor() { + this.isInitialized = false; + this.MacMiniData = null; + this.sshConnection = new NodeSSH(); + this.macMiniConfig = { + host: process.env.MAC_MINI_HOST || '192.168.1.122', + username: process.env.MAC_MINI_USERNAME || 'hyungiahn', + privateKey: process.env.MAC_MINI_SSH_KEY || '/Users/hyungiahn/.ssh/id_rsa' + }; + } + + /** + * 데이터베이스 모델 초기화 + */ + async initialize() { + try { + // Mac Mini 데이터 모델 정의 + this.MacMiniData = sequelize.define('mac_mini_data', { + id: { + type: DataTypes.BIGINT, + primaryKey: true, + autoIncrement: true + }, + timestamp: { + type: DataTypes.DATE(3), + defaultValue: Sequelize.NOW + }, + cpu_percent: { + type: DataTypes.DECIMAL(5, 2), + allowNull: true + }, + memory_percent: { + type: DataTypes.DECIMAL(5, 2), + allowNull: true + }, + disk_percent: { + type: DataTypes.DECIMAL(5, 2), + allowNull: true + }, + power_watts: { + type: DataTypes.DECIMAL(8, 2), + allowNull: true + }, + uptime_seconds: { + type: DataTypes.BIGINT, + allowNull: true + } + }, { + tableName: 'mac_mini_data', + timestamps: false, + indexes: [ + { + fields: ['timestamp'] + } + ] + }); + + this.isInitialized = true; + logger.info('MacMiniCollector initialized successfully'); + + } catch (error) { + logger.error('Failed to initialize MacMiniCollector:', error); + throw error; + } + } + + /** + * SSH 연결 설정 + */ + async connectSSH() { + try { + await this.sshConnection.connect(this.macMiniConfig); + logger.info(`SSH connected to Mac Mini: ${this.macMiniConfig.host}`); + return true; + } catch (error) { + logger.error('Failed to connect to Mac Mini via SSH:', error); + return false; + } + } + + /** + * SSH 연결 해제 + */ + disconnectSSH() { + this.sshConnection.dispose(); + } + + /** + * 원격 명령어 실행 + */ + async executeRemoteCommand(command) { + try { + const result = await this.sshConnection.execCommand(command); + if (result.code === 0) { + return result.stdout; + } else { + logger.error(`Remote command failed: ${command}`, result.stderr); + return null; + } + } catch (error) { + logger.error(`Failed to execute remote command: ${command}`, error); + return null; + } + } + + /** + * CPU 사용률 수집 (원격) + */ + async getCpuUsage() { + try { + const output = await this.executeRemoteCommand("top -l 1 | grep 'CPU usage'"); + if (!output) return null; + + // CPU usage: 8.57% user, 12.20% sys, 79.22% idle + const match = output.match(/(\d+\.\d+)% idle/); + if (match) { + const idlePercent = parseFloat(match[1]); + const usagePercent = 100 - idlePercent; + return Math.round(usagePercent * 100) / 100; // 소수점 2자리 + } + return null; + } catch (error) { + logger.error('Failed to get CPU usage:', error); + return null; + } + } + + /** + * 메모리 사용률 수집 (원격) + */ + async getMemoryUsage() { + try { + const output = await this.executeRemoteCommand("vm_stat"); + if (!output) return null; + + const lines = output.split('\n'); + + let totalPages = 0; + let freePages = 0; + let inactivePages = 0; + + for (const line of lines) { + if (line.includes('Pages free:')) { + freePages = parseInt(line.match(/(\d+)/)[1]); + } else if (line.includes('Pages active:')) { + totalPages += parseInt(line.match(/(\d+)/)[1]); + } else if (line.includes('Pages inactive:')) { + inactivePages = parseInt(line.match(/(\d+)/)[1]); + totalPages += inactivePages; + } else if (line.includes('Pages speculative:')) { + totalPages += parseInt(line.match(/(\d+)/)[1]); + } else if (line.includes('Pages wired down:')) { + totalPages += parseInt(line.match(/(\d+)/)[1]); + } + } + + totalPages += freePages; + const usedPages = totalPages - freePages - inactivePages; + const memoryPercent = (usedPages / totalPages) * 100; + + return Math.round(memoryPercent * 100) / 100; + } catch (error) { + logger.error('Failed to get memory usage:', error); + return null; + } + } + + /** + * 디스크 사용률 수집 (원격) + */ + async getDiskUsage() { + try { + const output = await this.executeRemoteCommand("df -h / | tail -1"); + if (!output) return null; + + // /dev/disk3s1s1 460Gi 10Gi 358Gi 3% 426k 3.8G 0% / + const match = output.match(/(\d+)%/); + if (match) { + return parseFloat(match[1]); + } + return null; + } catch (error) { + logger.error('Failed to get disk usage:', error); + return null; + } + } + + /** + * 시스템 업타임 수집 (초 단위) (원격) + */ + async getUptime() { + try { + const output = await this.executeRemoteCommand("uptime"); + if (!output) return null; + + // 11:17 up 2 days, 15 hrs, 1 user, load averages: 2.50 2.32 2.51 + + let totalSeconds = 0; + + // days 파싱 + const daysMatch = output.match(/(\d+) days?/); + if (daysMatch) { + totalSeconds += parseInt(daysMatch[1]) * 24 * 60 * 60; + } + + // hours 파싱 + const hoursMatch = output.match(/(\d+) hrs?/); + if (hoursMatch) { + totalSeconds += parseInt(hoursMatch[1]) * 60 * 60; + } + + // minutes 파싱 (만약 있다면) + const minutesMatch = output.match(/(\d+) mins?/); + if (minutesMatch) { + totalSeconds += parseInt(minutesMatch[1]) * 60; + } + + return totalSeconds; + } catch (error) { + logger.error('Failed to get uptime:', error); + return null; + } + } + + /** + * Tapo 플러그에서 전력 데이터 수집 (Mac Mini 전용 플러그) + */ + async getPowerUsage() { + try { + // Tapo 수집기에서 Mac Mini 전용 플러그 데이터 가져오기 + const tapoCollector = require('./tapoCollector'); + + // Mac Mini용 플러그 ID 찾기 (설정에서) + const devices = tapoCollector.getAllDeviceConfigs(); + const macMiniPlug = devices.find(device => + device.location === '서재' && device.description && + device.description.includes('Mac Mini') + ); + + if (macMiniPlug && tapoCollector.apis.has(macMiniPlug.id)) { + const api = tapoCollector.apis.get(macMiniPlug.id); + const powerData = await tapoCollector.collectFromDevice(macMiniPlug.id, api); + return powerData ? powerData.watts : null; + } + + return null; // 전력 측정 플러그가 없거나 연결되지 않음 + } catch (error) { + logger.error('Failed to get power usage:', error); + return null; + } + } + + /** + * 모든 시스템 데이터 수집 및 저장 + */ + async collectAndSave() { + if (!this.isInitialized) { + logger.warn('MacMiniCollector not initialized. Skipping data collection.'); + return null; + } + + try { + logger.info('Collecting Mac Mini system data via SSH...'); + + // SSH 연결 + const connected = await this.connectSSH(); + if (!connected) { + logger.error('Failed to establish SSH connection to Mac Mini'); + return null; + } + + try { + // 모든 데이터 병렬 수집 + const [cpuPercent, memoryPercent, diskPercent, powerWatts, uptimeSeconds] = await Promise.all([ + this.getCpuUsage(), + this.getMemoryUsage(), + this.getDiskUsage(), + this.getPowerUsage(), + this.getUptime() + ]); + + const systemData = { + cpu_percent: cpuPercent, + memory_percent: memoryPercent, + disk_percent: diskPercent, + power_watts: powerWatts, + uptime_seconds: uptimeSeconds + }; + + // 데이터베이스에 저장 + const savedData = await this.MacMiniData.create(systemData); + + logger.info('Mac Mini data collected and saved:', { + id: savedData.id, + cpu: cpuPercent + '%', + memory: memoryPercent + '%', + disk: diskPercent + '%', + power: powerWatts + 'W', + uptime: Math.round(uptimeSeconds / 3600) + 'h' + }); + + return savedData; + + } finally { + // SSH 연결 해제 + this.disconnectSSH(); + } + + } catch (error) { + logger.error('Failed to collect and save Mac Mini data:', error); + return null; + } + } + + /** + * 최근 데이터 조회 + */ + async getRecentData(limit = 10) { + if (!this.isInitialized) { + return []; + } + + try { + return await this.MacMiniData.findAll({ + order: [['timestamp', 'DESC']], + limit: limit + }); + } catch (error) { + logger.error('Failed to get recent Mac Mini data:', error); + return []; + } + } + + /** + * 특정 기간 데이터 조회 + */ + async getDataByDateRange(startDate, endDate) { + if (!this.isInitialized) { + return []; + } + + try { + return await this.MacMiniData.findAll({ + where: { + timestamp: { + [Sequelize.Op.between]: [startDate, endDate] + } + }, + order: [['timestamp', 'ASC']] + }); + } catch (error) { + logger.error('Failed to get Mac Mini data by date range:', error); + return []; + } + } +} + +module.exports = new MacMiniCollector(); diff --git a/src/collectors/nasCollector.js b/src/collectors/nasCollector.js new file mode 100644 index 0000000..51748c3 --- /dev/null +++ b/src/collectors/nasCollector.js @@ -0,0 +1,185 @@ +const axios = require('axios'); +const logger = require('../utils/logger'); +const { sequelize } = require('../config/database'); + +class NasCollector { + constructor() { + this.baseUrl = process.env.NAS_HOST || 'http://192.168.1.2:5000'; + this.account = process.env.NAS_ACCOUNT || ''; + this.password = process.env.NAS_PASSWORD || ''; + this.verifyTls = (process.env.NAS_TLS_VERIFY || 'false') === 'true'; + this.sid = null; + this.http = axios.create({ + baseURL: this.baseUrl, + timeout: 10000, + validateStatus: (s) => s >= 200 && s < 500, + }); + } + + async login() { + try { + const params = new URLSearchParams({ + api: 'SYNO.API.Auth', + version: '3', + method: 'login', + account: this.account, + passwd: this.password, + session: 'Core', + format: 'sid', + }); + const { data } = await this.http.get(`/webapi/auth.cgi?${params.toString()}`); + if (!data?.success) throw new Error(JSON.stringify(data)); + this.sid = data.data.sid; + return true; + } catch (err) { + logger.error('NAS login failed', err.message || err); + return false; + } + } + + async logout() { + try { + if (!this.sid) return; + const params = new URLSearchParams({ + api: 'SYNO.API.Auth', + version: '3', + method: 'logout', + session: 'Core', + _sid: this.sid, + }); + await this.http.get(`/webapi/auth.cgi?${params.toString()}`); + } catch (err) { + // ignore + } finally { + this.sid = null; + } + } + + async callEntry(api, method, version, params = {}) { + const sp = new URLSearchParams({ api, method, version: String(version), _sid: this.sid, ...params }); + const { data } = await this.http.get(`/webapi/entry.cgi?${sp.toString()}`); + if (!data?.success) throw new Error(`${api}.${method} failed: ${JSON.stringify(data)}`); + return data.data; + } + + // Collect core NAS stats + async collectCore() { + const core = { + cpu_percent: null, + memory_percent: null, + storage_percent: null, + raid_status: 'healthy', + active_sessions: 0, + power_watts: null, + }; + + try { + // System utilization + // SYNO.Core.System.Utilization + const util = await this.callEntry('SYNO.Core.System.Utilization', 'get', 1); + if (util?.cpu?.user !== undefined) { + const user = util.cpu.user || 0; + const system = util.cpu.system || 0; + core.cpu_percent = Math.round((user + system) * 100) / 100; + } + if (util?.memory?.total && util?.memory?.avail_real !== undefined) { + const total = util.memory.total; + const avail = util.memory.avail_real; + const usedPct = ((total - avail) / total) * 100; + core.memory_percent = Math.round(usedPct * 100) / 100; + } + } catch (e) { + logger.warn('NAS utilization fetch failed (ok in dev):', e.message || e); + } + + try { + // Storage summary to compute storage_percent & raid status + // SYNO.Core.Storage.Volume / Storage.Pool + const pools = await this.callEntry('SYNO.Core.Storage.Pool', 'list', 1, { limit: 100, offset: 0 }); + if (Array.isArray(pools?.pools) && pools.pools.length > 0) { + const sizeTotal = pools.pools.reduce((a, p) => a + (p.size?.total || 0), 0); + const sizeUsed = pools.pools.reduce((a, p) => a + (p.size?.used || 0), 0); + if (sizeTotal > 0) core.storage_percent = Math.round((sizeUsed / sizeTotal) * 10000) / 100; + // raid status derive + core.raid_status = pools.pools.some((p) => p.status && p.status !== 'healthy') ? 'degraded' : 'healthy'; + } + } catch (e) { + logger.warn('NAS storage fetch failed (ok in dev):', e.message || e); + } + + try { + // Sessions + const sessions = await this.callEntry('SYNO.Core.User.Session', 'list', 1, { limit: 500, offset: 0 }); + if (Array.isArray(sessions?.sessions)) core.active_sessions = sessions.sessions.length; + } catch (e) { + logger.warn('NAS sessions fetch failed (ok in dev):', e.message || e); + } + + return core; + } + + // Collect disk smart/health per bay + async collectDisks() { + const results = []; + try { + // SYNO.Core.Storage.Disk + const diskList = await this.callEntry('SYNO.Core.Storage.Disk', 'list', 1, { limit: 100, offset: 0 }); + const disks = Array.isArray(diskList?.disks) ? diskList.disks : []; + for (const d of disks) { + results.push({ + bay_number: d?.slot || d?.bay || null, + disk_model: d?.model || null, + temperature: d?.temp || d?.temperature || null, + health_status: d?.health || d?.status || 'healthy', + reallocated_sectors: d?.smart_info?.reallocated_sector_ct || 0, + }); + } + } catch (e) { + logger.warn('NAS disk list fetch failed (ok in dev):', e.message || e); + } + return results; + } + + async saveNasData(core) { + const sql = `INSERT INTO nas_data (cpu_percent, memory_percent, storage_percent, raid_status, active_sessions, power_watts) VALUES (?, ?, ?, ?, ?, ?)`; + const params = [ + core.cpu_percent, + core.memory_percent, + core.storage_percent, + core.raid_status, + core.active_sessions, + core.power_watts, + ]; + await sequelize.query(sql, { replacements: params }); + } + + async saveDiskData(disks) { + if (!disks || disks.length === 0) return; + const sql = `INSERT INTO nas_disk_data (bay_number, disk_model, temperature, health_status, reallocated_sectors) VALUES (?, ?, ?, ?, ?)`; + for (const dk of disks) { + const params = [dk.bay_number, dk.disk_model, dk.temperature, dk.health_status, dk.reallocated_sectors]; + await sequelize.query(sql, { replacements: params }); + } + } + + async collectAndSave() { + const loggedIn = await this.login(); + if (!loggedIn) { + logger.warn('Skip NAS collect: not logged in'); + return { saved: false }; + } + try { + const [core, disks] = await Promise.all([this.collectCore(), this.collectDisks()]); + await this.saveNasData(core); + await this.saveDiskData(disks); + return { saved: true, coreCount: 1, diskCount: disks.length }; + } catch (err) { + logger.error('NAS collect/save failed', err.message || err); + return { saved: false }; + } finally { + await this.logout(); + } + } +} + +module.exports = new NasCollector(); diff --git a/src/controllers/macMiniController.js b/src/controllers/macMiniController.js new file mode 100644 index 0000000..aebf914 --- /dev/null +++ b/src/controllers/macMiniController.js @@ -0,0 +1,240 @@ +const macMiniCollector = require('../collectors/macMiniCollector'); +const logger = require('../utils/logger'); + +class MacMiniController { + constructor() { + // MacMiniCollector 초기화는 app.js에서 관리 + } + + /** + * 실시간 시스템 데이터 수집 및 반환 + */ + async getCurrentData(req, res) { + try { + const data = await macMiniCollector.collectAndSave(); + if (data) { + res.json({ + success: true, + message: 'Mac Mini data collected successfully', + data: data + }); + } else { + res.status(500).json({ + success: false, + message: 'Failed to collect Mac Mini data' + }); + } + } catch (error) { + logger.error('Error in getCurrentData:', error); + res.status(500).json({ + success: false, + message: 'Failed to collect data', + error: error.message + }); + } + } + + /** + * 최근 데이터 조회 + */ + async getRecentData(req, res) { + try { + const limit = parseInt(req.query.limit) || 10; + const data = await macMiniCollector.getRecentData(limit); + + res.json({ + success: true, + count: data.length, + data: data + }); + } catch (error) { + logger.error('Error in getRecentData:', error); + res.status(500).json({ + success: false, + message: 'Failed to retrieve recent data', + error: error.message + }); + } + } + + /** + * 특정 기간 데이터 조회 + */ + async getDataByDateRange(req, res) { + try { + const { startDate, endDate } = req.query; + + if (!startDate || !endDate) { + return res.status(400).json({ + success: false, + message: 'startDate and endDate are required' + }); + } + + const data = await macMiniCollector.getDataByDateRange( + new Date(startDate), + new Date(endDate) + ); + + res.json({ + success: true, + count: data.length, + dateRange: { startDate, endDate }, + data: data + }); + } catch (error) { + logger.error('Error in getDataByDateRange:', error); + res.status(500).json({ + success: false, + message: 'Failed to retrieve data by date range', + error: error.message + }); + } + } + + /** + * 시스템 상태 요약 + */ + async getSystemSummary(req, res) { + try { + const recentData = await macMiniCollector.getRecentData(1); + + if (recentData.length === 0) { + return res.json({ + success: true, + message: 'No data available', + summary: null + }); + } + + const latest = recentData[0]; + const summary = { + timestamp: latest.timestamp, + system_health: this.assessSystemHealth(latest), + cpu_status: this.getCpuStatus(latest.cpu_percent), + memory_status: this.getMemoryStatus(latest.memory_percent), + disk_status: this.getDiskStatus(latest.disk_percent), + power_status: this.getPowerStatus(latest.power_watts), + uptime_friendly: this.formatUptime(latest.uptime_seconds) + }; + + res.json({ + success: true, + summary: summary, + raw_data: latest + }); + } catch (error) { + logger.error('Error in getSystemSummary:', error); + res.status(500).json({ + success: false, + message: 'Failed to generate system summary', + error: error.message + }); + } + } + + /** + * 시스템 상태 평가 + */ + assessSystemHealth(data) { + const issues = []; + + if (data.cpu_percent > 80) issues.push('high_cpu'); + if (data.memory_percent > 85) issues.push('high_memory'); + if (data.disk_percent > 90) issues.push('high_disk'); + if (data.power_watts > 100) issues.push('high_power'); // Mac Mini 일반적으로 50W 내외 + + if (issues.length === 0) return 'healthy'; + if (issues.length <= 1) return 'warning'; + return 'critical'; + } + + /** + * CPU 상태 평가 + */ + getCpuStatus(cpuPercent) { + if (!cpuPercent) return 'unknown'; + if (cpuPercent < 50) return 'normal'; + if (cpuPercent < 80) return 'moderate'; + return 'high'; + } + + /** + * 메모리 상태 평가 + */ + getMemoryStatus(memoryPercent) { + if (!memoryPercent) return 'unknown'; + if (memoryPercent < 70) return 'normal'; + if (memoryPercent < 85) return 'moderate'; + return 'high'; + } + + /** + * 디스크 상태 평가 + */ + getDiskStatus(diskPercent) { + if (!diskPercent) return 'unknown'; + if (diskPercent < 80) return 'normal'; + if (diskPercent < 90) return 'moderate'; + return 'high'; + } + + /** + * 전력 상태 평가 + */ + getPowerStatus(powerWatts) { + if (!powerWatts) return 'unknown'; + if (powerWatts < 60) return 'normal'; // Mac Mini 평상시 + if (powerWatts < 100) return 'moderate'; // 높은 부하 + return 'high'; // 비정상적으로 높음 + } + + /** + * 업타임을 친숙한 형태로 변환 + */ + formatUptime(uptimeSeconds) { + if (!uptimeSeconds) return 'unknown'; + + const days = Math.floor(uptimeSeconds / (24 * 3600)); + const hours = Math.floor((uptimeSeconds % (24 * 3600)) / 3600); + const minutes = Math.floor((uptimeSeconds % 3600) / 60); + + if (days > 0) { + return `${days}일 ${hours}시간`; + } else if (hours > 0) { + return `${hours}시간 ${minutes}분`; + } else { + return `${minutes}분`; + } + } + + /** + * 테스트용 - 시스템 명령어 직접 실행 + */ + async testSystemCommands(req, res) { + try { + const tests = { + cpu: await macMiniCollector.getCpuUsage(), + memory: await macMiniCollector.getMemoryUsage(), + disk: await macMiniCollector.getDiskUsage(), + uptime: await macMiniCollector.getUptime(), + power: await macMiniCollector.getPowerUsage() + }; + + res.json({ + success: true, + message: 'System commands test completed', + tests: tests + }); + } catch (error) { + logger.error('Error in testSystemCommands:', error); + res.status(500).json({ + success: false, + message: 'Failed to test system commands', + error: error.message + }); + } + } +} + +module.exports = new MacMiniController(); diff --git a/src/routes/index.js b/src/routes/index.js index b9fed43..741b4cf 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -11,6 +11,7 @@ router.get('/', (req, res) => { health: '/health', devices: '/api/devices', tapo: '/api/tapo', + macMini: '/api/mac-mini', power: '/api/power', network: '/api/network', system: '/api/system', @@ -23,6 +24,7 @@ router.get('/', (req, res) => { // 라우터 연결 router.use('/devices', require('./devices')); router.use('/tapo', require('./tapo')); +router.use('/mac-mini', require('./macMini')); // router.use('/power', require('./power')); // router.use('/network', require('./network')); // router.use('/system', require('./system')); diff --git a/src/routes/macMini.js b/src/routes/macMini.js new file mode 100644 index 0000000..e061617 --- /dev/null +++ b/src/routes/macMini.js @@ -0,0 +1,20 @@ +const express = require('express'); +const router = express.Router(); +const macMiniController = require('../controllers/macMiniController'); + +// 실시간 시스템 데이터 수집 +router.get('/collect', macMiniController.getCurrentData); + +// 최근 데이터 조회 +router.get('/recent', macMiniController.getRecentData); + +// 특정 기간 데이터 조회 +router.get('/history', macMiniController.getDataByDateRange); + +// 시스템 상태 요약 +router.get('/summary', macMiniController.getSystemSummary); + +// 테스트용 - 시스템 명령어 직접 실행 +router.get('/test', macMiniController.testSystemCommands); + +module.exports = router;