Compare commits

...

33 Commits

Author SHA1 Message Date
7062a81d33 수정 2025-12-28 23:57:22 +09:00
79a7fc1550 수정 2025-12-28 23:54:56 +09:00
bf10a1d000 수정 2025-12-28 23:49:11 +09:00
8bcb5c7ff7 수정 2025-12-28 23:44:40 +09:00
248eb6e6e0 메모리 상태 또 변경 2025-12-28 23:06:44 +09:00
474e20eb5c 소스 수정 2025-12-28 18:33:49 +09:00
bd9d2a703f 소스 수정 2025-12-28 18:26:24 +09:00
240e096bd8 소스 수정 2025-12-28 18:24:58 +09:00
e49c962cee 소스 수정 2025-12-28 18:09:10 +09:00
99db3acc20 소스 수정 2025-12-28 18:09:04 +09:00
e6370601d4 소스 수정 2025-12-28 17:56:09 +09:00
fb43bf6d07 소스 수정 2025-12-28 17:55:55 +09:00
ce90c41f0c 소스 수정 2025-12-28 17:42:50 +09:00
1aeeab4d40 소스 수정 2025-12-28 17:35:46 +09:00
24741e2445 소스 수정 2025-12-28 17:26:32 +09:00
4312a942dc 소스 수정 2025-12-28 17:16:00 +09:00
383e24ba1c 소스 수정 2025-12-28 17:08:12 +09:00
eb9848ede4 소스 수정 2025-12-28 16:43:12 +09:00
c2733ca3da 소스 수정 2025-12-28 16:35:14 +09:00
1fc6a9ccd9 소스 수정 2025-12-28 16:27:46 +09:00
56451c8070 소스 수정 2025-12-28 16:18:02 +09:00
1801f46c89 소스 수정 2025-12-28 16:17:39 +09:00
a29055d181 소스 수정 2025-12-28 16:05:58 +09:00
c79b73fba0 포트변경 2025-12-28 16:00:24 +09:00
2fbb4247d6 포트변경 2025-12-28 15:35:33 +09:00
7cd2e5ba80 포트변경 2025-12-28 15:29:58 +09:00
a6a417fa21 포트변경 2025-12-28 15:27:36 +09:00
3e22cc0afc 업데이트 2025-12-28 15:23:51 +09:00
3937453f99 업데이트 2025-12-28 15:07:33 +09:00
3ff058d00f 업데이트 2025-12-28 15:00:43 +09:00
35e55d73cf 업데이트 2025-12-28 14:57:16 +09:00
3defa9abd7 업데이트 2025-12-28 14:55:00 +09:00
40182b4e2d docker 삭제 2025-12-28 14:49:21 +09:00
46 changed files with 1990 additions and 1159 deletions

6
.env
View File

@@ -2,11 +2,11 @@
NODE_ENV=development
# PostgreSQL 연결 정보 (운영서버)
DB_HOST=192.168.0.248
DB_HOST=172.25.0.199
DB_PORT=5432
DB_NAME=osolit_monitor
DB_USER=postgres
DB_PASSWORD=osolit1!
DB_USER=osolit
DB_PASSWORD=osolit1234
# 스케줄러 자동시작: false (로컬에서는 수동 시작)
AUTO_START_SCHEDULER=false

View File

@@ -1,10 +1,12 @@
# 운영 서버 환경 설정
NODE_ENV=production
AUTO_START_SCHEDULER=true
# PostgreSQL 연결 정보
DB_HOST=192.168.0.248
DB_HOST=monitor-postgres
DB_PORT=5432
DB_NAME=osolit_monitor
DB_USER=postgres
DB_PASSWORD=osolit1!
DB_USER=osolit
DB_PASSWORD=osolit1234
# 스케줄러 자동시작: false (로컬에서는 수동 시작)
AUTO_START_SCHEDULER=true

6
.gitignore vendored
View File

@@ -13,13 +13,7 @@ node_modules
logs
*.log
# Database
database/*.db
database/*.db-shm
database/*.db-wal
# Misc
.DS_Store
.fleet
.idea

15
.run/dev.run.xml Normal file
View File

@@ -0,0 +1,15 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="dev" type="js.build_tools.npm" nameIsGenerated="true">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="dev" />
</scripts>
<node-interpreter value="project" />
<envs />
<EXTENSION ID="com.intellij.javascript.debugger.execution.StartBrowserRunConfigurationExtension">
<browser with-js-debugger="true" />
</EXTENSION>
<method v="2" />
</configuration>
</component>

View File

@@ -7,6 +7,8 @@ WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
# 운영 환경변수로 빌드
COPY .env.prod .env
RUN npm run build
FROM node:20-alpine
@@ -20,7 +22,7 @@ RUN apk add --no-cache tzdata \
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
ENV PORT=4055
# 빌드 결과물 복사
COPY --from=builder /app/.output /app/.output
@@ -29,15 +31,16 @@ COPY --from=builder /app/.output /app/.output
COPY --from=builder /app/package*.json /app/
RUN npm ci --omit=dev
# 운영 환경변수 복사
COPY .env.prod /app/.env
# 비root 사용자
RUN addgroup -g 1001 -S nodejs \
&& adduser -S nuxt -u 1001 -G nodejs \
&& chown -R nuxt:nodejs /app
USER nuxt
EXPOSE 3000
EXPOSE 4055
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1
CMD ["node", ".output/server/index.mjs"]
# .env 로드 후 실행
CMD ["sh", "-c", "export $(grep -v '^#' .env | xargs) && node .output/server/index.mjs"]

506
README.md
View File

@@ -1,284 +1,336 @@
# OSOLIT Monitor
시스템 헬스체크 & 모니터링 대시보드 (Nuxt 3 + SQLite + SSE)
시스템 헬스체크 & 모니터링 대시보드 (Nuxt 3 + PostgreSQL + WebSocket)
## 주요 기능
### 📊 실시간 대시보드 (웹소켓/SSE 기반)
- **네트워크 점검** 포틀릿 (단일, 핵심)
- Server-Sent Events (SSE)로 실시간 업데이트
- 점검 시간과 상태 즉시 반영
- 연결 상태 표시 (🟢 연결됨 / 🔴 연결 끊김)
- 향후 추가 포틀릿 확장 가능
### 📊 실시간 대시보드
- **서버 상태 모니터링**: 다중 서버 헬스체크 및 상태 표시
- **네트워크 점검**: Public(외부망) / Private(내부망) 분리 모니터링
- **위치별 통계**: 서버 위치(Location)별 현황 요약
- **WebSocket 기반 실시간 업데이트**
- **다크/라이트 테마 지원**
### 🔄 적응형 스케줄러
- **정상 시**: 5분 간격 점검
- **장애 시**: 1분 간격 점검 (빠른 복구 감지)
- 자동으로 간격 조정
- DB 시간 기준으로 로그 저장
### 🌐 네트워크 모니터링
- **Public Network (pubnet)**: 외부망 연결 상태 점검
- **Private Network (privnet)**: 내부망 연결 상태 점검
- 응답 시간 측정 및 에러 메시지 상세 표시
### 🌐 네트워크 점검
- World Time API를 통한 외부 네트워크 상태 확인
- 응답 시간 측정
- 에러 메시지 상세 표시
### 🖥️ 서버 모니터링
- 다중 서버 대상 관리
- 서버별 헬스체크 이력 조회
- 위치별 서버 현황 통계
- 스케줄러 제어 (시작/중지)
### 💾 데이터 저장
- **SQLite 파일 DB** (`database/health_logs.db`)
- 별도 DB 서버 설치 불필요
- 상태: UP(정상) / DOWN(장애)
### 📈 이상탐지 (Anomaly Detection)
- **Baseline 분석**: 정상 기준선 대비 이상 탐지
- **Z-Score 분석**: 통계적 이상치 탐지
- **Trend 분석**: 장기 추세 분석
- **Short-term 분석**: 단기 변동 탐지
- Chart.js 기반 시각화
### ⚙️ 설정 관리
- 임계값(Threshold) 설정
- 스케줄러 간격 조정
## 기술 스택
| 구분 | 기술 |
|------|------|
| **Frontend** | Nuxt 3, Vue 3, Chart.js |
| **Backend** | Nitro (Node.js) |
| **Database** | PostgreSQL |
| **Real-time** | WebSocket |
| **Container** | Docker |
| **Timezone** | Asia/Seoul |
## 프로젝트 구조
```
osolit-monitor/
├── frontend/ # 프론트엔드 (Pages & Components)
│ ├── index.vue # 📊 메인 대시보드
│ ├── components/
│ │ ├── DashboardControl.vue
│ │ ├── ServerPortlet.vue
│ │ ├── NetworkPortlet.vue
│ │ ├── LocationStatsPortlet.vue
│ │ ├── SidebarNav.vue
│ │ └── ThemeToggle.vue
│ ├── network/ # 네트워크 모니터링
│ ├── server/ # 서버 모니터링
│ │ ├── list.vue
│ │ └── history.vue
│ ├── anomaly/ # 이상탐지
│ │ ├── baseline.vue
│ │ ├── zscore.vue
│ │ ├── trend.vue
│ │ └── short-term.vue
│ ├── settings/ # 설정
│ ├── assets/css/
│ └── composables/
├── backend/ # 백엔드 (API & Plugins)
│ ├── api/
│ │ ├── network/
│ │ │ ├── pubnet/ # 외부망 API
│ │ │ └── privnet/ # 내부망 API
│ │ ├── server/ # 서버 모니터링 API
│ │ │ ├── status.get.ts
│ │ │ ├── location-stats.get.ts
│ │ │ ├── targets/
│ │ │ ├── history/
│ │ │ └── scheduler/
│ │ ├── anomaly/ # 이상탐지 API
│ │ │ ├── baseline.get.ts
│ │ │ ├── zscore.get.ts
│ │ │ ├── trend.get.ts
│ │ │ ├── short-term.get.ts
│ │ │ ├── chart.get.ts
│ │ │ └── logs.get.ts
│ │ └── settings/ # 설정 API
│ │ ├── thresholds.get.ts
│ │ └── thresholds.put.ts
│ ├── plugins/
│ │ ├── pubnet-init.ts # 외부망 스케줄러 초기화
│ │ ├── privnet-init.ts # 내부망 스케줄러 초기화
│ │ └── server-init.ts # 서버 스케줄러 초기화
│ ├── routes/ # WebSocket 라우트
│ └── utils/
│ ├── db.ts # PostgreSQL 연결
│ ├── pubnet-scheduler.ts
│ ├── privnet-scheduler.ts
│ └── server-scheduler.ts
├── database/ # DB 관련 (마이그레이션 등)
├── .env # 개발 환경변수
├── .env.prod # 운영 환경변수
├── Dockerfile # 프로덕션 Docker 이미지
├── nuxt.config.ts
└── package.json
```
## 설치 및 실행
### 환경 요구사항
- Node.js 20+
- PostgreSQL 14+
### 1. 의존성 설치
```bash
npm install
```
### 2. 개발 서버 실행
### 2. 환경변수 설정
```bash
# .env 파일 생성
cp .env.example .env
```
```env
# 환경 설정
NODE_ENV=development
# PostgreSQL 연결 정보
DB_HOST=localhost
DB_PORT=5432
DB_NAME=osolit_monitor
DB_USER=postgres
DB_PASSWORD=your_password
# 스케줄러 자동시작 (development: false 권장)
AUTO_START_SCHEDULER=false
```
### 3. 개발 서버 실행
```bash
npm run dev
```
서버 시작 시:
- DB 자동 초기화
- 즉시 첫 네트워크 점검 실행
- SSE 서버 시작
- 개발 서버: http://localhost:3000
- 개발 서버: http://localhost:4055
- WebSocket: ws://localhost:4055/_ws
### 3. 프로덕션 빌드
### 4. 프로덕션 빌드
```bash
npm run build
npm run preview
```
## Docker 배포
### 빌드 및 실행
```bash
# 이미지 빌드
docker build -t osolit-monitor .
# 컨테이너 실행
docker run -d \
--name osolit-monitor \
-p 4055:4055 \
-e DB_HOST=your-db-host \
-e DB_PORT=5432 \
-e DB_NAME=osolit_monitor \
-e DB_USER=osolit \
-e DB_PASSWORD=your_password \
osolit-monitor
```
### Docker 특징
- Multi-stage 빌드 (최적화된 이미지 크기)
- Asia/Seoul 타임존 설정
- 비root 사용자 실행 (보안)
- 환경변수 기반 설정
## 화면 구성
### 홈 (실시간 대시보드)
**http://localhost:3000**
#### 네트워크 점검 포틀릿
### 메인 대시보드 (`/`)
```
┌─────────────────────────────────┐
🌐 네트워크 점검 ← 상태 아이콘
├─────────────────────────────────┤
[ UP ] │ ← 상태 뱃지
│ │
점검 시간: 12/25 14:35:20 │ ← 마지막 점검 시간
응답 시간: 123ms ← 응답 속도
├─────────────────────────────────┤
5분 전 ⏱️ 5분 간격 │ ← 상대시간 & 점검주기
└─────────────────────────────────
┌─────────────────────────────────────────────────────────
📊 대시보드 2024-12-25 14:30:00 │
├─────────────────────────────────────────────────────────
┌─────────────────────────────────┐ ┌─────────────────┐ │
│ │ 🌐 Public ✓ │
│ 서버 현황 (90%) │ │ [UP] │ │
│ ├─────────────────┤ │
│ │ 서버 목록 & 상태 │ │ 🔒 Private ✓ │ │
│ │ [UP] │ │
│ │ │ ├─────────────────┤ │
│ │ │ │ 📍 위치별 통계 │ │
│ └─────────────────────────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────┘
🟢 연결됨
```
**실시간 업데이트:**
- 점검 완료 시 즉시 화면 반영
- 별도 새로고침 불필요
- SSE 연결 상태 표시
### 서버 관리 (`/server/list`, `/server/history`)
- 모니터링 대상 서버 목록 관리
- 서버별 헬스체크 이력 조회
**향후 확장:**
- 데이터베이스 점검 포틀릿
- 서버 상태 포틀릿
- 디스크 용량 포틀릿
- 기타 모니터링 항목
### 이상탐지 (`/anomaly/*`)
- Baseline / Z-Score / Trend / Short-term 분석
- 차트 기반 시각화
### 로그 & 통계
**http://localhost:3000/logs**
- 전체 점검 이력
- 통계 정보
### 시스템 헬스체크
**http://localhost:3000/health**
- 실시간 상태 조회
### 글로벌 표준시
**http://localhost:3000/time**
- Time API 직접 테스트
## 프로젝트 구조
```
osolit-monitor/
├── frontend/
│ ├── index.vue # 🏠 실시간 대시보드
│ ├── logs.vue # 📊 로그 & 통계
│ ├── health.vue # 🏥 헬스체크
│ └── time.vue # 🌍 시간 API
├── backend/
│ ├── api/
│ │ ├── dashboard.get.ts # 대시보드 초기 데이터
│ │ ├── stream.get.ts # SSE 스트림 (실시간)
│ │ ├── logs.get.ts
│ │ └── stats.get.ts
│ ├── plugins/
│ │ └── scheduler.ts # 적응형 스케줄러
│ └── utils/
│ ├── database.ts # SQLite
│ └── timecheck.ts # 네트워크 체크
├── database/
│ └── health_logs.db # SQLite (자동 생성)
└── nuxt.config.ts
```
## 실시간 업데이트 (SSE)
### Server-Sent Events
```javascript
// 프론트엔드
const eventSource = new EventSource('/api/stream')
eventSource.onmessage = (event) => {
const message = JSON.parse(event.data)
if (message.type === 'health_update') {
// 화면 즉시 업데이트
networkStatus.value = message.data
}
}
```
### 메시지 타입
- `health_update`: 헬스체크 결과
- `ping`: 연결 유지 (30초마다)
### 자동 재연결
연결 끊김 시 5초 후 자동 재연결 시도
## 적응형 스케줄링
### 동작 방식
1. **서버 시작**: 즉시 첫 점검
2. **정상(UP)**: 다음 점검 5분 후
3. **장애(DOWN)**: 다음 점검 1분 후 (빠른 복구 감지)
4. **상태 변경 시**: 자동으로 간격 조정
### 콘솔 로그
```
🚀 Initializing Health Check Scheduler...
✅ Database initialized
⏰ Running network health check...
Result: UP (123ms)
✅ Health Check Scheduler started
[5분 후]
⏰ Running network health check...
Result: DOWN (5001ms)
🔄 Interval changed: 1 minutes
[1분 후]
⏰ Running network health check...
Result: UP (125ms)
🔄 Interval changed: 5 minutes
```
### 설정 (`/settings/*`)
- 임계값 설정 관리
## API 엔드포인트
### GET /api/dashboard
초기 대시보드 데이터
### 네트워크
| Method | Endpoint | 설명 |
|--------|----------|------|
| GET | `/api/network/pubnet/status` | 외부망 상태 |
| GET | `/api/network/privnet/status` | 내부망 상태 |
**Response:**
```json
{
"networkStatus": {
"id": 123,
"service_name": "Network",
"status": "UP",
"check_time": "2024-12-25T12:34:56.000Z",
"response_time": 123,
"error_message": null
},
"lastUpdate": "2024-12-25T12:34:56.789Z"
### 서버
| Method | Endpoint | 설명 |
|--------|----------|------|
| GET | `/api/server/status` | 서버 현황 |
| GET | `/api/server/location-stats` | 위치별 통계 |
| GET | `/api/server/targets` | 모니터링 대상 목록 |
| GET | `/api/server/history` | 헬스체크 이력 |
### 이상탐지
| Method | Endpoint | 설명 |
|--------|----------|------|
| GET | `/api/anomaly/baseline` | Baseline 분석 |
| GET | `/api/anomaly/zscore` | Z-Score 분석 |
| GET | `/api/anomaly/trend` | Trend 분석 |
| GET | `/api/anomaly/short-term` | 단기 분석 |
| GET | `/api/anomaly/chart` | 차트 데이터 |
### 설정
| Method | Endpoint | 설명 |
|--------|----------|------|
| GET | `/api/settings/thresholds` | 임계값 조회 |
| PUT | `/api/settings/thresholds` | 임계값 수정 |
## WebSocket 통신
### 연결
```javascript
const ws = new WebSocket('ws://localhost:4055/_ws')
```
### 메시지 타입
**클라이언트 → 서버:**
```javascript
// 새로고침 간격 설정
{ type: 'set_interval', interval: 1 }
// 자동 새로고침 토글
{ type: 'set_auto_refresh', enabled: true }
// 즉시 새로고침
{ type: 'refresh' }
// 특정 시점 데이터 조회
{ type: 'fetch_at', datetime: '2024-12-25T14:30:00' }
```
**서버 → 클라이언트:**
```javascript
// 실시간 상태
{ type: 'status', data: { pubnet: {...}, privnet: {...} } }
// 서버 현황
{ type: 'server', data: { servers: [...], summary: {...} } }
// 과거 데이터
{ type: 'historical', data: {...} }
```
## 데이터베이스
### PostgreSQL 연결
```typescript
// backend/utils/db.ts
const config = {
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || '5432'),
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
}
```
### GET /api/stream (SSE)
실시간 업데이트 스트림
### 주요 테이블
- `health_check_log`: 헬스체크 로그
- `server_targets`: 모니터링 대상 서버
- `thresholds`: 임계값 설정
**Event Stream:**
```
data: {"type":"health_update","data":{...}}
## 스케줄러
data: {"type":"ping","timestamp":"2024-12-25T12:34:56.789Z"}
```
### 동작 방식
- **개발 환경**: 수동 시작 (`AUTO_START_SCHEDULER=false`)
- **운영 환경**: 자동 시작 (`AUTO_START_SCHEDULER=true`)
### GET /api/logs
헬스체크 로그 조회
### GET /api/stats
통계 조회
## 데이터베이스 스키마
### health_check_log 테이블
```sql
CREATE TABLE health_check_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
check_time DATETIME DEFAULT CURRENT_TIMESTAMP,
service_name TEXT NOT NULL, -- 'Network'
status TEXT NOT NULL, -- 'UP' or 'DOWN'
response_time INTEGER, -- 밀리초
error_message TEXT,
details TEXT
)
```
## 데이터 관리
### DB 백업
```bash
cp database/health_logs.db database/backup_$(date +%Y%m%d).db
```
### DB 조회
```bash
sqlite3 database/health_logs.db
# 최근 로그
SELECT * FROM health_check_log
WHERE service_name = 'Network'
ORDER BY check_time DESC LIMIT 20;
# 통계
SELECT
status,
COUNT(*) as count,
AVG(response_time) as avg_ms
FROM health_check_log
WHERE service_name = 'Network'
GROUP BY status;
```
### 스케줄러 종류
| 스케줄러 | 설명 | 간격 |
|----------|------|------|
| `pubnet-scheduler` | 외부망 점검 | 적응형 (정상 5분, 장애 1분) |
| `privnet-scheduler` | 내부망 점검 | 적응형 (정상 5분, 장애 1분) |
| `server-scheduler` | 서버 점검 | 설정 가능 |
## 브라우저 호환성
SSE 지원 브라우저:
- ✅ Chrome
- ✅ Firefox
- ✅ Safari
- ✅ Edge
- ❌ IE (미지원)
## 다음 단계
## 개발 로드맵
- [ ] 데이터베이스 점검 포틀릿 추가
- [ ] 서버 상태 점검 포틀릿 추가
- [ ] 디스크 용량 점검 포틀릿 추가
- [ ] Jenkins Job 통합
- [x] 서버 모니터링 포틀릿
- [x] 네트워크 모니터링 (Public/Private)
- [x] 이상탐지 기능
- [x] Chart.js 차트
- [x] 다크/라이트 테마
- [x] Docker 지원
- [ ] 장애 알림 (이메일/Slack)
- [ ] 차트/그래프
- [ ] 오래된 로그 자동 삭제
- [ ] Jenkins Job 통합
## 기술 스택
## 라이선스
- **Frontend**: Nuxt 3, Vue 3
- **Backend**: Nitro (Node.js)
- **Database**: SQLite (better-sqlite3)
- **Real-time**: Server-Sent Events (SSE)
- **Scheduler**: 적응형 (정상 5분, 장애 1분)
- **External API**: World Time API
## 특징
**실시간 업데이트** - SSE로 즉시 반영
**적응형 스케줄링** - 장애 시 빠른 점검
**단순한 구조** - 네트워크 점검 중심
**확장 가능** - 포틀릿 추가 용이
**파일 DB** - 별도 서버 불필요
Private - TurboSoft

View File

@@ -4,10 +4,10 @@ export default defineEventHandler(async (event) => {
const DEVIATION_THRESHOLD = 2.0
const servers = await query<any>(`
SELECT target_id, name as server_name
SELECT target_id, server_name
FROM server_targets
WHERE is_active = 1
ORDER BY name
ORDER BY server_name
`)
const now = new Date()
@@ -22,25 +22,25 @@ export default defineEventHandler(async (event) => {
for (const server of servers) {
// 최근 14일 동일 시간대 데이터
const historicalData = await query<any>(`
SELECT cpu_usage as cpu_percent, memory_usage as memory_percent, checked_at as collected_at
FROM server_logs
SELECT cpu_percent, memory_percent, collected_at
FROM server_snapshots
WHERE target_id = $1
AND checked_at >= NOW() - INTERVAL '14 days'
AND EXTRACT(HOUR FROM checked_at) = $2
AND collected_at::timestamp >= NOW() - INTERVAL '14 days'
AND EXTRACT(HOUR FROM collected_at::timestamp) = $2
AND (
($3 = 'weekend' AND EXTRACT(DOW FROM checked_at) IN (0, 6))
($3 = 'weekend' AND EXTRACT(DOW FROM collected_at::timestamp) IN (0, 6))
OR
($3 = 'weekday' AND EXTRACT(DOW FROM checked_at) NOT IN (0, 6))
($3 = 'weekday' AND EXTRACT(DOW FROM collected_at::timestamp) NOT IN (0, 6))
)
ORDER BY checked_at DESC
ORDER BY collected_at DESC
`, [server.target_id, currentHour, dayType])
// 현재 값
const currentRows = await query<any>(`
SELECT cpu_usage as cpu_percent, memory_usage as memory_percent
FROM server_logs
SELECT cpu_percent, memory_percent
FROM server_snapshots
WHERE target_id = $1
ORDER BY checked_at DESC
ORDER BY collected_at DESC
LIMIT 1
`, [server.target_id])

View File

@@ -6,54 +6,44 @@ export default defineEventHandler(async (event) => {
const type = queryParams.type as string || 'short-term'
const period = queryParams.period as string || '24h'
// 기간/간격 계산
let interval = '24 hours'
let groupFormat = 'YYYY-MM-DD HH24:00'
// 기간/간격 설정
const config = getPeriodConfig(period)
if (period === '1h') {
interval = '1 hour'
groupFormat = 'YYYY-MM-DD HH24:MI'
} else if (period === '6h') {
interval = '6 hours'
groupFormat = 'YYYY-MM-DD HH24:00'
} else if (period === '12h') {
interval = '12 hours'
groupFormat = 'YYYY-MM-DD HH24:00'
} else if (period === '24h') {
interval = '24 hours'
groupFormat = 'YYYY-MM-DD HH24:00'
} else if (period === '7d') {
interval = '7 days'
groupFormat = 'YYYY-MM-DD'
} else if (period === '30d') {
interval = '30 days'
groupFormat = 'YYYY-MM-DD'
}
// 시간 슬롯 생성 (연속된 X축)
const timeSlots = generateTimeSlots(config)
// 시간대별 집계 (anomaly_logs 테이블이 없으면 빈 배열 반환)
// DB에서 로그 조회
let rows: any[] = []
try {
rows = await query<any>(`
SELECT
to_char(detected_at, '${groupFormat}') as time_slot,
to_char(detected_at::timestamp, '${config.dbFormat}') as time_slot,
SUM(CASE WHEN level = 'warning' THEN 1 ELSE 0 END) as warning,
SUM(CASE WHEN level = 'danger' THEN 1 ELSE 0 END) as danger
FROM anomaly_logs
WHERE detect_type = $1
AND detected_at >= NOW() - INTERVAL '${interval}'
AND detected_at::timestamp >= NOW() - INTERVAL '${config.interval}'
GROUP BY time_slot
ORDER BY time_slot ASC
`, [type])
} catch (e) {
// 테이블이 없으면 빈 배열
rows = []
}
// 시간 포맷 변환
const data = rows.map(r => ({
time: formatTimeLabel(r.time_slot, period),
warning: Number(r.warning),
danger: Number(r.danger)
// 로그 데이터를 Map으로 변환
const logMap = new Map<string, { warning: number; danger: number }>()
for (const r of rows) {
logMap.set(r.time_slot, {
warning: Number(r.warning) || 0,
danger: Number(r.danger) || 0
})
}
// 전체 시간 슬롯에 데이터 매핑 (없으면 0)
const data = timeSlots.map(slot => ({
time: slot.label,
warning: logMap.get(slot.key)?.warning || 0,
danger: logMap.get(slot.key)?.danger || 0
}))
return {
@@ -64,17 +54,134 @@ export default defineEventHandler(async (event) => {
}
})
function formatTimeLabel(timeSlot: string, period: string): string {
if (!timeSlot) return ''
if (period === '7d' || period === '30d') {
const parts = timeSlot.split('-')
return `${parts[1]}/${parts[2]}`
} else {
const parts = timeSlot.split(' ')
if (parts.length === 2) {
return parts[1].substring(0, 5)
}
return timeSlot.substring(11, 16)
interface PeriodConfig {
interval: string
stepMinutes: number
dbFormat: string
labelFormat: (date: Date) => string
}
function getPeriodConfig(period: string): PeriodConfig {
switch (period) {
case '1h':
return {
interval: '1 hour',
stepMinutes: 5,
dbFormat: 'YYYY-MM-DD HH24:MI',
labelFormat: (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`
}
case '6h':
return {
interval: '6 hours',
stepMinutes: 30,
dbFormat: 'YYYY-MM-DD HH24:MI',
labelFormat: (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`
}
case '12h':
return {
interval: '12 hours',
stepMinutes: 60,
dbFormat: 'YYYY-MM-DD HH24:00',
labelFormat: (d) => `${pad(d.getHours())}:00`
}
case '24h':
return {
interval: '24 hours',
stepMinutes: 60,
dbFormat: 'YYYY-MM-DD HH24:00',
labelFormat: (d) => `${pad(d.getHours())}:00`
}
case '7d':
return {
interval: '7 days',
stepMinutes: 360, // 6시간
dbFormat: 'YYYY-MM-DD HH24:00',
labelFormat: (d) => `${pad(d.getMonth()+1)}/${pad(d.getDate())} ${pad(d.getHours())}`
}
case '30d':
return {
interval: '30 days',
stepMinutes: 1440, // 1일
dbFormat: 'YYYY-MM-DD',
labelFormat: (d) => `${pad(d.getMonth()+1)}/${pad(d.getDate())}`
}
default:
return {
interval: '24 hours',
stepMinutes: 60,
dbFormat: 'YYYY-MM-DD HH24:00',
labelFormat: (d) => `${pad(d.getHours())}:00`
}
}
}
function generateTimeSlots(config: PeriodConfig): { key: string; label: string }[] {
const slots: { key: string; label: string }[] = []
const now = new Date()
// 현재 시간을 간격에 맞게 내림
const roundedNow = new Date(now)
if (config.stepMinutes >= 1440) {
// 일 단위: 오늘 00:00
roundedNow.setHours(0, 0, 0, 0)
} else if (config.stepMinutes >= 60) {
// 시간 단위: 현재 시간 00분
roundedNow.setMinutes(0, 0, 0)
} else {
// 분 단위: 가장 가까운 간격
const mins = roundedNow.getMinutes()
const rounded = Math.floor(mins / config.stepMinutes) * config.stepMinutes
roundedNow.setMinutes(rounded, 0, 0)
}
// interval을 밀리초로 변환
const intervalMs = parseInterval(config.interval)
const startTime = new Date(roundedNow.getTime() - intervalMs)
// 시작부터 현재까지 슬롯 생성
const stepMs = config.stepMinutes * 60 * 1000
let current = new Date(startTime)
while (current <= roundedNow) {
const key = formatDbKey(current, config.dbFormat)
const label = config.labelFormat(current)
slots.push({ key, label })
current = new Date(current.getTime() + stepMs)
}
return slots
}
function parseInterval(interval: string): number {
const match = interval.match(/(\d+)\s*(hour|hours|day|days)/)
if (!match) return 24 * 60 * 60 * 1000
const num = parseInt(match[1])
const unit = match[2]
if (unit.startsWith('day')) {
return num * 24 * 60 * 60 * 1000
} else {
return num * 60 * 60 * 1000
}
}
function formatDbKey(date: Date, format: string): string {
const y = date.getFullYear()
const m = pad(date.getMonth() + 1)
const d = pad(date.getDate())
const h = pad(date.getHours())
const mi = pad(date.getMinutes())
if (format === 'YYYY-MM-DD') {
return `${y}-${m}-${d}`
} else if (format === 'YYYY-MM-DD HH24:00') {
return `${y}-${m}-${d} ${h}:00`
} else {
return `${y}-${m}-${d} ${h}:${mi}`
}
}
function pad(n: number): string {
return n.toString().padStart(2, '0')
}

View File

@@ -23,7 +23,7 @@ export default defineEventHandler(async (event) => {
current_value, threshold_value, message, detected_at
FROM anomaly_logs
WHERE detect_type = $1
AND detected_at >= NOW() - INTERVAL '${interval}'
AND detected_at::timestamp >= NOW() - INTERVAL '${interval}'
ORDER BY detected_at DESC
LIMIT 100
`, [type])

View File

@@ -5,10 +5,10 @@ export default defineEventHandler(async (event) => {
// 활성 서버 목록
const servers = await query<any>(`
SELECT target_id, name as server_name
SELECT target_id, server_name
FROM server_targets
WHERE is_active = 1
ORDER BY name
ORDER BY server_name
`)
const anomalies: any[] = []
@@ -16,10 +16,10 @@ export default defineEventHandler(async (event) => {
for (const server of servers) {
const snapshots = await query<any>(`
SELECT cpu_usage as cpu_percent, memory_usage as memory_percent, checked_at as collected_at
FROM server_logs
SELECT cpu_percent, memory_percent, collected_at
FROM server_snapshots
WHERE target_id = $1
ORDER BY checked_at DESC
ORDER BY collected_at DESC
LIMIT 20
`, [server.target_id])

View File

@@ -6,10 +6,10 @@ export default defineEventHandler(async (event) => {
const WINDOW_MINUTES = 30
const servers = await query<any>(`
SELECT target_id, name as server_name
SELECT target_id, server_name
FROM server_targets
WHERE is_active = 1
ORDER BY name
ORDER BY server_name
`)
const anomalies: any[] = []
@@ -17,12 +17,12 @@ export default defineEventHandler(async (event) => {
for (const server of servers) {
const snapshots = await query<any>(`
SELECT cpu_usage as cpu_percent, memory_usage as memory_percent, checked_at as collected_at,
EXTRACT(EPOCH FROM (NOW() - checked_at)) / 60 as minutes_ago
FROM server_logs
WHERE target_id = $1 AND is_success = 1
AND checked_at >= NOW() - INTERVAL '${WINDOW_MINUTES} minutes'
ORDER BY checked_at ASC
SELECT cpu_percent, memory_percent, collected_at,
EXTRACT(EPOCH FROM (NOW() - collected_at::timestamp)) / 60 as minutes_ago
FROM server_snapshots
WHERE target_id = $1 AND is_online = 1
AND collected_at::timestamp >= NOW() - INTERVAL '${WINDOW_MINUTES} minutes'
ORDER BY collected_at ASC
`, [server.target_id])
if (snapshots.length < MIN_SAMPLES) {

View File

@@ -5,10 +5,10 @@ export default defineEventHandler(async (event) => {
const DANGER_Z = 3.0
const servers = await query<any>(`
SELECT target_id, name as server_name
SELECT target_id, server_name
FROM server_targets
WHERE is_active = 1
ORDER BY name
ORDER BY server_name
`)
const anomalies: any[] = []
@@ -16,11 +16,11 @@ export default defineEventHandler(async (event) => {
for (const server of servers) {
const snapshots = await query<any>(`
SELECT cpu_usage as cpu_percent, memory_usage as memory_percent, checked_at as collected_at
FROM server_logs
SELECT cpu_percent, memory_percent, collected_at
FROM server_snapshots
WHERE target_id = $1
AND checked_at >= NOW() - INTERVAL '1 hour'
ORDER BY checked_at DESC
AND collected_at::timestamp >= NOW() - INTERVAL '1 hour'
ORDER BY collected_at DESC
`, [server.target_id])
if (snapshots.length < 10) {

View File

@@ -45,7 +45,7 @@ export default defineEventHandler(async (event) => {
collected_at
FROM server_containers
WHERE target_id = $1
AND collected_at >= NOW() - INTERVAL '${interval}'
AND collected_at::timestamp >= NOW() - INTERVAL '${interval}'
ORDER BY collected_at ASC, container_name ASC
`, [targetId])
} catch (e) {

View File

@@ -42,7 +42,7 @@ export default defineEventHandler(async (event) => {
collected_at
FROM server_disks
WHERE target_id = $1
AND collected_at >= NOW() - INTERVAL '${interval}'
AND collected_at::timestamp >= NOW() - INTERVAL '${interval}'
ORDER BY collected_at ASC, mount_point ASC
`, [targetId])
} catch (e) {

View File

@@ -13,13 +13,13 @@ export default defineEventHandler(async (event) => {
const snapshot = await queryOne(`
SELECT
l.*,
t.name as server_name,
t.host as server_ip
FROM server_logs l
JOIN server_targets t ON l.target_id = t.target_id
WHERE l.target_id = $1
ORDER BY l.checked_at DESC
s.*,
t.server_name,
t.server_ip
FROM server_snapshots s
JOIN server_targets t ON s.target_id = t.target_id
WHERE s.target_id = $1
ORDER BY s.collected_at DESC
LIMIT 1
`, [targetId])

View File

@@ -42,7 +42,7 @@ export default defineEventHandler(async (event) => {
collected_at
FROM server_networks
WHERE target_id = $1
AND collected_at >= NOW() - INTERVAL '${interval}'
AND collected_at::timestamp >= NOW() - INTERVAL '${interval}'
ORDER BY collected_at ASC, interface_name ASC
`, [targetId])
} catch (e) {

View File

@@ -30,16 +30,23 @@ export default defineEventHandler(async (event) => {
const snapshots = await query(`
SELECT
log_id,
cpu_usage as cpu_percent,
memory_usage as memory_percent,
disk_usage as disk_percent,
is_success as is_online,
checked_at as collected_at
FROM server_logs
snapshot_id,
cpu_percent,
cpu_temp,
load_percent,
memory_percent,
memory_total,
memory_used,
memory_free,
swap_percent,
swap_total,
swap_used,
is_online,
collected_at
FROM server_snapshots
WHERE target_id = $1
AND checked_at >= NOW() - INTERVAL '${interval}'
ORDER BY checked_at ASC
AND collected_at::timestamp >= NOW() - INTERVAL '${interval}'
ORDER BY collected_at ASC
`, [targetId])
return {

View File

@@ -0,0 +1,28 @@
import { query } from '../../utils/db'
export default defineEventHandler(async () => {
const stats = await query(`
SELECT
t.physical_location,
COUNT(DISTINCT t.target_id) as server_count,
ROUND(AVG(s.cpu_temp)::numeric, 1) as avg_temp,
ROUND(SUM(n.speed_recv)::numeric, 0) as total_rx,
ROUND(SUM(n.speed_sent)::numeric, 0) as total_tx
FROM server_targets t
LEFT JOIN server_snapshots s ON t.target_id = s.target_id
AND s.collected_at::timestamp >= NOW() - INTERVAL '10 minutes'
LEFT JOIN server_networks n ON t.target_id = n.target_id
AND n.collected_at::timestamp >= NOW() - INTERVAL '10 minutes'
WHERE t.is_active = 1
GROUP BY t.physical_location
ORDER BY t.physical_location
`)
return stats.map((row: any) => ({
location: row.physical_location || '미지정',
serverCount: Number(row.server_count) || 0,
avgTemp: row.avg_temp ? parseFloat(row.avg_temp) : null,
totalRx: Number(row.total_rx) || 0,
totalTx: Number(row.total_tx) || 0
}))
})

View File

@@ -4,7 +4,7 @@ import { refreshServerTimer } from '../../../utils/server-scheduler'
export default defineEventHandler(async (event) => {
const targetId = getRouterParam(event, 'id')
const body = await readBody(event)
const { name, host, port, username, auth_type, is_active } = body
const { server_name, server_ip, glances_url, is_active, collect_interval, physical_location } = body
if (!targetId) {
throw createError({
@@ -15,15 +15,15 @@ export default defineEventHandler(async (event) => {
const changes = await execute(`
UPDATE server_targets
SET name = $1,
host = $2,
port = $3,
username = $4,
auth_type = $5,
is_active = $6,
SET server_name = $1,
server_ip = $2,
glances_url = $3,
is_active = $4,
collect_interval = $5,
physical_location = $6,
updated_at = NOW()
WHERE target_id = $7
`, [name, host, port || 22, username, auth_type || 'password', is_active ? 1 : 0, targetId])
`, [server_name, server_ip, glances_url, is_active ? 1 : 0, collect_interval || 60, physical_location || '운영서버실', targetId])
// 스케줄러에 반영
refreshServerTimer(Number(targetId))

View File

@@ -3,7 +3,7 @@ import { refreshServerTimer } from '../../../utils/server-scheduler'
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const { server_name, server_ip, glances_url, is_active = 1, collect_interval = 60 } = body
const { server_name, server_ip, glances_url, is_active = 1, collect_interval = 60, physical_location = '운영서버실' } = body
if (!server_name || !server_ip || !glances_url) {
throw createError({
@@ -13,10 +13,10 @@ export default defineEventHandler(async (event) => {
}
const result = await queryOne<{ target_id: number }>(`
INSERT INTO server_targets (server_name, server_ip, glances_url, is_active, collect_interval)
VALUES ($1, $2, $3, $4, $5)
INSERT INTO server_targets (server_name, server_ip, glances_url, is_active, collect_interval, physical_location)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING target_id
`, [server_name, server_ip, glances_url, is_active ? 1 : 0, collect_interval])
`, [server_name, server_ip, glances_url, is_active ? 1 : 0, collect_interval, physical_location])
const targetId = result?.target_id

View File

@@ -163,25 +163,40 @@ async function getServerDashboard() {
// 서버 목록
const servers = await query(`
SELECT target_id, name as server_name, is_active
SELECT target_id, server_name, server_ip, is_active
FROM server_targets
WHERE is_active = 1
ORDER BY name
ORDER BY server_name
`)
const serverStatuses: any[] = []
const summaryServers = { total: servers.length, normal: 0, warning: 0, critical: 0, danger: 0, offline: 0 }
const summaryContainers = { total: 0, normal: 0, warning: 0, critical: 0, danger: 0, stopped: 0 }
for (const server of servers) {
// 최신 로그
// 최신 스냅샷
const snapshot = await queryOne(`
SELECT cpu_usage as cpu_percent, memory_usage as memory_percent, disk_usage as disk_percent, checked_at as collected_at
FROM server_logs
WHERE target_id = $1 AND is_success = 1
ORDER BY checked_at DESC
SELECT cpu_percent, memory_percent, memory_total, memory_free, memory_used,
cpu_temp, load_percent, uptime_str, collected_at
FROM server_snapshots
WHERE target_id = $1 AND is_online = 1
ORDER BY collected_at DESC
LIMIT 1
`, [server.target_id])
// 최신 디스크 사용률 (최대값) + 용량
const diskData = await queryOne(`
SELECT MAX(disk_percent) as disk_percent,
SUM(disk_used) as disk_used,
SUM(disk_total) as disk_total
FROM server_disks
WHERE target_id = $1
AND collected_at = (SELECT MAX(collected_at) FROM server_disks WHERE target_id = $1)
AND device_name NOT LIKE '%loop%'
AND mount_point NOT LIKE '%/snap%'
AND fs_type NOT IN ('tmpfs', 'squashfs')
`, [server.target_id])
// 오프라인 체크
let isOffline = true
let lastCollected = null
@@ -197,8 +212,9 @@ async function getServerDashboard() {
if (!isOffline && snapshot) {
cpuLevel = getLevel(Number(snapshot.cpu_percent), thresholds.server?.cpu || { warning: 70, critical: 85, danger: 95 })
// 메모리: snapshot.memory_percent 직접 사용
memLevel = getLevel(Number(snapshot.memory_percent), thresholds.server?.memory || { warning: 80, critical: 90, danger: 95 })
diskLevel = getLevel(Number(snapshot.disk_percent), thresholds.server?.disk || { warning: 80, critical: 90, danger: 95 })
diskLevel = getLevel(Number(diskData?.disk_percent), thresholds.server?.disk || { warning: 80, critical: 90, danger: 95 })
serverLevel = getHighestLevel([cpuLevel, memLevel, diskLevel])
}
@@ -209,25 +225,114 @@ async function getServerDashboard() {
else if (serverLevel === 'warning') summaryServers.warning++
else summaryServers.normal++
// 컨테이너 조회 (최신 데이터만)
const containers: any[] = []
const containerSummary = { total: 0, normal: 0, warning: 0, critical: 0, stopped: 0 }
if (!isOffline) {
// 서버별 최신 컨테이너 (container_name별 최신 1건)
const latestContainers = await query(`
SELECT DISTINCT ON (container_name)
container_name as name,
container_status as status,
cpu_percent,
memory_usage,
memory_limit,
uptime,
network_rx,
network_tx
FROM server_containers
WHERE target_id = $1
ORDER BY container_name, collected_at DESC
`, [server.target_id])
for (const c of latestContainers) {
let containerLevel = 'normal'
if (c.status !== 'running') {
containerLevel = 'stopped'
containerSummary.stopped++
} else {
const cCpuLevel = getLevel(Number(c.cpu_percent), thresholds.container?.cpu || { warning: 80, critical: 90, danger: 95 })
const memPct = c.memory_limit ? (Number(c.memory_usage) / Number(c.memory_limit)) * 100 : 0
const cMemLevel = getLevel(memPct, thresholds.container?.memory || { warning: 80, critical: 90, danger: 95 })
containerLevel = getHighestLevel([cCpuLevel, cMemLevel])
if (containerLevel === 'danger') containerSummary.critical++
else if (containerLevel === 'critical') containerSummary.critical++
else if (containerLevel === 'warning') containerSummary.warning++
else containerSummary.normal++
}
containers.push({
name: c.name,
status: c.status || 'unknown',
level: containerLevel,
cpu_percent: c.cpu_percent,
memory_usage: c.memory_usage,
memory_limit: c.memory_limit,
uptime: c.uptime,
network_rx: c.network_rx,
network_tx: c.network_tx
})
containerSummary.total++
summaryContainers.total++
}
// 전체 컨테이너 요약 집계
summaryContainers.normal += containerSummary.normal
summaryContainers.warning += containerSummary.warning
summaryContainers.critical += containerSummary.critical
summaryContainers.stopped += containerSummary.stopped
}
serverStatuses.push({
target_id: server.target_id,
server_name: server.server_name,
server_ip: server.server_ip,
level: serverLevel,
cpu_percent: snapshot?.cpu_percent ?? null,
cpu_level: cpuLevel,
memory_percent: snapshot?.memory_percent ?? null,
memory_percent: snapshot?.memory_percent ?? null, // snapshot의 memory_percent 직접 사용
memory_level: memLevel,
disk_percent: snapshot?.disk_percent ?? null,
memory_total: snapshot?.memory_total ?? null,
memory_free: snapshot?.memory_free ?? null,
memory_used: snapshot?.memory_used ?? null,
disk_percent: diskData?.disk_percent ?? null,
disk_level: diskLevel,
last_collected: lastCollected
disk_used: diskData?.disk_used ?? null,
disk_total: diskData?.disk_total ?? null,
cpu_temp: snapshot?.cpu_temp ?? null,
load_percent: snapshot?.load_percent ?? null,
uptime_str: snapshot?.uptime_str ?? null,
last_collected: lastCollected,
containers: containers,
container_summary: containerSummary
})
}
// 서버 정렬: 이름
serverStatuses.sort((a, b) => a.server_name.localeCompare(b.server_name))
// 서버 정렬: 장애 우선 → 컨테이너 많은 순 → 이름순
serverStatuses.sort((a, b) => {
// 1. 장애 여부 (서버 장애 또는 컨테이너에 장애)
const aHasIssue = a.level !== 'normal' ||
(a.container_summary.stopped > 0 || a.container_summary.critical > 0 || a.container_summary.warning > 0)
const bHasIssue = b.level !== 'normal' ||
(b.container_summary.stopped > 0 || b.container_summary.critical > 0 || b.container_summary.warning > 0)
if (aHasIssue !== bHasIssue) return aHasIssue ? -1 : 1
// 2. 컨테이너 수 (많은 순)
const aContainers = a.container_summary.total || 0
const bContainers = b.container_summary.total || 0
if (aContainers !== bContainers) return bContainers - aContainers
// 3. 서버 이름순
return a.server_name.localeCompare(b.server_name)
})
return {
summary: { servers: summaryServers },
summary: { servers: summaryServers, containers: summaryContainers },
servers: serverStatuses,
timestamp: new Date().toISOString()
}

View File

@@ -13,7 +13,7 @@ let pool: pg.Pool | null = null
*/
export function getPool(): pg.Pool {
if (!pool) {
pool = new Pool({
const config = {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432'),
database: process.env.DB_NAME || 'osolit_monitor',
@@ -22,7 +22,11 @@ export function getPool(): pg.Pool {
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
})
}
console.log(`[DB] Connecting to ${config.host}:${config.port}/${config.database}`)
pool = new Pool(config)
pool.on('error', (err) => {
console.error('[DB] Unexpected pool error:', err)

View File

@@ -1,4 +1,5 @@
import { query, queryOne, execute, getPool } from './db'
import { ofetch } from 'ofetch'
interface ServerTarget {
target_id: number
@@ -20,16 +21,20 @@ function timestamp(): string {
return new Date().toLocaleString('sv-SE', { timeZone: 'Asia/Seoul' }).replace('T', ' ')
}
// Glances API 호출 (버전 지정)
// Glances API 호출 (버전 지정) - ofetch 사용
async function fetchGlancesApi(baseUrl: string, endpoint: string, version: string): Promise<any> {
const url = `${baseUrl}/api/${version}/${endpoint}`
try {
const url = `${baseUrl}/api/${version}/${endpoint}`
const response = await fetch(url, {
signal: AbortSignal.timeout(5000)
const data = await ofetch(url, {
timeout: 5000,
retry: 0
})
if (!response.ok) return null
return await response.json()
} catch (err) {
return data
} catch (err: any) {
// 404, timeout 등 에러는 조용히 null 반환
if (err.statusCode !== 404) {
console.error(`[fetchGlancesApi] ${url} - Error: ${err.message}`)
}
return null
}
}
@@ -62,6 +67,9 @@ async function detectApiVersion(baseUrl: string, serverName: string): Promise<st
async function detectAnomalies(targetId: number, serverName: string) {
const now = timestamp()
// 절대값 변화 최소 임계값 (이 값 이하면 무시)
const MIN_ABSOLUTE_CHANGE = 5
try {
// === 단기 변화율 감지 ===
const SHORT_TERM_THRESHOLD = 30
@@ -81,53 +89,57 @@ async function detectAnomalies(targetId: number, serverName: string) {
const currCpuAvg = currSnapshots.reduce((sum, s) => sum + (s.cpu_percent || 0), 0) / currSnapshots.length
const prevCpuAvg = prevSnapshots.reduce((sum, s) => sum + (s.cpu_percent || 0), 0) / prevSnapshots.length
// 메모리: memory_percent 직접 사용
const currMemAvg = currSnapshots.reduce((sum, s) => sum + (s.memory_percent || 0), 0) / currSnapshots.length
const prevMemAvg = prevSnapshots.reduce((sum, s) => sum + (s.memory_percent || 0), 0) / prevSnapshots.length
const cpuChange = prevCpuAvg > 1 ? ((currCpuAvg - prevCpuAvg) / prevCpuAvg) * 100 : currCpuAvg - prevCpuAvg
const memChange = prevMemAvg > 1 ? ((currMemAvg - prevMemAvg) / prevMemAvg) * 100 : currMemAvg - prevMemAvg
// CPU 단기 변화율 체크
if (Math.abs(cpuChange) >= SHORT_TERM_THRESHOLD) {
// 절대값 변화량
const cpuAbsChange = currCpuAvg - prevCpuAvg
const memAbsChange = currMemAvg - prevMemAvg
// CPU 단기 변화율 체크 (증가만 감지, 절대값 5%p 이상일 때만)
if (cpuChange >= SHORT_TERM_THRESHOLD && cpuAbsChange >= MIN_ABSOLUTE_CHANGE) {
const recentExists = await queryOne(`
SELECT 1 FROM anomaly_logs
WHERE target_id = $1 AND detect_type = 'short-term' AND metric = 'CPU'
AND detected_at > NOW() - INTERVAL '1 minute'
AND detected_at::timestamp > NOW() - INTERVAL '1 minute'
LIMIT 1
`, [targetId])
if (!recentExists) {
const level = Math.abs(cpuChange) >= 100 ? 'danger' : 'warning'
const direction = cpuChange >= 0 ? '증가' : '감소'
const message = `CPU ${direction} 감지 (${prevCpuAvg.toFixed(1)}% → ${currCpuAvg.toFixed(1)}%)`
const level = cpuChange >= 100 ? 'danger' : 'warning'
const message = `CPU 급증 감지 (${prevCpuAvg.toFixed(1)}% → ${currCpuAvg.toFixed(1)}%)`
await execute(`
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
VALUES ($1, $2, 'short-term', 'CPU', $3, $4, $5, $6)
`, [targetId, serverName, level, currCpuAvg, cpuChange, message])
console.log(`[${now}] 🚨 [${serverName}] 단기변화율 이상감지: CPU ${cpuChange.toFixed(1)}% (${level})`)
console.log(`[${now}] 🚨 [${serverName}] 단기변화율 이상감지: CPU +${cpuChange.toFixed(1)}% (${level})`)
}
}
// Memory 단기 변화율 체크
if (Math.abs(memChange) >= SHORT_TERM_THRESHOLD) {
// Memory 단기 변화율 체크 (증가만 감지, 절대값 5%p 이상일 때만)
if (memChange >= SHORT_TERM_THRESHOLD && memAbsChange >= MIN_ABSOLUTE_CHANGE) {
const recentExists = await queryOne(`
SELECT 1 FROM anomaly_logs
WHERE target_id = $1 AND detect_type = 'short-term' AND metric = 'Memory'
AND detected_at > NOW() - INTERVAL '1 minute'
AND detected_at::timestamp > NOW() - INTERVAL '1 minute'
LIMIT 1
`, [targetId])
if (!recentExists) {
const level = Math.abs(memChange) >= 100 ? 'danger' : 'warning'
const direction = memChange >= 0 ? '증가' : '감소'
const message = `Memory ${direction} 감지 (${prevMemAvg.toFixed(1)}% → ${currMemAvg.toFixed(1)}%)`
const level = memChange >= 100 ? 'danger' : 'warning'
const message = `Memory 급증 감지 (${prevMemAvg.toFixed(1)}% → ${currMemAvg.toFixed(1)}%)`
await execute(`
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
VALUES ($1, $2, 'short-term', 'Memory', $3, $4, $5, $6)
`, [targetId, serverName, level, currMemAvg, memChange, message])
console.log(`[${now}] 🚨 [${serverName}] 단기변화율 이상감지: Memory ${memChange.toFixed(1)}% (${level})`)
console.log(`[${now}] 🚨 [${serverName}] 단기변화율 이상감지: Memory +${memChange.toFixed(1)}% (${level})`)
}
}
}
@@ -140,14 +152,14 @@ async function detectAnomalies(targetId: number, serverName: string) {
SELECT cpu_percent, memory_percent
FROM server_snapshots
WHERE target_id = $1 AND is_online = 1
AND collected_at >= NOW() - INTERVAL '1 hour'
AND collected_at::timestamp >= NOW() - INTERVAL '1 hour'
ORDER BY collected_at DESC
`, [targetId])
if (hourSnapshots.length >= 10) {
const current = hourSnapshots[0]
const currCpu = current.cpu_percent ?? 0
const currMem = current.memory_percent ?? 0
const currMem = current.memory_percent ?? 0 // memory_percent 직접 사용
const cpuValues = hourSnapshots.map(s => s.cpu_percent ?? 0)
const memValues = hourSnapshots.map(s => s.memory_percent ?? 0)
@@ -163,19 +175,22 @@ async function detectAnomalies(targetId: number, serverName: string) {
const cpuZscore = cpuStd > 0.1 ? (currCpu - cpuAvg) / cpuStd : 0
const memZscore = memStd > 0.1 ? (currMem - memAvg) / memStd : 0
// CPU Z-Score 체크
if (Math.abs(cpuZscore) >= WARNING_Z) {
// 절대값 변화량
const cpuAbsDiff = currCpu - cpuAvg
const memAbsDiff = currMem - memAvg
// CPU Z-Score 체크 (높은 경우만 감지, 절대값 5%p 이상일 때만)
if (cpuZscore >= WARNING_Z && cpuAbsDiff >= MIN_ABSOLUTE_CHANGE) {
const recentExists = await queryOne(`
SELECT 1 FROM anomaly_logs
WHERE target_id = $1 AND detect_type = 'zscore' AND metric = 'CPU'
AND detected_at > NOW() - INTERVAL '1 minute'
AND detected_at::timestamp > NOW() - INTERVAL '1 minute'
LIMIT 1
`, [targetId])
if (!recentExists) {
const level = Math.abs(cpuZscore) >= DANGER_Z ? 'danger' : 'warning'
const direction = cpuZscore >= 0 ? '높음' : '낮음'
const message = `CPU 평균 대비 ${Math.abs(cpuZscore).toFixed(1)}σ ${direction} (평균: ${cpuAvg.toFixed(1)}%, 현재: ${currCpu.toFixed(1)}%)`
const level = cpuZscore >= DANGER_Z ? 'danger' : 'warning'
const message = `CPU 평균 대비 ${cpuZscore.toFixed(1)}σ 높음 (평균: ${cpuAvg.toFixed(1)}%, 현재: ${currCpu.toFixed(1)}%)`
await execute(`
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
@@ -185,19 +200,18 @@ async function detectAnomalies(targetId: number, serverName: string) {
}
}
// Memory Z-Score 체크
if (Math.abs(memZscore) >= WARNING_Z) {
// Memory Z-Score 체크 (높은 경우만 감지, 절대값 5%p 이상일 때만)
if (memZscore >= WARNING_Z && memAbsDiff >= MIN_ABSOLUTE_CHANGE) {
const recentExists = await queryOne(`
SELECT 1 FROM anomaly_logs
WHERE target_id = $1 AND detect_type = 'zscore' AND metric = 'Memory'
AND detected_at > NOW() - INTERVAL '1 minute'
AND detected_at::timestamp > NOW() - INTERVAL '1 minute'
LIMIT 1
`, [targetId])
if (!recentExists) {
const level = Math.abs(memZscore) >= DANGER_Z ? 'danger' : 'warning'
const direction = memZscore >= 0 ? '높음' : '낮음'
const message = `Memory 평균 대비 ${Math.abs(memZscore).toFixed(1)}σ ${direction} (평균: ${memAvg.toFixed(1)}%, 현재: ${currMem.toFixed(1)}%)`
const level = memZscore >= DANGER_Z ? 'danger' : 'warning'
const message = `Memory 평균 대비 ${memZscore.toFixed(1)}σ 높음 (평균: ${memAvg.toFixed(1)}%, 현재: ${currMem.toFixed(1)}%)`
await execute(`
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
@@ -219,12 +233,12 @@ async function detectAnomalies(targetId: number, serverName: string) {
SELECT cpu_percent, memory_percent
FROM server_snapshots
WHERE target_id = $1 AND is_online = 1
AND collected_at >= NOW() - INTERVAL '14 days'
AND EXTRACT(HOUR FROM collected_at) = $2
AND collected_at::timestamp >= NOW() - INTERVAL '14 days'
AND EXTRACT(HOUR FROM collected_at::timestamp) = $2
AND (
($3 = 'weekend' AND EXTRACT(DOW FROM collected_at) IN (0, 6))
($3 = 'weekend' AND EXTRACT(DOW FROM collected_at::timestamp) IN (0, 6))
OR
($3 = 'weekday' AND EXTRACT(DOW FROM collected_at) NOT IN (0, 6))
($3 = 'weekday' AND EXTRACT(DOW FROM collected_at::timestamp) NOT IN (0, 6))
)
`, [targetId, currentHour, dayType])
@@ -237,7 +251,7 @@ async function detectAnomalies(targetId: number, serverName: string) {
if (baselineData.length >= 5 && currentSnapshot) {
const currCpu = currentSnapshot.cpu_percent ?? 0
const currMem = currentSnapshot.memory_percent ?? 0
const currMem = currentSnapshot.memory_percent ?? 0 // memory_percent 직접 사용
const cpuValues = baselineData.map(s => s.cpu_percent ?? 0)
const memValues = baselineData.map(s => s.memory_percent ?? 0)
@@ -253,19 +267,23 @@ async function detectAnomalies(targetId: number, serverName: string) {
const cpuDeviation = cpuStd > 0.1 ? (currCpu - cpuAvg) / cpuStd : 0
const memDeviation = memStd > 0.1 ? (currMem - memAvg) / memStd : 0
if (Math.abs(cpuDeviation) >= DEVIATION_THRESHOLD) {
// 절대값 변화량
const cpuBaseAbsDiff = currCpu - cpuAvg
const memBaseAbsDiff = currMem - memAvg
// CPU 베이스라인 체크 (높은 경우만 감지, 절대값 5%p 이상일 때만)
if (cpuDeviation >= DEVIATION_THRESHOLD && cpuBaseAbsDiff >= MIN_ABSOLUTE_CHANGE) {
const recentExists = await queryOne(`
SELECT 1 FROM anomaly_logs
WHERE target_id = $1 AND detect_type = 'baseline' AND metric = 'CPU'
AND detected_at > NOW() - INTERVAL '1 minute'
AND detected_at::timestamp > NOW() - INTERVAL '1 minute'
LIMIT 1
`, [targetId])
if (!recentExists) {
const level = Math.abs(cpuDeviation) >= 3.0 ? 'danger' : 'warning'
const direction = cpuDeviation >= 0 ? '높음' : '낮음'
const level = cpuDeviation >= 3.0 ? 'danger' : 'warning'
const dayLabel = isWeekend ? '주말' : '평일'
const message = `CPU ${dayLabel} ${currentHour}시 베이스라인 대비 ${Math.abs(cpuDeviation).toFixed(1)}σ ${direction}`
const message = `CPU ${dayLabel} ${currentHour}시 베이스라인 대비 ${cpuDeviation.toFixed(1)}σ 높음`
await execute(`
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
@@ -275,19 +293,19 @@ async function detectAnomalies(targetId: number, serverName: string) {
}
}
if (Math.abs(memDeviation) >= DEVIATION_THRESHOLD) {
// Memory 베이스라인 체크 (높은 경우만 감지, 절대값 5%p 이상일 때만)
if (memDeviation >= DEVIATION_THRESHOLD && memBaseAbsDiff >= MIN_ABSOLUTE_CHANGE) {
const recentExists = await queryOne(`
SELECT 1 FROM anomaly_logs
WHERE target_id = $1 AND detect_type = 'baseline' AND metric = 'Memory'
AND detected_at > NOW() - INTERVAL '1 minute'
AND detected_at::timestamp > NOW() - INTERVAL '1 minute'
LIMIT 1
`, [targetId])
if (!recentExists) {
const level = Math.abs(memDeviation) >= 3.0 ? 'danger' : 'warning'
const direction = memDeviation >= 0 ? '높음' : '낮음'
const level = memDeviation >= 3.0 ? 'danger' : 'warning'
const dayLabel = isWeekend ? '주말' : '평일'
const message = `Memory ${dayLabel} ${currentHour}시 베이스라인 대비 ${Math.abs(memDeviation).toFixed(1)}σ ${direction}`
const message = `Memory ${dayLabel} ${currentHour}시 베이스라인 대비 ${memDeviation.toFixed(1)}σ 높음`
await execute(`
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
@@ -306,7 +324,7 @@ async function detectAnomalies(targetId: number, serverName: string) {
SELECT cpu_percent, memory_percent
FROM server_snapshots
WHERE target_id = $1 AND is_online = 1
AND collected_at >= NOW() - INTERVAL '${WINDOW_MINUTES} minutes'
AND collected_at::timestamp >= NOW() - INTERVAL '${WINDOW_MINUTES} minutes'
ORDER BY collected_at ASC
`, [targetId])
@@ -341,7 +359,7 @@ async function detectAnomalies(targetId: number, serverName: string) {
const recentExists = await queryOne(`
SELECT 1 FROM anomaly_logs
WHERE target_id = $1 AND detect_type = 'trend' AND metric = 'CPU'
AND detected_at > NOW() - INTERVAL '1 minute'
AND detected_at::timestamp > NOW() - INTERVAL '1 minute'
LIMIT 1
`, [targetId])
@@ -361,7 +379,7 @@ async function detectAnomalies(targetId: number, serverName: string) {
const recentExists = await queryOne(`
SELECT 1 FROM anomaly_logs
WHERE target_id = $1 AND detect_type = 'trend' AND metric = 'Memory'
AND detected_at > NOW() - INTERVAL '1 minute'
AND detected_at::timestamp > NOW() - INTERVAL '1 minute'
LIMIT 1
`, [targetId])
@@ -459,10 +477,10 @@ async function collectServerData(target: ServerTarget) {
await execute(`
INSERT INTO server_snapshots (
target_id, os_name, os_version, host_name, uptime_seconds, uptime_str, ip_address,
cpu_name, cpu_count, cpu_percent, memory_total, memory_used, memory_percent,
cpu_name, cpu_count, cpu_percent, memory_total, memory_used, memory_free, memory_percent,
swap_total, swap_used, swap_percent, is_online, api_version, cpu_temp,
load_1, load_5, load_15, load_percent, collected_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25)
`, [
target.target_id,
system?.os_name || system?.linux_distro || null,
@@ -474,11 +492,12 @@ async function collectServerData(target: ServerTarget) {
quicklook?.cpu_name || null,
quicklook?.cpu_number || quicklook?.cpu_log_core || cpu?.cpucore || null,
cpu?.total ?? quicklook?.cpu ?? null,
mem?.total || null,
mem?.used || null,
mem?.total ? Math.round(mem.total) : null, // bigint: 정수 변환
mem?.total && mem?.percent ? Math.round(mem.total * mem.percent / 100) : null, // bigint: 정수 변환
mem?.free ? Math.round(mem.free) : null, // bigint: 정수 변환
mem?.percent || null,
memswap?.total || null,
memswap?.used || null,
memswap?.total ? Math.round(memswap.total) : null, // bigint: 정수 변환
memswap?.used ? Math.round(memswap.used) : null, // bigint: 정수 변환
memswap?.percent || null,
isOnline ? 1 : 0,
apiVersion,
@@ -504,8 +523,8 @@ async function collectServerData(target: ServerTarget) {
disk.device_name || null,
disk.mnt_point || null,
disk.fs_type || null,
disk.size || null,
disk.used || null,
disk.size ? Math.round(disk.size) : null, // bigint: 정수 변환
disk.used ? Math.round(disk.used) : null, // bigint: 정수 변환
disk.percent || null,
now
])
@@ -516,6 +535,26 @@ async function collectServerData(target: ServerTarget) {
if (Array.isArray(docker) && docker.length > 0) {
console.log(`[${now}] 🐳 [${target.server_name}] container 저장 (${docker.length}개 컨테이너)`)
for (const container of docker) {
// CPU 값 추출 로직
const cpuPercent = container.cpu?.total ?? container.cpu_percent ?? null
const memoryUsage = container.memory?.usage || container.memory_usage || null
const memoryLimit = container.memory?.limit || container.memory_limit || null
const memoryPercent = container.memory?.usage && container.memory?.limit
? (container.memory.usage / container.memory.limit * 100)
: container.memory_percent ?? null
// 각 컨테이너별 상세 로그
console.log(`[${now}] 🐳 - ${container.name}: CPU=${cpuPercent?.toFixed(2) ?? 'null'}%, MEM=${memoryPercent?.toFixed(2) ?? 'null'}%`)
// CPU가 null인 경우 상세 디버깅
if (cpuPercent === null) {
console.warn(`[${now}] ⚠️ - ${container.name}: CPU 값 null! 원본:`, JSON.stringify({
'cpu.total': container.cpu?.total,
'cpu_percent': container.cpu_percent,
'cpu': container.cpu
}))
}
await execute(`
INSERT INTO server_containers (
target_id, docker_id, container_name, container_image,
@@ -528,12 +567,10 @@ async function collectServerData(target: ServerTarget) {
container.name || null,
Array.isArray(container.image) ? container.image.join(', ') : container.image || null,
container.status || null,
container.cpu?.total ?? container.cpu_percent ?? null,
container.memory?.usage || container.memory_usage || null,
container.memory?.limit || container.memory_limit || null,
container.memory?.usage && container.memory?.limit
? (container.memory.usage / container.memory.limit * 100)
: container.memory_percent ?? null,
cpuPercent,
memoryUsage ? Math.round(memoryUsage) : null, // bigint: 정수 변환
memoryLimit ? Math.round(memoryLimit) : null, // bigint: 정수 변환
memoryPercent,
container.uptime || null,
container.network?.rx ?? container.network_rx ?? null,
container.network?.tx ?? container.network_tx ?? null,
@@ -546,6 +583,11 @@ async function collectServerData(target: ServerTarget) {
if (Array.isArray(network) && network.length > 0) {
console.log(`[${now}] 🌐 [${target.server_name}] network 저장 (${network.length}개 인터페이스)`)
for (const iface of network) {
const bytesRecv = iface.bytes_recv || iface.cumulative_rx || null
const bytesSent = iface.bytes_sent || iface.cumulative_tx || null
const packetsRecv = iface.packets_recv || null
const packetsSent = iface.packets_sent || null
await execute(`
INSERT INTO server_networks (
target_id, interface_name, bytes_recv, bytes_sent,
@@ -555,10 +597,10 @@ async function collectServerData(target: ServerTarget) {
`, [
target.target_id,
iface.interface_name || null,
iface.bytes_recv || iface.cumulative_rx || null,
iface.bytes_sent || iface.cumulative_tx || null,
iface.packets_recv || null,
iface.packets_sent || null,
bytesRecv ? Math.round(bytesRecv) : null, // bigint: 정수 변환
bytesSent ? Math.round(bytesSent) : null, // bigint: 정수 변환
packetsRecv ? Math.round(packetsRecv) : null, // bigint: 정수 변환
packetsSent ? Math.round(packetsSent) : null, // bigint: 정수 변환
iface.bytes_recv_rate_per_sec || iface.rx || iface.bytes_recv_rate || null,
iface.bytes_sent_rate_per_sec || iface.tx || iface.bytes_sent_rate || null,
iface.is_up ? 1 : 0,

View File

@@ -1,2 +0,0 @@
# This file ensures the database directory is included in git
# The actual .db files are ignored in .gitignore

BIN
database/osolit-monitor.db Normal file

Binary file not shown.

View File

@@ -1,6 +0,0 @@
# ===========================================
# Osolit Monitor - Docker 환경변수
# ===========================================
# PostgreSQL
DB_PASSWORD=osolit1!

View File

@@ -1,11 +0,0 @@
# ===========================================
# Osolit Monitor - 외부 PostgreSQL 연결 설정
# 기존 PostgreSQL 서버에 연결할 때 사용
# ===========================================
# PostgreSQL 연결 정보
DB_HOST=192.168.0.248
DB_PORT=5432
DB_NAME=osolit_monitor
DB_USER=postgres
DB_PASSWORD=osolit1!

View File

@@ -1,7 +0,0 @@
# PostgreSQL 설정
POSTGRES_USER=osolit
POSTGRES_PASSWORD=osolit1234
POSTGRES_DB=osolit_monitor
# 타임존
TZ=Asia/Seoul

View File

@@ -263,8 +263,8 @@ onUnmounted(() => { if (chart) chart.destroy() })
.pros-cons { display: flex; gap: 16px; }
.pros, .cons { flex: 1; padding: 12px; border-radius: 8px; }
.pros { background: #f0fdf4; border: 1px solid #86efac; }
.cons { background: #fef2f2; border: 1px solid #fca5a5; }
.pros { background: var(--container-normal-bg, #f0fdf4); border: 1px solid var(--container-normal-border, #86efac); }
.cons { background: var(--container-danger-bg, #fef2f2); border: 1px solid var(--container-danger-border, #fca5a5); }
.pros h4, .cons h4 { margin: 0 0 8px 0; font-size: 13px; }
.pros ul, .cons ul { margin: 0; padding-left: 18px; font-size: 12px; color: var(--text-secondary); }
@@ -304,14 +304,14 @@ onUnmounted(() => { if (chart) chart.destroy() })
.log-list { max-height: 300px; overflow-y: auto; }
.log-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 6px; margin-bottom: 4px; font-size: 12px; }
.log-item.warning { background: #fefce8; }
.log-item.danger { background: #fef2f2; }
.log-item.warning { background: var(--log-warning-bg, #fefce8); }
.log-item.danger { background: var(--log-danger-bg, #fef2f2); }
.log-time { color: var(--text-muted); font-family: monospace; min-width: 70px; }
.log-level { font-size: 14px; }
.log-server { font-weight: 600; color: var(--text-primary); min-width: 80px; }
.log-metric { color: var(--text-secondary); min-width: 50px; }
.log-value { font-weight: 600; min-width: 50px; }
.log-item.warning .log-value { color: #ca8a04; }
.log-item.danger .log-value { color: #dc2626; }
.log-item.warning .log-value { color: var(--log-warning-text, #ca8a04); }
.log-item.danger .log-value { color: var(--log-danger-text, #dc2626); }
.log-msg { color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
</style>

View File

@@ -293,8 +293,8 @@ onUnmounted(() => {
.pros-cons { display: flex; gap: 16px; }
.pros, .cons { flex: 1; padding: 12px; border-radius: 8px; }
.pros { background: #f0fdf4; border: 1px solid #86efac; }
.cons { background: #fef2f2; border: 1px solid #fca5a5; }
.pros { background: var(--container-normal-bg, #f0fdf4); border: 1px solid var(--container-normal-border, #86efac); }
.cons { background: var(--container-danger-bg, #fef2f2); border: 1px solid var(--container-danger-border, #fca5a5); }
.pros h4, .cons h4 { margin: 0 0 8px 0; font-size: 13px; }
.pros ul, .cons ul { margin: 0; padding-left: 18px; font-size: 12px; color: var(--text-secondary); }
.pros li, .cons li { margin-bottom: 2px; }
@@ -338,14 +338,14 @@ onUnmounted(() => {
.log-list { max-height: 300px; overflow-y: auto; }
.log-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 6px; margin-bottom: 4px; font-size: 12px; }
.log-item.warning { background: #fefce8; }
.log-item.danger { background: #fef2f2; }
.log-item.warning { background: var(--log-warning-bg, #fefce8); }
.log-item.danger { background: var(--log-danger-bg, #fef2f2); }
.log-time { color: var(--text-muted); font-family: monospace; min-width: 70px; }
.log-level { font-size: 14px; }
.log-server { font-weight: 600; color: var(--text-primary); min-width: 80px; }
.log-metric { color: var(--text-secondary); min-width: 50px; }
.log-value { font-weight: 600; min-width: 50px; }
.log-item.warning .log-value { color: #ca8a04; }
.log-item.danger .log-value { color: #dc2626; }
.log-item.warning .log-value { color: var(--log-warning-text, #ca8a04); }
.log-item.danger .log-value { color: var(--log-danger-text, #dc2626); }
.log-msg { color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
</style>

View File

@@ -263,8 +263,8 @@ onUnmounted(() => { if (chart) chart.destroy() })
.pros-cons { display: flex; gap: 16px; }
.pros, .cons { flex: 1; padding: 12px; border-radius: 8px; }
.pros { background: #f0fdf4; border: 1px solid #86efac; }
.cons { background: #fef2f2; border: 1px solid #fca5a5; }
.pros { background: var(--container-normal-bg, #f0fdf4); border: 1px solid var(--container-normal-border, #86efac); }
.cons { background: var(--container-danger-bg, #fef2f2); border: 1px solid var(--container-danger-border, #fca5a5); }
.pros h4, .cons h4 { margin: 0 0 8px 0; font-size: 13px; }
.pros ul, .cons ul { margin: 0; padding-left: 18px; font-size: 12px; color: var(--text-secondary); }
@@ -305,14 +305,14 @@ onUnmounted(() => { if (chart) chart.destroy() })
.log-list { max-height: 300px; overflow-y: auto; }
.log-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 6px; margin-bottom: 4px; font-size: 12px; }
.log-item.warning { background: #fefce8; }
.log-item.danger { background: #fef2f2; }
.log-item.warning { background: var(--log-warning-bg, #fefce8); }
.log-item.danger { background: var(--log-danger-bg, #fef2f2); }
.log-time { color: var(--text-muted); font-family: monospace; min-width: 70px; }
.log-level { font-size: 14px; }
.log-server { font-weight: 600; color: var(--text-primary); min-width: 80px; }
.log-metric { color: var(--text-secondary); min-width: 50px; }
.log-value { font-weight: 600; min-width: 70px; }
.log-item.warning .log-value { color: #ca8a04; }
.log-item.danger .log-value { color: #dc2626; }
.log-item.warning .log-value { color: var(--log-warning-text, #ca8a04); }
.log-item.danger .log-value { color: var(--log-danger-text, #dc2626); }
.log-msg { color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
</style>

View File

@@ -258,8 +258,8 @@ onUnmounted(() => { if (chart) chart.destroy() })
.pros-cons { display: flex; gap: 16px; }
.pros, .cons { flex: 1; padding: 12px; border-radius: 8px; }
.pros { background: #f0fdf4; border: 1px solid #86efac; }
.cons { background: #fef2f2; border: 1px solid #fca5a5; }
.pros { background: var(--container-normal-bg, #f0fdf4); border: 1px solid var(--container-normal-border, #86efac); }
.cons { background: var(--container-danger-bg, #fef2f2); border: 1px solid var(--container-danger-border, #fca5a5); }
.pros h4, .cons h4 { margin: 0 0 8px 0; font-size: 13px; }
.pros ul, .cons ul { margin: 0; padding-left: 18px; font-size: 12px; color: var(--text-secondary); }
@@ -299,14 +299,14 @@ onUnmounted(() => { if (chart) chart.destroy() })
.log-list { max-height: 300px; overflow-y: auto; }
.log-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 6px; margin-bottom: 4px; font-size: 12px; }
.log-item.warning { background: #fefce8; }
.log-item.danger { background: #fef2f2; }
.log-item.warning { background: var(--log-warning-bg, #fefce8); }
.log-item.danger { background: var(--log-danger-bg, #fef2f2); }
.log-time { color: var(--text-muted); font-family: monospace; min-width: 70px; }
.log-level { font-size: 14px; }
.log-server { font-weight: 600; color: var(--text-primary); min-width: 80px; }
.log-metric { color: var(--text-secondary); min-width: 50px; }
.log-value { font-weight: 600; min-width: 60px; }
.log-item.warning .log-value { color: #ca8a04; }
.log-item.danger .log-value { color: #dc2626; }
.log-item.warning .log-value { color: var(--log-warning-text, #ca8a04); }
.log-item.danger .log-value { color: var(--log-danger-text, #dc2626); }
.log-msg { color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
</style>

View File

@@ -38,6 +38,10 @@
--sidebar-active-bg: #e8e8e8;
--sidebar-active-border: #4a90d9;
/* 액센트 색상 */
--accent-color: #3b82f6;
--accent-bg: #eff6ff;
/* 기타 */
--link-color: #4a90d9;
--time-color: #4a90d9;
@@ -73,6 +77,28 @@
--fail-border: #f87171;
--fail-text: #fca5a5;
/* 컨테이너 카드 색상 */
--container-normal-bg: #1a3d1a;
--container-normal-border: #2d5a2d;
--container-warning-bg: #3d3a1a;
--container-warning-border: #5a5a2d;
--container-critical-bg: #3d2a1a;
--container-critical-border: #5a3d2d;
--container-danger-bg: #3d1a1a;
--container-danger-border: #5a2d2d;
/* 로그 색상 */
--log-warning-bg: #3d3a1a;
--log-warning-text: #fbbf24;
--log-danger-bg: #3d1a1a;
--log-danger-text: #f87171;
/* 컨테이너 상태 텍스트 */
--container-status-running: #86efac;
--container-status-exited: #fca5a5;
--container-status-paused: #fcd34d;
--container-status-restarting: #fdba74;
/* 버튼 */
--btn-active-bg: #555;
--btn-active-border: #666;
@@ -88,6 +114,10 @@
--sidebar-active-bg: #3a3a3a;
--sidebar-active-border: #6b9dc4;
/* 액센트 색상 */
--accent-color: #60a5fa;
--accent-bg: #1e3a5f;
/* 기타 */
--link-color: #6b9dc4;
--time-color: #6b9dc4;

View File

@@ -22,12 +22,8 @@
{{ autoRefresh ? '⏸ 자동갱신 ON' : '▶ 자동갱신 OFF' }}
</button>
<div class="last-fetch-info">
마지막 조회: <span class="last-fetch-time">{{ relativeTime }}</span>
</div>
</div>
<div class="control-row">
<div class="control-divider"></div>
<span class="control-label">특정시간:</span>
<input
type="datetime-local"
@@ -49,7 +45,10 @@
<span v-if="fetchState === 'loading'" class="loading-spinner"></span>
{{ buttonText }}
</button>
<span v-if="autoRefresh" class="hint-text">자동갱신 OFF 특정시간 조회 가능</span>
<div class="last-fetch-info">
마지막 조회: <span class="last-fetch-time">{{ relativeTime }}</span>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,191 @@
<template>
<div class="location-stats-card">
<div class="card-header">
<span class="card-title">🏢 물리공간</span>
</div>
<div class="location-list">
<div
v-for="loc in locations"
:key="loc.location"
class="location-item"
:class="getTempClass(loc.avgTemp)"
>
<div class="loc-header">
<span class="loc-name">{{ loc.location }}</span>
<span class="loc-servers">{{ loc.serverCount }}</span>
</div>
<div class="loc-stats">
<div class="stat-temp">
<span class="temp-value">{{ loc.avgTemp ? loc.avgTemp + '°' : '-' }}</span>
</div>
<div class="stat-traffic">
<div class="traffic-row">
<span class="traffic-label"></span>
<span class="traffic-value rx">{{ formatBytes(loc.totalRx) }}</span>
</div>
<div class="traffic-row">
<span class="traffic-label"></span>
<span class="traffic-value tx">{{ formatBytes(loc.totalTx) }}</span>
</div>
</div>
</div>
</div>
<div v-if="!locations || locations.length === 0" class="no-data">
데이터 없음
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface LocationStat {
location: string
serverCount: number
avgTemp: number | null
totalRx: number
totalTx: number
}
defineProps<{
locations: LocationStat[]
}>()
function getTempClass(temp: number | null): string {
if (temp === null) return ''
if (temp >= 70) return 'temp-critical'
if (temp >= 60) return 'temp-warning'
return 'temp-normal'
}
function formatBytes(bytes: number): string {
if (!bytes || bytes === 0) return '0B'
if (bytes >= 1024 * 1024 * 1024) return (bytes / (1024 * 1024 * 1024)).toFixed(1) + 'G'
if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + 'M'
if (bytes >= 1024) return (bytes / 1024).toFixed(0) + 'K'
return bytes.toFixed(0) + 'B'
}
</script>
<style scoped>
.location-stats-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 6px;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.card-header {
margin-bottom: 4px;
padding-bottom: 4px;
border-bottom: 1px solid var(--border-color);
}
.card-title { font-size: 11px; font-weight: 600; color: var(--text-primary); }
.location-list {
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
flex: 1;
}
.location-item {
background: var(--bg-tertiary, #f8fafc);
border-radius: 6px;
padding: 5px 6px;
border-left: 3px solid #22c55e;
}
.location-item.temp-warning { border-left-color: #f59e0b; }
.location-item.temp-critical { border-left-color: #ef4444; }
.loc-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 3px;
}
.loc-name {
font-size: 10px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.loc-servers {
font-size: 9px;
color: var(--text-muted);
flex-shrink: 0;
margin-left: 4px;
}
.loc-stats {
display: flex;
align-items: center;
gap: 6px;
}
.stat-temp {
flex-shrink: 0;
}
.temp-value {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
font-family: monospace;
}
.temp-warning .temp-value { color: #f59e0b; }
.temp-critical .temp-value { color: #ef4444; }
.stat-traffic {
flex: 1;
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
overflow: hidden;
}
.traffic-row {
display: flex;
align-items: center;
gap: 2px;
}
.traffic-label {
font-size: 11px;
color: var(--text-muted);
width: 10px;
flex-shrink: 0;
}
.traffic-value {
font-size: 12px;
font-weight: 600;
font-family: monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.traffic-value.rx { color: #06b6d4; }
.traffic-value.tx { color: #f59e0b; }
.no-data {
font-size: 10px;
color: var(--text-muted);
text-align: center;
padding: 12px 0;
}
</style>

View File

@@ -1,14 +1,8 @@
<template>
<div :class="['network-card', statusClass]" @click="goToList">
<div class="card-icon">{{ icon }}</div>
<div class="card-title">{{ title }}</div>
<div class="card-status" v-if="status && status.last_checked_at">
<span class="status-icon">{{ status.is_healthy ? '✅' : '❌' }}</span>
<span class="status-text">{{ status.is_healthy ? '정상' : '오류' }}</span>
</div>
<div class="card-status" v-else>
<span class="status-icon"></span>
<span class="status-text">대기</span>
<div class="card-row">
<span class="card-title">{{ icon }} {{ title }}</span>
<span class="status-badge">{{ status?.is_healthy ? '✅' : status?.last_checked_at ? '❌' : '⚪' }}</span>
</div>
<div class="card-time" v-if="status && status.last_checked_at">
{{ formatTimeAgo(status.last_checked_at) }}
@@ -55,30 +49,29 @@ function formatTimeAgo(datetime: string | null): string {
.network-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 12px;
padding: 8px 10px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
text-align: center;
}
.network-card:hover {
background: var(--bg-tertiary, #f8fafc);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.network-card.healthy { border-top: 4px solid #22c55e; }
.network-card.unhealthy { border-top: 4px solid #ef4444; }
.network-card.pending { border-top: 4px solid #9ca3af; }
.network-card.healthy { border-left: 3px solid #22c55e; }
.network-card.unhealthy { border-left: 3px solid #ef4444; }
.network-card.pending { border-left: 3px solid #9ca3af; }
.card-icon { font-size: 28px; margin-bottom: 8px; }
.card-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 10px; }
.card-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-status { display: flex; align-items: center; justify-content: center; gap: 6px; margin-bottom: 4px; }
.status-icon { font-size: 16px; }
.status-text { font-size: 15px; font-weight: 500; color: var(--text-secondary); }
.card-title { font-size: 12px; font-weight: 600; color: var(--text-primary); }
.status-badge { font-size: 12px; }
.card-time { font-size: 12px; color: var(--text-muted); }
.card-time { font-size: 10px; color: var(--text-muted); margin-top: 2px; }
</style>

View File

@@ -27,41 +27,72 @@
</div>
<div class="server-grid">
<!-- 서버 유닛들 -->
<div
v-for="server in servers"
:key="server.target_id"
:class="['server-unit', server.level]"
>
<!-- 서버 정보 (왼쪽) -->
<div class="server-info" @dblclick="goToServerStatus(server.target_id)">
<div class="server-name">
<span class="level-icon">{{ levelIcon(server.level) }}</span>
<span class="name">{{ server.server_name }}</span>
<div class="name-wrap">
<span class="name">{{ server.server_name }}</span>
<span class="server-ip">{{ server.server_ip }}</span>
</div>
<span class="container-count" v-if="server.level !== 'offline'">📦{{ server.container_summary.total }}</span>
</div>
<template v-if="server.level !== 'offline'">
<!-- 업타임 -->
<div class="info-row">
<span class="info-label">UP</span>
<span class="info-value">{{ server.uptime_str || '-' }}</span>
</div>
<div class="metric-row">
<span class="metric-label">CPU</span>
<div class="progress-bar">
<div :class="['progress-fill', server.cpu_level]" :style="{ width: (server.cpu_percent || 0) + '%' }"></div>
</div>
<span :class="['metric-value', server.cpu_level]">{{ server.cpu_percent?.toFixed(0) || '-' }}</span>
<span :class="['metric-value', server.cpu_level, { 'value-changed': isChanged(server.target_id, 'cpu') }]">
{{ server.cpu_percent?.toFixed(0) || '-' }}%
</span>
</div>
<div class="metric-row">
<span class="metric-label">MEM</span>
<div class="progress-bar">
<div :class="['progress-fill', server.memory_level]" :style="{ width: (server.memory_percent || 0) + '%' }"></div>
<div :class="['progress-fill', server.memory_level]" :style="{ width: calcMemPercent(server) + '%' }"></div>
</div>
<span :class="['metric-value', server.memory_level]">{{ server.memory_percent?.toFixed(0) || '-' }}</span>
<span :class="['metric-value', server.memory_level, { 'value-changed': isChanged(server.target_id, 'mem') }]">
{{ calcMemPercent(server).toFixed(0) }}%
</span>
</div>
<div class="metric-row">
<span class="metric-label">DISK</span>
<div class="progress-bar">
<div :class="['progress-fill', server.disk_level]" :style="{ width: (server.disk_percent || 0) + '%' }"></div>
</div>
<span :class="['metric-value', server.disk_level]">{{ server.disk_percent?.toFixed(0) || '-' }}</span>
<span :class="['metric-value', server.disk_level, { 'value-changed': isChanged(server.target_id, 'disk') }]">
{{ server.disk_percent?.toFixed(0) || '-' }}%
</span>
</div>
<!-- 추가 정보: 온도, Load, 메모리용량, 디스크용량 -->
<div class="info-row">
<span class="info-label">TEMP</span>
<span class="info-value">{{ server.cpu_temp ? server.cpu_temp + '°C' : '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">LOAD</span>
<span class="info-value">{{ server.load_percent ? server.load_percent.toFixed(1) + '%' : '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">RAM</span>
<span class="info-value">{{ formatServerMem(server) }}</span>
</div>
<div class="info-row">
<span class="info-label">HDD</span>
<span class="info-value">{{ formatServerDisk(server) }}</span>
</div>
</template>
@@ -73,7 +104,6 @@
</template>
</div>
<!-- 컨테이너 영역 (오른쪽) -->
<div class="container-area" v-if="server.level !== 'offline'">
<div
v-for="container in sortContainers(server.containers)"
@@ -93,24 +123,32 @@
<div class="card-metric">
<span class="label">CPU</span>
<div class="mini-bar">
<div :class="['mini-fill', getContainerCpuLevel(container)]" :style="{ width: (container.cpu_percent || 0) + '%' }"></div>
<div :class="['mini-fill', getContainerCpuLevel(container)]" :style="{ width: Math.min(container.cpu_percent || 0, 100) + '%' }"></div>
</div>
<span class="value">{{ container.cpu_percent?.toFixed(0) || '-' }}%</span>
<span :class="['value', { 'value-changed': isContainerChanged(server.target_id, container.name, 'cpu') }]">
{{ formatCpuPercent(container.cpu_percent) }}
</span>
</div>
<div class="card-metric">
<span class="label">MEM</span>
<div class="mini-bar">
<div :class="['mini-fill', getContainerMemLevel(container)]" :style="{ width: getMemPercent(container) + '%' }"></div>
</div>
<span class="value">{{ formatMemoryShort(container.memory_usage) }}</span>
<span :class="['value', { 'mem-highlight': isMemoryOver1GB(container.memory_usage), 'value-changed': isContainerChanged(server.target_id, container.name, 'mem') }]">
{{ formatMemoryShort(container.memory_usage) }}
</span>
</div>
<div class="card-metric">
<span class="label">RX</span>
<span class="value net">{{ formatNetworkShort(container.network_rx) }}</span>
<span :class="['value', 'net', { 'value-changed': isContainerChanged(server.target_id, container.name, 'rx') }]">
{{ formatNetworkShort(container.network_rx) }}
</span>
</div>
<div class="card-metric">
<span class="label">TX</span>
<span class="value net">{{ formatNetworkShort(container.network_tx) }}</span>
<span :class="['value', 'net', { 'value-changed': isContainerChanged(server.target_id, container.name, 'tx') }]">
{{ formatNetworkShort(container.network_tx) }}
</span>
</div>
</div>
</template>
@@ -119,7 +157,6 @@
</template>
</div>
<!-- 컨테이너 없음 -->
<div class="no-container" v-if="server.containers.length === 0">
<span>컨테이너 없음</span>
</div>
@@ -130,6 +167,8 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
interface ContainerStatus {
name: string
status: string
@@ -145,13 +184,22 @@ interface ContainerStatus {
interface ServerStatus {
target_id: number
server_name: string
server_ip: string
level: string
cpu_percent: number | null
cpu_level: string
memory_percent: number | null
memory_level: string
memory_total: number | null
memory_free: number | null
memory_used: number | null
disk_percent: number | null
disk_level: string
disk_used: number | null
disk_total: number | null
cpu_temp: number | null
load_percent: number | null
uptime_str: string | null
last_collected: string | null
containers: ContainerStatus[]
container_summary: { total: number; normal: number; warning: number; critical: number; stopped: number }
@@ -171,7 +219,112 @@ const emit = defineEmits<{
(e: 'navigate', path: string): void
}>()
const levelPriority: Record<string, number> = { stopped: 3, critical: 2, danger: 2, warning: 1, normal: 0 }
// 이전 값 저장 (변경 감지용)
const prevServerValues = ref<Record<string, Record<string, number | null>>>({})
const prevContainerValues = ref<Record<string, Record<string, number | null>>>({})
const changedKeys = ref<Set<string>>(new Set())
// 값 변경 감지
watch(() => props.servers, (newServers, oldServers) => {
if (!oldServers || oldServers.length === 0) {
// 초기 로드 시 이전 값 저장
newServers.forEach(server => {
prevServerValues.value[server.target_id] = {
cpu: server.cpu_percent,
mem: server.memory_percent,
disk: server.disk_percent
}
server.containers.forEach(c => {
const key = `${server.target_id}-${c.name}`
prevContainerValues.value[key] = {
cpu: c.cpu_percent,
mem: c.memory_usage,
rx: c.network_rx,
tx: c.network_tx
}
})
})
return
}
const newChangedKeys = new Set<string>()
newServers.forEach(server => {
const prev = prevServerValues.value[server.target_id] || {}
// 서버 값 비교
if (prev.cpu !== server.cpu_percent) newChangedKeys.add(`server-${server.target_id}-cpu`)
if (prev.mem !== server.memory_percent) newChangedKeys.add(`server-${server.target_id}-mem`)
if (prev.disk !== server.disk_percent) newChangedKeys.add(`server-${server.target_id}-disk`)
// 현재 값 저장
prevServerValues.value[server.target_id] = {
cpu: server.cpu_percent,
mem: server.memory_percent,
disk: server.disk_percent
}
// 컨테이너 값 비교
server.containers.forEach(c => {
const key = `${server.target_id}-${c.name}`
const prevC = prevContainerValues.value[key] || {}
if (prevC.cpu !== c.cpu_percent) newChangedKeys.add(`container-${key}-cpu`)
if (prevC.mem !== c.memory_usage) newChangedKeys.add(`container-${key}-mem`)
if (prevC.rx !== c.network_rx) newChangedKeys.add(`container-${key}-rx`)
if (prevC.tx !== c.network_tx) newChangedKeys.add(`container-${key}-tx`)
prevContainerValues.value[key] = {
cpu: c.cpu_percent,
mem: c.memory_usage,
rx: c.network_rx,
tx: c.network_tx
}
})
})
changedKeys.value = newChangedKeys
// 1.5초 후 하이라이트 제거
if (newChangedKeys.size > 0) {
setTimeout(() => {
changedKeys.value = new Set()
}, 1500)
}
}, { deep: true })
function isChanged(serverId: number, metric: string): boolean {
return changedKeys.value.has(`server-${serverId}-${metric}`)
}
function isContainerChanged(serverId: number, containerName: string, metric: string): boolean {
return changedKeys.value.has(`container-${serverId}-${containerName}-${metric}`)
}
// 서버 메모리 퍼센트: memory_percent 직접 사용
function calcMemPercent(server: ServerStatus): number {
return server.memory_percent || 0
}
// 서버 메모리 용량 포맷: used/total GB (memory_used는 DB에서 계산된 값)
function formatServerMem(server: ServerStatus): string {
const used = Number(server.memory_used) || 0
const total = Number(server.memory_total) || 0
if (total === 0) return '-'
const usedGB = (used / (1024 * 1024 * 1024)).toFixed(1)
const totalGB = (total / (1024 * 1024 * 1024)).toFixed(1)
return `${usedGB}/${totalGB}G`
}
// 서버 디스크 용량 포맷: used/total GB
function formatServerDisk(server: ServerStatus): string {
const used = Number(server.disk_used) || 0
const total = Number(server.disk_total) || 0
if (total === 0) return '-'
const usedGB = (used / (1024 * 1024 * 1024)).toFixed(0)
const totalGB = (total / (1024 * 1024 * 1024)).toFixed(0)
return `${usedGB}/${totalGB}G`
}
function sortContainers(containers: ContainerStatus[]) {
return [...containers].sort((a, b) => a.name.localeCompare(b.name))
@@ -206,22 +359,38 @@ function getContainerMemLevel(c: ContainerStatus): string {
function getMemPercent(c: ContainerStatus): number {
if (!c.memory_limit || !c.memory_usage) return 0
return (c.memory_usage / c.memory_limit) * 100
return (Number(c.memory_usage) / Number(c.memory_limit)) * 100
}
function formatCpuPercent(value: number | null): string {
if (value === null || value === undefined) return '-'
// 10% 이상이면 정수로, 미만이면 소수점 1자리
if (value >= 10) return `${value.toFixed(0)}%`
if (value >= 1) return `${value.toFixed(1)}%`
if (value >= 0.1) return `${value.toFixed(1)}%`
return `${value.toFixed(2)}%`
}
function formatMemoryShort(bytes: number | null): string {
if (bytes === null || bytes === undefined) return '-'
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}K`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(0)}M`
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)}G`
const numBytes = Number(bytes)
if (numBytes < 1024 * 1024) return `${(numBytes / 1024).toFixed(0)}K`
if (numBytes < 1024 * 1024 * 1024) return `${(numBytes / 1024 / 1024).toFixed(0)}M`
return `${(numBytes / 1024 / 1024 / 1024).toFixed(1)}G`
}
function isMemoryOver1GB(bytes: number | null): boolean {
if (bytes === null || bytes === undefined) return false
return Number(bytes) >= 1024 * 1024 * 1024
}
function formatNetworkShort(bytes: number | null): string {
if (bytes === null || bytes === undefined) return '-'
if (bytes < 1024) return `${bytes}B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}K`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)}M`
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)}G`
const numBytes = Number(bytes)
if (numBytes < 1024) return `${numBytes}B`
if (numBytes < 1024 * 1024) return `${(numBytes / 1024).toFixed(0)}K`
if (numBytes < 1024 * 1024 * 1024) return `${(numBytes / 1024 / 1024).toFixed(1)}M`
return `${(numBytes / 1024 / 1024 / 1024).toFixed(2)}G`
}
function formatTimeAgo(datetime: string | null): string {
@@ -239,81 +408,105 @@ function formatTimeAgo(datetime: string | null): string {
<style scoped>
.server-portlet { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; overflow: hidden; }
.portlet-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 18px; border-bottom: 1px solid var(--border-color); background: var(--bg-tertiary, #f8fafc); }
.portlet-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--text-primary); }
.summary-badges { display: flex; align-items: center; gap: 12px; font-size: 14px; color: var(--text-secondary); }
.portlet-header { display: flex; justify-content: space-between; align-items: center; padding: 18px 24px; border-bottom: 1px solid var(--border-color); background: var(--bg-tertiary, #f8fafc); }
.portlet-title { margin: 0; font-size: 22px; font-weight: 600; color: var(--text-primary); }
.summary-badges { display: flex; align-items: center; gap: 16px; font-size: 17px; color: var(--text-secondary); }
.divider { color: var(--border-color); }
.level-counts { margin-left: 8px; }
.level-counts .lv { margin-right: 6px; }
.level-counts { margin-left: 12px; }
.level-counts .lv { margin-right: 10px; font-size: 16px; }
/* 서버 그리드 - flex-wrap */
.server-grid { display: flex; flex-wrap: wrap; gap: 12px; padding: 16px; align-items: flex-start; }
.server-grid { display: flex; flex-wrap: wrap; gap: 16px; padding: 20px; align-items: flex-start; }
/* 서버 유닛 */
.server-unit { display: flex; background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 10px; overflow: hidden; }
.server-unit.warning { border-left: 3px solid #eab308; }
.server-unit.critical { border-left: 3px solid #f97316; }
.server-unit.danger { border-left: 3px solid #ef4444; }
.server-unit.offline { border-left: 3px solid #6b7280; opacity: 0.7; }
.server-unit { display: flex; background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 12px; overflow: hidden; }
.server-unit.warning { border-left: 5px solid #eab308; }
.server-unit.critical { border-left: 5px solid #f97316; }
.server-unit.danger { border-left: 5px solid #ef4444; }
.server-unit.offline { border-left: 5px solid #6b7280; opacity: 0.7; }
/* 서버 정보 (왼쪽) */
.server-info { width: 150px; min-width: 150px; padding: 12px; border-right: 1px solid var(--border-color); cursor: pointer; }
.server-info { width: 240px; min-width: 240px; padding: 16px; border-right: 1px solid var(--border-color); cursor: pointer; }
.server-info:hover { background: var(--bg-tertiary, #f8fafc); }
.server-name { display: flex; align-items: center; gap: 6px; margin-bottom: 10px; }
.server-name .level-icon { font-size: 12px; flex-shrink: 0; }
.server-name .name { font-size: 15px; font-weight: 600; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.server-name .container-count { font-size: 12px; color: var(--text-muted); flex-shrink: 0; }
.server-name { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.server-name .level-icon { font-size: 16px; flex-shrink: 0; }
.server-name .name-wrap { flex: 1; min-width: 0; overflow: hidden; }
.server-name .name { display: block; font-size: 19px; font-weight: 700; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.server-name .server-ip { display: block; font-size: 12px; color: var(--text-muted); font-family: monospace; margin-top: 2px; }
.server-name .container-count { font-size: 15px; color: var(--text-muted); flex-shrink: 0; }
/* 서버 메트릭 */
.metric-row { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
.metric-label { font-size: 11px; font-weight: 500; color: var(--text-muted); width: 28px; }
.progress-bar { flex: 1; height: 6px; background: var(--bg-tertiary, #e5e7eb); border-radius: 3px; overflow: hidden; }
.progress-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
.metric-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.metric-label { font-size: 14px; font-weight: 600; color: var(--text-muted); width: 42px; }
.progress-bar { flex: 1; height: 10px; background: var(--bg-tertiary, #e5e7eb); border-radius: 5px; overflow: hidden; }
.progress-fill { height: 100%; border-radius: 5px; transition: width 0.3s; }
.progress-fill.normal { background: #22c55e; }
.progress-fill.warning { background: #eab308; }
.progress-fill.critical { background: #f97316; }
.progress-fill.danger { background: #ef4444; }
.metric-value { font-size: 12px; font-weight: 600; width: 24px; text-align: right; }
.metric-value { font-size: 17px; font-weight: 700; width: 44px; text-align: right; transition: all 0.3s; padding: 2px 4px; border-radius: 4px; }
.metric-value.normal { color: #16a34a; }
.metric-value.warning { color: #ca8a04; }
.metric-value.critical { color: #ea580c; }
.metric-value.danger { color: #dc2626; }
.offline-info { text-align: center; padding: 16px 0; color: var(--text-muted); }
.offline-text { font-size: 14px; margin-bottom: 4px; }
.offline-time { font-size: 12px; opacity: 0.7; }
.info-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.info-label { font-size: 14px; font-weight: 600; color: var(--text-muted); width: 42px; }
.info-value { flex: 1; font-size: 14px; font-weight: 600; color: var(--text-secondary); font-family: monospace; text-align: right; }
/* 컨테이너 영역 */
.container-area { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px; align-content: flex-start; min-width: 100px; }
.offline-info { text-align: center; padding: 24px 0; color: var(--text-muted); }
.offline-text { font-size: 18px; margin-bottom: 8px; }
.offline-time { font-size: 15px; opacity: 0.7; }
/* 컨테이너 카드 */
.container-card { width: 200px; padding: 10px; border-radius: 8px; border: 1px solid var(--border-color); background: var(--bg-secondary); cursor: pointer; transition: all 0.15s; overflow: hidden; }
.container-card:hover { transform: translateY(-1px); box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.container-card.normal { background: #f0fdf4; border-color: #86efac; }
.container-card.warning { background: #fefce8; border-color: #fde047; }
.container-card.critical { background: #fff7ed; border-color: #fdba74; }
.container-card.danger { background: #fef2f2; border-color: #fca5a5; }
.container-card.stopped { background: #fef2f2; border-color: #fca5a5; }
.container-area { display: flex; flex-wrap: wrap; gap: 10px; padding: 12px; align-content: flex-start; min-width: 140px; }
.container-card { width: 208px; padding: 10px; border-radius: 8px; border: 1px solid var(--border-color); background: var(--bg-secondary); cursor: pointer; transition: all 0.15s; overflow: hidden; }
.container-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.container-card.normal { background: var(--container-normal-bg, #f0fdf4); border-color: var(--container-normal-border, #86efac); }
.container-card.warning { background: var(--container-warning-bg, #fefce8); border-color: var(--container-warning-border, #fde047); }
.container-card.critical { background: var(--container-critical-bg, #fff7ed); border-color: var(--container-critical-border, #fdba74); }
.container-card.danger { background: var(--container-danger-bg, #fef2f2); border-color: var(--container-danger-border, #fca5a5); }
.container-card.stopped { background: var(--container-danger-bg, #fef2f2); border-color: var(--container-danger-border, #fca5a5); }
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; overflow: hidden; }
.card-name { display: flex; align-items: center; gap: 4px; flex: 1; min-width: 0; overflow: hidden; }
.card-name .card-level { font-size: 10px; flex-shrink: 0; }
.card-name .name { font-size: 13px; font-weight: 600; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card-uptime { font-size: 11px; color: var(--text-muted); flex-shrink: 0; margin-left: 8px; white-space: nowrap; }
.card-name { display: flex; align-items: center; gap: 6px; flex: 1; min-width: 0; overflow: hidden; }
.card-name .card-level { font-size: 12px; flex-shrink: 0; }
.card-name .name { font-size: 15px; font-weight: 700; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card-uptime { font-size: 12px; color: var(--text-muted); flex-shrink: 0; margin-left: 8px; white-space: nowrap; }
.card-metrics { display: flex; flex-wrap: wrap; gap: 4px 8px; }
.card-metric { display: flex; align-items: center; gap: 4px; width: calc(50% - 4px); overflow: hidden; }
.card-metric .label { font-size: 10px; color: var(--text-muted); width: 22px; flex-shrink: 0; }
.mini-bar { flex: 1; height: 5px; background: rgba(0,0,0,0.1); border-radius: 2px; overflow: hidden; min-width: 20px; }
.mini-fill { height: 100%; border-radius: 2px; }
.card-metrics { display: flex; flex-wrap: wrap; gap: 5px 8px; }
.card-metric { display: flex; align-items: center; gap: 4px; width: calc(50% - 4px); overflow: visible; }
.card-metric .label { font-size: 11px; color: var(--text-muted); width: 22px; flex-shrink: 0; font-weight: 500; }
.mini-bar { flex: 1; height: 6px; background: rgba(0,0,0,0.1); border-radius: 3px; overflow: hidden; min-width: 20px; }
.mini-fill { height: 100%; border-radius: 3px; }
.mini-fill.normal { background: #22c55e; }
.mini-fill.warning { background: #eab308; }
.mini-fill.critical { background: #f97316; }
.mini-fill.danger { background: #ef4444; }
.card-metric .value { font-size: 11px; font-weight: 500; color: var(--text-secondary); width: 36px; text-align: right; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; }
.card-metric .value { font-size: 13px; font-weight: 700; color: var(--text-secondary); min-width: 44px; text-align: right; flex-shrink: 0; transition: all 0.3s; padding: 1px 2px; border-radius: 3px; }
.card-metric .value.mem-highlight { color: #dc2626; font-weight: 800; }
.card-metric .value.net { color: var(--text-muted); font-weight: 600; }
.card-stopped { font-size: 12px; color: var(--text-muted); font-style: italic; text-align: center; padding: 6px 0; }
.card-stopped { font-size: 15px; color: var(--text-muted); font-style: italic; text-align: center; padding: 10px 0; }
.no-container { font-size: 13px; color: var(--text-muted); padding: 12px; display: flex; align-items: center; justify-content: center; }
.no-container { font-size: 16px; color: var(--text-muted); padding: 16px; display: flex; align-items: center; justify-content: center; }
/* 값 변경 시 하이라이트 애니메이션 - 글자색 변경 */
@keyframes valueFlash {
0% {
color: #3b82f6;
text-shadow: 0 0 8px rgba(59, 130, 246, 0.8);
transform: scale(1.15);
}
50% {
color: #60a5fa;
text-shadow: 0 0 4px rgba(59, 130, 246, 0.5);
transform: scale(1.08);
}
100% {
text-shadow: none;
transform: scale(1);
}
}
.value-changed {
animation: valueFlash 1.5s ease-out;
}
</style>

View File

@@ -1,69 +1,72 @@
<template>
<aside class="sidebar">
<aside :class="['sidebar', { collapsed: isCollapsed }]">
<div class="sidebar-header">
<div class="sidebar-logo">
<span class="logo-icon">📡</span>
<button class="toggle-btn" @click="toggle" :title="isCollapsed ? '메뉴 열기' : '메뉴 닫기'">
<span class="hamburger"></span>
</button>
<div class="sidebar-logo" v-show="!isCollapsed">
<span>OSOLIT Monitor</span>
</div>
</div>
<nav class="sidebar-nav">
<NuxtLink to="/" class="nav-item" :class="{ active: route.path === '/' }">
<NuxtLink to="/" class="nav-item" :class="{ active: route.path === '/' }" :title="isCollapsed ? '대시보드' : ''">
<span class="icon">📊</span>
<span>대시보드</span>
<span class="label">대시보드</span>
</NuxtLink>
<div class="nav-group-title">네트워크</div>
<div class="nav-group-title" v-show="!isCollapsed">네트워크</div>
<div class="nav-divider" v-show="isCollapsed"></div>
<NuxtLink to="/network/pubnet" class="nav-item nav-sub-item" :class="{ active: route.path === '/network/pubnet' }">
<NuxtLink to="/network/pubnet" class="nav-item nav-sub-item" :class="{ active: route.path === '/network/pubnet' }" :title="isCollapsed ? 'Public Network' : ''">
<span class="icon">🌐</span>
<span>Public Network</span>
<span class="label">Public Network</span>
</NuxtLink>
<NuxtLink to="/network/privnet" class="nav-item nav-sub-item" :class="{ active: route.path === '/network/privnet' }">
<NuxtLink to="/network/privnet" class="nav-item nav-sub-item" :class="{ active: route.path === '/network/privnet' }" :title="isCollapsed ? 'Private Network' : ''">
<span class="icon">🔒</span>
<span>Private Network</span>
<span class="label">Private Network</span>
</NuxtLink>
<div class="nav-group-title">서버</div>
<div class="nav-group-title" v-show="!isCollapsed">서버</div>
<div class="nav-divider" v-show="isCollapsed"></div>
<NuxtLink to="/server/list" class="nav-item nav-sub-item" :class="{ active: route.path === '/server/list' }">
<NuxtLink to="/server/list" class="nav-item nav-sub-item" :class="{ active: route.path === '/server/list' }" :title="isCollapsed ? 'Server Targets' : ''">
<span class="icon">🖥</span>
<span>Server Targets</span>
<span class="label">Server Targets</span>
</NuxtLink>
<NuxtLink to="/server/history" class="nav-item nav-sub-item" :class="{ active: route.path === '/server/history' }">
<NuxtLink to="/server/history" class="nav-item nav-sub-item" :class="{ active: route.path === '/server/history' }" :title="isCollapsed ? 'Server Status' : ''">
<span class="icon">📈</span>
<span>Server Status</span>
<span class="label">Server Status</span>
</NuxtLink>
<div class="nav-group-title">이상감지</div>
<NuxtLink to="/anomaly/short-term" class="nav-item nav-sub-item" :class="{ active: route.path === '/anomaly/short-term' }">
<span class="icon"></span>
<span>단기 변화율</span>
</NuxtLink>
<NuxtLink to="/anomaly/zscore" class="nav-item nav-sub-item" :class="{ active: route.path === '/anomaly/zscore' }">
<span class="icon">📊</span>
<span>Z-Score 분석</span>
</NuxtLink>
<NuxtLink to="/anomaly/baseline" class="nav-item nav-sub-item" :class="{ active: route.path === '/anomaly/baseline' }">
<span class="icon">🕐</span>
<span>시간대별 베이스라인</span>
</NuxtLink>
<NuxtLink to="/anomaly/trend" class="nav-item nav-sub-item" :class="{ active: route.path === '/anomaly/trend' }">
<span class="icon">📉</span>
<span>추세 분석</span>
</NuxtLink>
<div class="nav-group-title">설정</div>
<NuxtLink to="/settings/thresholds" class="nav-item nav-sub-item" :class="{ active: route.path === '/settings/thresholds' }">
<NuxtLink to="/settings/thresholds" class="nav-item nav-sub-item" :class="{ active: route.path === '/settings/thresholds' }" :title="isCollapsed ? 'Thresholds' : ''">
<span class="icon"></span>
<span>임계값 설정</span>
<span class="label">Thresholds</span>
</NuxtLink>
<div class="nav-group-title" v-show="!isCollapsed">이상감지</div>
<div class="nav-divider" v-show="isCollapsed"></div>
<NuxtLink to="/anomaly/short-term" class="nav-item nav-sub-item" :class="{ active: route.path === '/anomaly/short-term' }" :title="isCollapsed ? '단기 변화율' : ''">
<span class="icon"></span>
<span class="label">단기 변화율</span>
</NuxtLink>
<NuxtLink to="/anomaly/zscore" class="nav-item nav-sub-item" :class="{ active: route.path === '/anomaly/zscore' }" :title="isCollapsed ? 'Z-Score 분석' : ''">
<span class="icon">📊</span>
<span class="label">Z-Score 분석</span>
</NuxtLink>
<NuxtLink to="/anomaly/baseline" class="nav-item nav-sub-item" :class="{ active: route.path === '/anomaly/baseline' }" :title="isCollapsed ? '시간대별 베이스라인' : ''">
<span class="icon">🕐</span>
<span class="label">시간대별 베이스라인</span>
</NuxtLink>
<NuxtLink to="/anomaly/trend" class="nav-item nav-sub-item" :class="{ active: route.path === '/anomaly/trend' }" :title="isCollapsed ? '추세 분석' : ''">
<span class="icon">📉</span>
<span class="label">추세 분석</span>
</NuxtLink>
</nav>
</aside>
@@ -71,4 +74,129 @@
<script setup lang="ts">
const route = useRoute()
const { isCollapsed, toggle } = useSidebar()
</script>
<style scoped>
.sidebar {
width: 200px;
min-width: 200px;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
transition: all 0.2s ease;
}
.sidebar.collapsed {
width: 50px;
min-width: 50px;
}
.sidebar-header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
border-bottom: 1px solid var(--border-color);
}
.toggle-btn {
background: none;
border: none;
cursor: pointer;
padding: 6px 8px;
border-radius: 6px;
transition: background 0.2s;
flex-shrink: 0;
}
.toggle-btn:hover {
background: var(--bg-tertiary, #f1f5f9);
}
.hamburger {
font-size: 18px;
color: var(--text-primary);
}
.sidebar-logo {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
}
.sidebar-nav {
flex: 1;
padding: 8px;
overflow-y: auto;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 8px;
color: var(--text-secondary);
text-decoration: none;
transition: all 0.15s;
margin-bottom: 2px;
}
.collapsed .nav-item {
justify-content: center;
padding: 10px 8px;
}
.nav-item:hover {
background: var(--bg-tertiary, #f1f5f9);
color: var(--text-primary);
}
.nav-item.active {
background: var(--accent-bg, #eff6ff);
color: var(--accent-color, #3b82f6);
font-weight: 500;
}
.nav-item .icon {
font-size: 16px;
flex-shrink: 0;
}
.nav-item .label {
font-size: 13px;
white-space: nowrap;
overflow: hidden;
}
.collapsed .nav-item .label {
display: none;
}
.nav-group-title {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
padding: 12px 12px 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.nav-divider {
height: 1px;
background: var(--border-color);
margin: 8px 6px;
}
.nav-sub-item {
padding-left: 16px;
}
.collapsed .nav-sub-item {
padding-left: 8px;
}
</style>

View File

@@ -0,0 +1,24 @@
const sidebarCollapsed = ref(true) // 기본값: 닫힌 상태
export function useSidebar() {
const isCollapsed = computed(() => sidebarCollapsed.value)
function toggle() {
sidebarCollapsed.value = !sidebarCollapsed.value
}
function open() {
sidebarCollapsed.value = false
}
function close() {
sidebarCollapsed.value = true
}
return {
isCollapsed,
toggle,
open,
close
}
}

View File

@@ -48,6 +48,9 @@
icon="🔒"
:status="privnetStatus"
/>
<LocationStatsPortlet
:locations="locationStats"
/>
</div>
</div>
</main>
@@ -83,11 +86,21 @@ let loadingStartTime = 0
const pubnetStatus = ref<any>(null)
const privnetStatus = ref<any>(null)
const serverDashboard = ref<any>(null)
const locationStats = ref<any[]>([])
// WebSocket
let ws: WebSocket | null = null
let timeInterval: ReturnType<typeof window.setInterval> | null = null
async function fetchLocationStats() {
try {
const data = await $fetch('/api/server/location-stats')
locationStats.value = data as any[]
} catch (err) {
console.error('Failed to fetch location stats:', err)
}
}
function formatTime(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
@@ -162,6 +175,7 @@ function connectWebSocket() {
if (msg.type === 'server') {
serverDashboard.value = msg.data
fetchLocationStats()
}
} catch (err) {
console.error('[WS] Parse error:', err)
@@ -214,6 +228,7 @@ function navigateTo(path: string) {
onMounted(() => {
connectWebSocket()
updateCurrentTime()
fetchLocationStats()
timeInterval = window.setInterval(updateCurrentTime, 1000)
})
@@ -239,7 +254,7 @@ onUnmounted(() => {
.dashboard-layout { display: flex; gap: 16px; height: 100%; }
.server-section { flex: 9; min-width: 0; }
.network-section { flex: 1; min-width: 130px; max-width: 160px; display: flex; flex-direction: column; gap: 12px; }
.network-section { flex: 1; min-width: 150px; max-width: 200px; display: flex; flex-direction: column; gap: 8px; }
.connection-status { position: fixed; bottom: 16px; right: 16px; padding: 8px 12px; background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 8px; font-size: 12px; color: var(--text-muted); }
.connection-status.connected { color: #16a34a; }

View File

@@ -405,23 +405,29 @@ async function fetchSnapshots() {
load: validLoad.length ? (validLoad.reduce((a: number, b: number) => a + b, 0) / validLoad.length).toFixed(1) : '-'
}
// Memory/Swap 라인 차트
// Memory/Swap 라인 차트 - memory_percent 직접 사용
const memData = data.map((d: any) => d.memory_percent || 0)
const swapData = data.map((d: any) => d.swap_percent || 0)
const swapData = data.map((d: any) => {
const total = Number(d.swap_total) || 0
const used = Number(d.swap_used) || 0
if (total === 0) return 0
return (used / total) * 100
})
memChart = createLineChart(memChartRef.value!, labels, [
{ label: 'Memory %', data: memData, borderColor: chartColors[1] },
{ label: 'Swap %', data: swapData, borderColor: chartColors[2] }
])
// 평균 계산 (Memory, Swap) + 사용량/전체용량
// 평균 계산 (Memory, Swap) + 사용량/전체용량 (BigInt는 문자열로 반환되므로 Number로 변환)
// 메모리 사용량 = memory_used (DB에서 계산된 값)
const validMem = memData.filter((v: number) => v > 0)
const validSwap = swapData.filter((v: number) => v >= 0)
const memUsedData = data.map((d: any) => d.memory_used || 0).filter((v: number) => v > 0)
const memUsedData = data.map((d: any) => Number(d.memory_used) || 0).filter((v: number) => v > 0)
const avgMemUsedGB = memUsedData.length ? (memUsedData.reduce((a: number, b: number) => a + b, 0) / memUsedData.length / (1024 * 1024 * 1024)).toFixed(1) : '-'
const memTotalGB = data[0]?.memory_total ? (data[0].memory_total / (1024 * 1024 * 1024)).toFixed(1) : '-'
const swapUsedData = data.map((d: any) => d.swap_used || 0).filter((v: number) => v >= 0)
const memTotalGB = data[0]?.memory_total ? (Number(data[0].memory_total) / (1024 * 1024 * 1024)).toFixed(1) : '-'
const swapUsedData = data.map((d: any) => Number(d.swap_used) || 0).filter((v: number) => v >= 0)
const avgSwapUsedGB = swapUsedData.length ? (swapUsedData.reduce((a: number, b: number) => a + b, 0) / swapUsedData.length / (1024 * 1024 * 1024)).toFixed(1) : '0'
const swapTotalGB = data[0]?.swap_total ? (data[0].swap_total / (1024 * 1024 * 1024)).toFixed(1) : '0'
const swapTotalGB = data[0]?.swap_total ? (Number(data[0].swap_total) / (1024 * 1024 * 1024)).toFixed(1) : '0'
memAvg.value = {
mem: validMem.length ? (validMem.reduce((a: number, b: number) => a + b, 0) / validMem.length).toFixed(1) : '-',
swap: validSwap.length ? (validSwap.reduce((a: number, b: number) => a + b, 0) / validSwap.length).toFixed(1) : '-',
@@ -449,10 +455,10 @@ async function fetchDisks() {
diskChart?.destroy()
diskChart = createLineChart(diskChartRef.value!, timeLabels, datasets)
// 평균 계산 (전체 디스크) + 사용량/전체용량
// 평균 계산 (전체 디스크) + 사용량/전체용량 (BigInt는 문자열로 반환되므로 Number로 변환)
const allPercents = data.map((d: any) => d.disk_percent || 0).filter((v: number) => v > 0)
const allUsed = data.map((d: any) => d.disk_used || 0).filter((v: number) => v > 0)
const allTotal = data.map((d: any) => d.disk_total || 0).filter((v: number) => v > 0)
const allUsed = data.map((d: any) => Number(d.disk_used) || 0).filter((v: number) => v > 0)
const allTotal = data.map((d: any) => Number(d.disk_total) || 0).filter((v: number) => v > 0)
const avgUsedGB = allUsed.length ? (allUsed.reduce((a: number, b: number) => a + b, 0) / allUsed.length / (1024 * 1024 * 1024)).toFixed(1) : '-'
const avgTotalGB = allTotal.length ? (allTotal.reduce((a: number, b: number) => a + b, 0) / allTotal.length / (1024 * 1024 * 1024)).toFixed(1) : '-'
diskAvg.value = {
@@ -489,14 +495,14 @@ async function fetchContainers() {
const cpuValues = containerRows.map((d: any) => d.cpu_percent || 0)
const cpuAvgVal = cpuValues.length ? (cpuValues.reduce((a: number, b: number) => a + b, 0) / cpuValues.length).toFixed(1) : '0'
// Memory 평균 (bytes -> MB)
const memValues = containerRows.map((d: any) => (d.memory_usage || 0) / 1024 / 1024)
// Memory 평균 (bytes -> MB, BigInt는 문자열로 반환되므로 Number로 변환)
const memValues = containerRows.map((d: any) => (Number(d.memory_usage) || 0) / 1024 / 1024)
const memAvgVal = memValues.length ? (memValues.reduce((a: number, b: number) => a + b, 0) / memValues.length) : 0
const memLimit = latest.memory_limit ? (latest.memory_limit / 1024 / 1024 / 1024).toFixed(1) + ' GB' : '-'
const memLimit = latest.memory_limit ? (Number(latest.memory_limit) / 1024 / 1024 / 1024).toFixed(1) + ' GB' : '-'
// Network 평균 (bytes/s -> KB/s)
const rxValues = containerRows.map((d: any) => (d.network_rx || 0) / 1024)
const txValues = containerRows.map((d: any) => (d.network_tx || 0) / 1024)
const rxValues = containerRows.map((d: any) => (Number(d.network_rx) || 0) / 1024)
const txValues = containerRows.map((d: any) => (Number(d.network_tx) || 0) / 1024)
const rxAvgVal = rxValues.length ? (rxValues.reduce((a: number, b: number) => a + b, 0) / rxValues.length) : 0
const txAvgVal = txValues.length ? (txValues.reduce((a: number, b: number) => a + b, 0) / txValues.length) : 0
@@ -526,22 +532,22 @@ async function fetchContainers() {
if (!containerCharts[name]) containerCharts[name] = {}
// CPU 차트 (0-100%)
// CPU 차트 (0-200% - 컨테이너는 멀티코어로 100% 초과 가능)
if (refs.cpu) {
const cpuData = containerRows.map((d: any) => d.cpu_percent || 0)
containerCharts[name].cpu = createLineChart(refs.cpu, timeLabels, [{ label: 'CPU %', data: cpuData, borderColor: '#3b82f6' }], 100)
containerCharts[name].cpu = createLineChart(refs.cpu, timeLabels, [{ label: 'CPU %', data: cpuData, borderColor: '#3b82f6' }], 200)
}
// Memory 차트 (MB 단위) - 자동 스케일
// Memory 차트 (MB 단위) - 자동 스케일 (BigInt 문자열 변환)
if (refs.mem) {
const memData = containerRows.map((d: any) => (d.memory_usage || 0) / 1024 / 1024)
const memData = containerRows.map((d: any) => (Number(d.memory_usage) || 0) / 1024 / 1024)
containerCharts[name].mem = createLineChart(refs.mem, timeLabels, [{ label: 'Memory MB', data: memData, borderColor: '#22c55e' }], null)
}
// Network 차트 (KB/s 단위) - 자동 스케일
// Network 차트 (KB/s 단위) - 자동 스케일 (BigInt 문자열 변환)
if (refs.net) {
const rxData = containerRows.map((d: any) => (d.network_rx || 0) / 1024)
const txData = containerRows.map((d: any) => (d.network_tx || 0) / 1024)
const rxData = containerRows.map((d: any) => (Number(d.network_rx) || 0) / 1024)
const txData = containerRows.map((d: any) => (Number(d.network_tx) || 0) / 1024)
containerCharts[name].net = createLineChart(refs.net, timeLabels, [
{ label: 'RX KB/s', data: rxData, borderColor: '#06b6d4' },
{ label: 'TX KB/s', data: txData, borderColor: '#f59e0b' }
@@ -755,32 +761,32 @@ onUnmounted(() => {
.section-title { margin: 0 0 16px 0; font-size: 16px; font-weight: 700; color: var(--text-primary); }
.container-cards { display: flex; flex-direction: column; gap: 16px; }
.container-card { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 10px; padding: 14px; }
.container-card.running { background: #f0fdf4; border-color: #86efac; }
.container-card.exited { background: #fef2f2; border-color: #fca5a5; }
.container-card.paused { background: #fffbeb; border-color: #fcd34d; }
.container-card.restarting { background: #fef3c7; border-color: #f59e0b; }
.container-card.running { background: var(--container-normal-bg, #f0fdf4); border-color: var(--container-normal-border, #86efac); }
.container-card.exited { background: var(--container-danger-bg, #fef2f2); border-color: var(--container-danger-border, #fca5a5); }
.container-card.paused { background: var(--container-warning-bg, #fffbeb); border-color: var(--container-warning-border, #fcd34d); }
.container-card.restarting { background: var(--container-critical-bg, #fef3c7); border-color: var(--container-critical-border, #f59e0b); }
.container-header { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--border-color); }
.container-card.running .container-header { border-color: #86efac; }
.container-card.exited .container-header { border-color: #fca5a5; }
.container-card.paused .container-header { border-color: #fcd34d; }
.container-card.restarting .container-header { border-color: #f59e0b; }
.container-card.running .container-header { border-color: var(--container-normal-border, #86efac); }
.container-card.exited .container-header { border-color: var(--container-danger-border, #fca5a5); }
.container-card.paused .container-header { border-color: var(--container-warning-border, #fcd34d); }
.container-card.restarting .container-header { border-color: var(--container-critical-border, #f59e0b); }
.container-name { font-size: 16px; font-weight: 700; color: var(--btn-primary-bg); background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.container-card.exited .container-name { background: linear-gradient(135deg, #dc2626 0%, #f97316 100%); -webkit-background-clip: text; background-clip: text; }
.container-card.paused .container-name { background: linear-gradient(135deg, #d97706 0%, #eab308 100%); -webkit-background-clip: text; background-clip: text; }
.container-card.restarting .container-name { background: linear-gradient(135deg, #ea580c 0%, #facc15 100%); -webkit-background-clip: text; background-clip: text; }
.container-status { font-size: 12px; padding: 3px 10px; border-radius: 12px; font-weight: 500; }
.container-status.running { background: #dcfce7; color: #166534; }
.container-status.exited { background: #fee2e2; color: #991b1b; }
.container-status.paused { background: #fef3c7; color: #92400e; }
.container-status.restarting { background: #ffedd5; color: #c2410c; }
.container-status.unknown { background: #e2e8f0; color: #475569; }
.container-status.running { background: var(--container-normal-bg, #dcfce7); color: var(--container-status-running, #166534); }
.container-status.exited { background: var(--container-danger-bg, #fee2e2); color: var(--container-status-exited, #991b1b); }
.container-status.paused { background: var(--container-warning-bg, #fef3c7); color: var(--container-status-paused, #92400e); }
.container-status.restarting { background: var(--container-critical-bg, #ffedd5); color: var(--container-status-restarting, #c2410c); }
.container-status.unknown { background: var(--bg-tertiary, #e2e8f0); color: var(--text-muted); }
.container-uptime { font-size: 12px; color: var(--text-muted); margin-left: auto; }
.container-charts { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.container-chart-box { background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 8px; padding: 10px; }
.container-card.running .container-chart-box { background: #ffffff; border-color: #bbf7d0; }
.container-card.exited .container-chart-box { background: #ffffff; border-color: #fecaca; }
.container-card.paused .container-chart-box { background: #ffffff; border-color: #fde68a; }
.container-card.restarting .container-chart-box { background: #ffffff; border-color: #fed7aa; }
.container-card.running .container-chart-box { background: var(--bg-primary); border-color: var(--container-normal-border, #bbf7d0); }
.container-card.exited .container-chart-box { background: var(--bg-primary); border-color: var(--container-danger-border, #fecaca); }
.container-card.paused .container-chart-box { background: var(--bg-primary); border-color: var(--container-warning-border, #fde68a); }
.container-card.restarting .container-chart-box { background: var(--bg-primary); border-color: var(--container-critical-border, #fed7aa); }
.container-chart-box .chart-header { margin-bottom: 6px; }
.container-chart-box .chart-title { font-size: 12px; font-weight: 600; color: var(--text-primary); }
.container-chart-box .chart-avg { font-size: 11px; padding: 2px 8px; }

View File

@@ -71,6 +71,9 @@
style="width: 80px;"
min="10"
/>
<select v-model="newTarget.physical_location" class="input-field" style="width: 110px;">
<option v-for="loc in locationOptions" :key="loc" :value="loc">{{ loc }}</option>
</select>
<label class="checkbox-label">
<input type="checkbox" v-model="newTarget.is_active" />
활성
@@ -93,7 +96,8 @@
<th style="width: 100px;">서버명</th>
<th style="width: 120px;">IP</th>
<th>Glances URL</th>
<th style="width: 70px;">주기()</th>
<th style="width: 70px;">주기</th>
<th style="width: 100px;">위치</th>
<th style="width: 50px;">상태</th>
<th style="width: 120px;">관리</th>
</tr>
@@ -117,6 +121,12 @@
<input v-if="editingId === target.target_id" v-model.number="editTarget.collect_interval" type="number" class="edit-input" min="10" />
<span v-else>{{ target.collect_interval }}</span>
</td>
<td>
<select v-if="editingId === target.target_id" v-model="editTarget.physical_location" class="edit-input">
<option v-for="loc in locationOptions" :key="loc" :value="loc">{{ loc }}</option>
</select>
<span v-else>{{ target.physical_location || '운영서버실' }}</span>
</td>
<td>
<label v-if="editingId === target.target_id" class="checkbox-label">
<input type="checkbox" v-model="editTarget.is_active" />
@@ -151,6 +161,7 @@ interface ServerTarget {
glances_url: string
is_active: number
collect_interval: number
physical_location: string
}
interface SchedulerStatus {
@@ -159,6 +170,11 @@ interface SchedulerStatus {
total_targets: number
}
const locationOptions = [
'운영서버실', '개발서버실', '터보사무실', '백업실',
'기타1', '기타2', '기타3', '기타4'
]
const targets = ref<ServerTarget[]>([])
const currentTime = ref('')
const editingId = ref<number | null>(null)
@@ -173,7 +189,8 @@ const newTarget = ref({
server_ip: '',
glances_url: '',
is_active: true,
collect_interval: 60
collect_interval: 60,
physical_location: '운영서버실'
})
const editTarget = ref({
@@ -181,7 +198,8 @@ const editTarget = ref({
server_ip: '',
glances_url: '',
is_active: true,
collect_interval: 60
collect_interval: 60,
physical_location: '운영서버실'
})
const canAdd = computed(() =>
@@ -243,7 +261,7 @@ async function addTarget() {
method: 'POST',
body: newTarget.value
})
newTarget.value = { server_name: '', server_ip: '', glances_url: '', is_active: true, collect_interval: 60 }
newTarget.value = { server_name: '', server_ip: '', glances_url: '', is_active: true, collect_interval: 60, physical_location: '운영서버실' }
await fetchTargets()
await fetchSchedulerStatus()
} catch (err) {
@@ -258,7 +276,8 @@ function startEdit(target: ServerTarget) {
server_ip: target.server_ip,
glances_url: target.glances_url,
is_active: !!target.is_active,
collect_interval: target.collect_interval || 60
collect_interval: target.collect_interval || 60,
physical_location: target.physical_location || '운영서버실'
}
}

View File

@@ -138,9 +138,20 @@ const thresholds = ref(JSON.parse(JSON.stringify(defaultThresholds)))
async function fetchThresholds() {
try {
const data = await $fetch('/api/settings/thresholds')
const data = await $fetch('/api/settings/thresholds') as any
if (data) {
thresholds.value = data as typeof defaultThresholds
// 기본값과 병합 (API 응답이 불완전할 수 있음)
thresholds.value = {
server: {
cpu: { ...defaultThresholds.server.cpu, ...data.server?.cpu },
memory: { ...defaultThresholds.server.memory, ...data.server?.memory },
disk: { ...defaultThresholds.server.disk, ...data.server?.disk }
},
container: {
cpu: { ...defaultThresholds.container.cpu, ...data.container?.cpu },
memory: { ...defaultThresholds.container.memory, ...data.container?.memory }
}
}
}
} catch (err) {
console.error('Failed to fetch thresholds:', err)

View File

@@ -1,6 +1,10 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2024-12-25',
devServer: {
host: '0.0.0.0',
port: 4055
},
devtools: { enabled: true },
// SSR 비활성화 (SPA 모드)
@@ -43,44 +47,25 @@ export default defineNuxtConfig({
// 서버 설정
nitro: {
preset: 'node-server',
// WebSocket 실험적 기능 활성화
experimental: {
websocket: true
},
// 네이티브 모듈은 번들링하지 않고 외부 모듈로 처리
externals: {
inline: []
},
// rollup에서 external로 처리
rollupConfig: {
external: ['pg']
},
// 플러그인 등록
plugins: [
'~/backend/plugins/pubnet-init.ts',
'~/backend/plugins/privnet-init.ts'
]
},
// Vite 설정 (네이티브 모듈)
// Vite 설정
vite: {
optimizeDeps: {
exclude: ['pg']
}
},
// 환경변수 설정
runtimeConfig: {
// 서버 전용 (NUXT_로 시작하는 환경변수 자동 로드)
dbHost: process.env.DB_HOST || 'localhost',
dbPort: process.env.DB_PORT || '5432',
dbName: process.env.DB_NAME || 'osolit_monitor',
dbUser: process.env.DB_USER || 'postgres',
dbPassword: process.env.DB_PASSWORD || '',
autoStartScheduler: process.env.AUTO_START_SCHEDULER || 'false',
// 클라이언트 공개
public: {
nodeEnv: process.env.NODE_ENV || 'development'
}
}
})

802
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,16 +11,16 @@
"postinstall": "nuxt prepare"
},
"dependencies": {
"chart.js": "^4.5.1",
"nuxt": "^3.13.0",
"pg": "^8.11.0",
"vue": "^3.4.0",
"vue-router": "^4.4.0"
"chart.js": "^4.4.7",
"nuxt": "^3.15.4",
"pg": "^8.13.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@nuxt/devtools": "latest",
"@types/node": "^20.0.0",
"@types/pg": "^8.11.0",
"typescript": "^5.3.0"
"@nuxt/devtools": "^2.3.1",
"@types/node": "^22.10.5",
"@types/pg": "^8.11.10",
"typescript": "^5.7.2"
}
}