feat(nas,macmini): 수집기 코드 추가 및 DB 스키마 단순화\n\n- Mac Mini SSH 기반 원격 수집기 추가 (/api/mac-mini/*)\n- DS1525+ DSM API 기반 수집기 골격 추가 (로그인/코어/디스크 수집)\n- NAS/디스크 간소화 테이블 생성 (nas_data, nas_disk_data)\n- README 업데이트 (SSH 설정, 엔드포인트, 테이블 구성)\n- Docker 통합 후 동작 확인

This commit is contained in:
Hyungi Ahn
2025-08-13 06:51:45 +09:00
parent 5fc5a697f9
commit b47627e8f9
9 changed files with 1045 additions and 15 deletions

View File

@@ -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/ # 라우터

164
package-lock.json generated
View File

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

View File

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

View File

@@ -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);
}
});
// 프로세스 종료 핸들링

View File

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

View File

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

View File

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

View File

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

20
src/routes/macMini.js Normal file
View File

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