Compare commits
10 Commits
e6370601d4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7062a81d33 | |||
| 79a7fc1550 | |||
| bf10a1d000 | |||
| 8bcb5c7ff7 | |||
| 248eb6e6e0 | |||
| 474e20eb5c | |||
| bd9d2a703f | |||
| 240e096bd8 | |||
| e49c962cee | |||
| 99db3acc20 |
506
README.md
506
README.md
@@ -1,284 +1,336 @@
|
|||||||
# OSOLIT Monitor
|
# OSOLIT Monitor
|
||||||
|
|
||||||
시스템 헬스체크 & 모니터링 대시보드 (Nuxt 3 + SQLite + SSE)
|
시스템 헬스체크 & 모니터링 대시보드 (Nuxt 3 + PostgreSQL + WebSocket)
|
||||||
|
|
||||||
## 주요 기능
|
## 주요 기능
|
||||||
|
|
||||||
### 📊 실시간 대시보드 (웹소켓/SSE 기반)
|
### 📊 실시간 대시보드
|
||||||
- **네트워크 점검** 포틀릿 (단일, 핵심)
|
- **서버 상태 모니터링**: 다중 서버 헬스체크 및 상태 표시
|
||||||
- Server-Sent Events (SSE)로 실시간 업데이트
|
- **네트워크 점검**: Public(외부망) / Private(내부망) 분리 모니터링
|
||||||
- 점검 시간과 상태 즉시 반영
|
- **위치별 통계**: 서버 위치(Location)별 현황 요약
|
||||||
- 연결 상태 표시 (🟢 연결됨 / 🔴 연결 끊김)
|
- **WebSocket 기반 실시간 업데이트**
|
||||||
- 향후 추가 포틀릿 확장 가능
|
- **다크/라이트 테마 지원**
|
||||||
|
|
||||||
### 🔄 적응형 스케줄러
|
### 🌐 네트워크 모니터링
|
||||||
- **정상 시**: 5분 간격 점검
|
- **Public Network (pubnet)**: 외부망 연결 상태 점검
|
||||||
- **장애 시**: 1분 간격 점검 (빠른 복구 감지)
|
- **Private Network (privnet)**: 내부망 연결 상태 점검
|
||||||
- 자동으로 간격 조정
|
- 응답 시간 측정 및 에러 메시지 상세 표시
|
||||||
- DB 시간 기준으로 로그 저장
|
|
||||||
|
|
||||||
### 🌐 네트워크 점검
|
### 🖥️ 서버 모니터링
|
||||||
- World Time API를 통한 외부 네트워크 상태 확인
|
- 다중 서버 대상 관리
|
||||||
- 응답 시간 측정
|
- 서버별 헬스체크 이력 조회
|
||||||
- 에러 메시지 상세 표시
|
- 위치별 서버 현황 통계
|
||||||
|
- 스케줄러 제어 (시작/중지)
|
||||||
|
|
||||||
### 💾 데이터 저장
|
### 📈 이상탐지 (Anomaly Detection)
|
||||||
- **SQLite 파일 DB** (`database/health_logs.db`)
|
- **Baseline 분석**: 정상 기준선 대비 이상 탐지
|
||||||
- 별도 DB 서버 설치 불필요
|
- **Z-Score 분석**: 통계적 이상치 탐지
|
||||||
- 상태: UP(정상) / DOWN(장애)
|
- **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. 의존성 설치
|
### 1. 의존성 설치
|
||||||
```bash
|
```bash
|
||||||
npm install
|
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
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
서버 시작 시:
|
- 개발 서버: http://localhost:4055
|
||||||
- DB 자동 초기화
|
- WebSocket: ws://localhost:4055/_ws
|
||||||
- 즉시 첫 네트워크 점검 실행
|
|
||||||
- SSE 서버 시작
|
|
||||||
- 개발 서버: http://localhost:3000
|
|
||||||
|
|
||||||
### 3. 프로덕션 빌드
|
### 4. 프로덕션 빌드
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
npm run preview
|
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**
|
|
||||||
|
|
||||||
#### 네트워크 점검 포틀릿
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────┐
|
||||||
│ 🌐 네트워크 점검 ✓ │ ← 상태 아이콘
|
│ 📊 대시보드 2024-12-25 14:30:00 │
|
||||||
├─────────────────────────────────┤
|
├─────────────────────────────────────────────────────────┤
|
||||||
│ [ UP ] │ ← 상태 뱃지
|
│ ┌─────────────────────────────────┐ ┌─────────────────┐ │
|
||||||
│ │
|
│ │ │ │ 🌐 Public ✓ │ │
|
||||||
│ 점검 시간: 12/25 14:35:20 │ ← 마지막 점검 시간
|
│ │ 서버 현황 (90%) │ │ [UP] │ │
|
||||||
│ 응답 시간: 123ms │ ← 응답 속도
|
│ │ │ ├─────────────────┤ │
|
||||||
├─────────────────────────────────┤
|
│ │ 서버 목록 & 상태 │ │ 🔒 Private ✓ │ │
|
||||||
│ 5분 전 ⏱️ 5분 간격 │ ← 상대시간 & 점검주기
|
│ │ │ │ [UP] │ │
|
||||||
└─────────────────────────────────┘
|
│ │ │ ├─────────────────┤ │
|
||||||
|
│ │ │ │ 📍 위치별 통계 │ │
|
||||||
|
│ └─────────────────────────────────┘ └─────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
🟢 연결됨
|
||||||
```
|
```
|
||||||
|
|
||||||
**실시간 업데이트:**
|
### 서버 관리 (`/server/list`, `/server/history`)
|
||||||
- 점검 완료 시 즉시 화면 반영
|
- 모니터링 대상 서버 목록 관리
|
||||||
- 별도 새로고침 불필요
|
- 서버별 헬스체크 이력 조회
|
||||||
- SSE 연결 상태 표시
|
|
||||||
|
|
||||||
**향후 확장:**
|
### 이상탐지 (`/anomaly/*`)
|
||||||
- 데이터베이스 점검 포틀릿
|
- Baseline / Z-Score / Trend / Short-term 분석
|
||||||
- 서버 상태 포틀릿
|
- 차트 기반 시각화
|
||||||
- 디스크 용량 포틀릿
|
|
||||||
- 기타 모니터링 항목
|
|
||||||
|
|
||||||
### 로그 & 통계
|
### 설정 (`/settings/*`)
|
||||||
**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
|
|
||||||
```
|
|
||||||
|
|
||||||
## API 엔드포인트
|
## API 엔드포인트
|
||||||
|
|
||||||
### GET /api/dashboard
|
### 네트워크
|
||||||
초기 대시보드 데이터
|
| Method | Endpoint | 설명 |
|
||||||
|
|--------|----------|------|
|
||||||
|
| GET | `/api/network/pubnet/status` | 외부망 상태 |
|
||||||
|
| GET | `/api/network/privnet/status` | 내부망 상태 |
|
||||||
|
|
||||||
**Response:**
|
### 서버
|
||||||
```json
|
| Method | Endpoint | 설명 |
|
||||||
{
|
|--------|----------|------|
|
||||||
"networkStatus": {
|
| GET | `/api/server/status` | 서버 현황 |
|
||||||
"id": 123,
|
| GET | `/api/server/location-stats` | 위치별 통계 |
|
||||||
"service_name": "Network",
|
| GET | `/api/server/targets` | 모니터링 대상 목록 |
|
||||||
"status": "UP",
|
| GET | `/api/server/history` | 헬스체크 이력 |
|
||||||
"check_time": "2024-12-25T12:34:56.000Z",
|
|
||||||
"response_time": 123,
|
### 이상탐지
|
||||||
"error_message": null
|
| Method | Endpoint | 설명 |
|
||||||
},
|
|--------|----------|------|
|
||||||
"lastUpdate": "2024-12-25T12:34:56.789Z"
|
| 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
|
| `pubnet-scheduler` | 외부망 점검 | 적응형 (정상 5분, 장애 1분) |
|
||||||
통계 조회
|
| `privnet-scheduler` | 내부망 점검 | 적응형 (정상 5분, 장애 1분) |
|
||||||
|
| `server-scheduler` | 서버 점검 | 설정 가능 |
|
||||||
## 데이터베이스 스키마
|
|
||||||
|
|
||||||
### 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;
|
|
||||||
```
|
|
||||||
|
|
||||||
## 브라우저 호환성
|
## 브라우저 호환성
|
||||||
|
|
||||||
SSE 지원 브라우저:
|
|
||||||
- ✅ Chrome
|
- ✅ Chrome
|
||||||
- ✅ Firefox
|
- ✅ Firefox
|
||||||
- ✅ Safari
|
- ✅ Safari
|
||||||
- ✅ Edge
|
- ✅ Edge
|
||||||
- ❌ IE (미지원)
|
- ❌ IE (미지원)
|
||||||
|
|
||||||
## 다음 단계
|
## 개발 로드맵
|
||||||
|
|
||||||
- [ ] 데이터베이스 점검 포틀릿 추가
|
- [x] 서버 모니터링 포틀릿
|
||||||
- [ ] 서버 상태 점검 포틀릿 추가
|
- [x] 네트워크 모니터링 (Public/Private)
|
||||||
- [ ] 디스크 용량 점검 포틀릿 추가
|
- [x] 이상탐지 기능
|
||||||
- [ ] Jenkins Job 통합
|
- [x] Chart.js 차트
|
||||||
|
- [x] 다크/라이트 테마
|
||||||
|
- [x] Docker 지원
|
||||||
- [ ] 장애 알림 (이메일/Slack)
|
- [ ] 장애 알림 (이메일/Slack)
|
||||||
- [ ] 차트/그래프
|
|
||||||
- [ ] 오래된 로그 자동 삭제
|
- [ ] 오래된 로그 자동 삭제
|
||||||
|
- [ ] Jenkins Job 통합
|
||||||
|
|
||||||
## 기술 스택
|
## 라이선스
|
||||||
|
|
||||||
- **Frontend**: Nuxt 3, Vue 3
|
Private - TurboSoft
|
||||||
- **Backend**: Nitro (Node.js)
|
|
||||||
- **Database**: SQLite (better-sqlite3)
|
|
||||||
- **Real-time**: Server-Sent Events (SSE)
|
|
||||||
- **Scheduler**: 적응형 (정상 5분, 장애 1분)
|
|
||||||
- **External API**: World Time API
|
|
||||||
|
|
||||||
## 특징
|
|
||||||
|
|
||||||
✅ **실시간 업데이트** - SSE로 즉시 반영
|
|
||||||
✅ **적응형 스케줄링** - 장애 시 빠른 점검
|
|
||||||
✅ **단순한 구조** - 네트워크 점검 중심
|
|
||||||
✅ **확장 가능** - 포틀릿 추가 용이
|
|
||||||
✅ **파일 DB** - 별도 서버 불필요
|
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ async function getServerDashboard() {
|
|||||||
|
|
||||||
// 서버 목록
|
// 서버 목록
|
||||||
const servers = await query(`
|
const servers = await query(`
|
||||||
SELECT target_id, server_name, is_active
|
SELECT target_id, server_name, server_ip, is_active
|
||||||
FROM server_targets
|
FROM server_targets
|
||||||
WHERE is_active = 1
|
WHERE is_active = 1
|
||||||
ORDER BY server_name
|
ORDER BY server_name
|
||||||
@@ -176,16 +176,19 @@ async function getServerDashboard() {
|
|||||||
for (const server of servers) {
|
for (const server of servers) {
|
||||||
// 최신 스냅샷
|
// 최신 스냅샷
|
||||||
const snapshot = await queryOne(`
|
const snapshot = await queryOne(`
|
||||||
SELECT cpu_percent, memory_percent, collected_at
|
SELECT cpu_percent, memory_percent, memory_total, memory_free, memory_used,
|
||||||
|
cpu_temp, load_percent, uptime_str, collected_at
|
||||||
FROM server_snapshots
|
FROM server_snapshots
|
||||||
WHERE target_id = $1 AND is_online = 1
|
WHERE target_id = $1 AND is_online = 1
|
||||||
ORDER BY collected_at DESC
|
ORDER BY collected_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`, [server.target_id])
|
`, [server.target_id])
|
||||||
|
|
||||||
// 최신 디스크 사용률 (최대값)
|
// 최신 디스크 사용률 (최대값) + 용량
|
||||||
const diskData = await queryOne(`
|
const diskData = await queryOne(`
|
||||||
SELECT MAX(disk_percent) as disk_percent
|
SELECT MAX(disk_percent) as disk_percent,
|
||||||
|
SUM(disk_used) as disk_used,
|
||||||
|
SUM(disk_total) as disk_total
|
||||||
FROM server_disks
|
FROM server_disks
|
||||||
WHERE target_id = $1
|
WHERE target_id = $1
|
||||||
AND collected_at = (SELECT MAX(collected_at) FROM server_disks WHERE target_id = $1)
|
AND collected_at = (SELECT MAX(collected_at) FROM server_disks WHERE target_id = $1)
|
||||||
@@ -209,6 +212,7 @@ async function getServerDashboard() {
|
|||||||
|
|
||||||
if (!isOffline && snapshot) {
|
if (!isOffline && snapshot) {
|
||||||
cpuLevel = getLevel(Number(snapshot.cpu_percent), thresholds.server?.cpu || { warning: 70, critical: 85, danger: 95 })
|
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 })
|
memLevel = getLevel(Number(snapshot.memory_percent), thresholds.server?.memory || { warning: 80, critical: 90, danger: 95 })
|
||||||
diskLevel = getLevel(Number(diskData?.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])
|
serverLevel = getHighestLevel([cpuLevel, memLevel, diskLevel])
|
||||||
@@ -286,13 +290,22 @@ async function getServerDashboard() {
|
|||||||
serverStatuses.push({
|
serverStatuses.push({
|
||||||
target_id: server.target_id,
|
target_id: server.target_id,
|
||||||
server_name: server.server_name,
|
server_name: server.server_name,
|
||||||
|
server_ip: server.server_ip,
|
||||||
level: serverLevel,
|
level: serverLevel,
|
||||||
cpu_percent: snapshot?.cpu_percent ?? null,
|
cpu_percent: snapshot?.cpu_percent ?? null,
|
||||||
cpu_level: cpuLevel,
|
cpu_level: cpuLevel,
|
||||||
memory_percent: snapshot?.memory_percent ?? null,
|
memory_percent: snapshot?.memory_percent ?? null, // snapshot의 memory_percent 직접 사용
|
||||||
memory_level: memLevel,
|
memory_level: memLevel,
|
||||||
|
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_percent: diskData?.disk_percent ?? null,
|
||||||
disk_level: diskLevel,
|
disk_level: diskLevel,
|
||||||
|
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,
|
last_collected: lastCollected,
|
||||||
containers: containers,
|
containers: containers,
|
||||||
container_summary: containerSummary
|
container_summary: containerSummary
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { query, queryOne, execute, getPool } from './db'
|
import { query, queryOne, execute, getPool } from './db'
|
||||||
|
import { ofetch } from 'ofetch'
|
||||||
|
|
||||||
interface ServerTarget {
|
interface ServerTarget {
|
||||||
target_id: number
|
target_id: number
|
||||||
@@ -20,16 +21,20 @@ function timestamp(): string {
|
|||||||
return new Date().toLocaleString('sv-SE', { timeZone: 'Asia/Seoul' }).replace('T', ' ')
|
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> {
|
async function fetchGlancesApi(baseUrl: string, endpoint: string, version: string): Promise<any> {
|
||||||
|
const url = `${baseUrl}/api/${version}/${endpoint}`
|
||||||
try {
|
try {
|
||||||
const url = `${baseUrl}/api/${version}/${endpoint}`
|
const data = await ofetch(url, {
|
||||||
const response = await fetch(url, {
|
timeout: 5000,
|
||||||
signal: AbortSignal.timeout(5000)
|
retry: 0
|
||||||
})
|
})
|
||||||
if (!response.ok) return null
|
return data
|
||||||
return await response.json()
|
} catch (err: any) {
|
||||||
} catch (err) {
|
// 404, timeout 등 에러는 조용히 null 반환
|
||||||
|
if (err.statusCode !== 404) {
|
||||||
|
console.error(`[fetchGlancesApi] ${url} - Error: ${err.message}`)
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,6 +89,8 @@ async function detectAnomalies(targetId: number, serverName: string) {
|
|||||||
|
|
||||||
const currCpuAvg = currSnapshots.reduce((sum, s) => sum + (s.cpu_percent || 0), 0) / currSnapshots.length
|
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
|
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 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 prevMemAvg = prevSnapshots.reduce((sum, s) => sum + (s.memory_percent || 0), 0) / prevSnapshots.length
|
||||||
|
|
||||||
@@ -152,7 +159,7 @@ async function detectAnomalies(targetId: number, serverName: string) {
|
|||||||
if (hourSnapshots.length >= 10) {
|
if (hourSnapshots.length >= 10) {
|
||||||
const current = hourSnapshots[0]
|
const current = hourSnapshots[0]
|
||||||
const currCpu = current.cpu_percent ?? 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 cpuValues = hourSnapshots.map(s => s.cpu_percent ?? 0)
|
||||||
const memValues = hourSnapshots.map(s => s.memory_percent ?? 0)
|
const memValues = hourSnapshots.map(s => s.memory_percent ?? 0)
|
||||||
@@ -244,7 +251,7 @@ async function detectAnomalies(targetId: number, serverName: string) {
|
|||||||
|
|
||||||
if (baselineData.length >= 5 && currentSnapshot) {
|
if (baselineData.length >= 5 && currentSnapshot) {
|
||||||
const currCpu = currentSnapshot.cpu_percent ?? 0
|
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 cpuValues = baselineData.map(s => s.cpu_percent ?? 0)
|
||||||
const memValues = baselineData.map(s => s.memory_percent ?? 0)
|
const memValues = baselineData.map(s => s.memory_percent ?? 0)
|
||||||
@@ -485,12 +492,12 @@ async function collectServerData(target: ServerTarget) {
|
|||||||
quicklook?.cpu_name || null,
|
quicklook?.cpu_name || null,
|
||||||
quicklook?.cpu_number || quicklook?.cpu_log_core || cpu?.cpucore || null,
|
quicklook?.cpu_number || quicklook?.cpu_log_core || cpu?.cpucore || null,
|
||||||
cpu?.total ?? quicklook?.cpu ?? null,
|
cpu?.total ?? quicklook?.cpu ?? null,
|
||||||
mem?.total || null,
|
mem?.total ? Math.round(mem.total) : null, // bigint: 정수 변환
|
||||||
mem?.used || null,
|
mem?.total && mem?.percent ? Math.round(mem.total * mem.percent / 100) : null, // bigint: 정수 변환
|
||||||
mem?.free || null,
|
mem?.free ? Math.round(mem.free) : null, // bigint: 정수 변환
|
||||||
mem?.percent || null,
|
mem?.percent || null,
|
||||||
memswap?.total || null,
|
memswap?.total ? Math.round(memswap.total) : null, // bigint: 정수 변환
|
||||||
memswap?.used || null,
|
memswap?.used ? Math.round(memswap.used) : null, // bigint: 정수 변환
|
||||||
memswap?.percent || null,
|
memswap?.percent || null,
|
||||||
isOnline ? 1 : 0,
|
isOnline ? 1 : 0,
|
||||||
apiVersion,
|
apiVersion,
|
||||||
@@ -516,8 +523,8 @@ async function collectServerData(target: ServerTarget) {
|
|||||||
disk.device_name || null,
|
disk.device_name || null,
|
||||||
disk.mnt_point || null,
|
disk.mnt_point || null,
|
||||||
disk.fs_type || null,
|
disk.fs_type || null,
|
||||||
disk.size || null,
|
disk.size ? Math.round(disk.size) : null, // bigint: 정수 변환
|
||||||
disk.used || null,
|
disk.used ? Math.round(disk.used) : null, // bigint: 정수 변환
|
||||||
disk.percent || null,
|
disk.percent || null,
|
||||||
now
|
now
|
||||||
])
|
])
|
||||||
@@ -528,6 +535,26 @@ async function collectServerData(target: ServerTarget) {
|
|||||||
if (Array.isArray(docker) && docker.length > 0) {
|
if (Array.isArray(docker) && docker.length > 0) {
|
||||||
console.log(`[${now}] 🐳 [${target.server_name}] container 저장 (${docker.length}개 컨테이너)`)
|
console.log(`[${now}] 🐳 [${target.server_name}] container 저장 (${docker.length}개 컨테이너)`)
|
||||||
for (const container of docker) {
|
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(`
|
await execute(`
|
||||||
INSERT INTO server_containers (
|
INSERT INTO server_containers (
|
||||||
target_id, docker_id, container_name, container_image,
|
target_id, docker_id, container_name, container_image,
|
||||||
@@ -540,12 +567,10 @@ async function collectServerData(target: ServerTarget) {
|
|||||||
container.name || null,
|
container.name || null,
|
||||||
Array.isArray(container.image) ? container.image.join(', ') : container.image || null,
|
Array.isArray(container.image) ? container.image.join(', ') : container.image || null,
|
||||||
container.status || null,
|
container.status || null,
|
||||||
container.cpu?.total ?? container.cpu_percent ?? null,
|
cpuPercent,
|
||||||
container.memory?.usage || container.memory_usage || null,
|
memoryUsage ? Math.round(memoryUsage) : null, // bigint: 정수 변환
|
||||||
container.memory?.limit || container.memory_limit || null,
|
memoryLimit ? Math.round(memoryLimit) : null, // bigint: 정수 변환
|
||||||
container.memory?.usage && container.memory?.limit
|
memoryPercent,
|
||||||
? (container.memory.usage / container.memory.limit * 100)
|
|
||||||
: container.memory_percent ?? null,
|
|
||||||
container.uptime || null,
|
container.uptime || null,
|
||||||
container.network?.rx ?? container.network_rx ?? null,
|
container.network?.rx ?? container.network_rx ?? null,
|
||||||
container.network?.tx ?? container.network_tx ?? null,
|
container.network?.tx ?? container.network_tx ?? null,
|
||||||
@@ -558,6 +583,11 @@ async function collectServerData(target: ServerTarget) {
|
|||||||
if (Array.isArray(network) && network.length > 0) {
|
if (Array.isArray(network) && network.length > 0) {
|
||||||
console.log(`[${now}] 🌐 [${target.server_name}] network 저장 (${network.length}개 인터페이스)`)
|
console.log(`[${now}] 🌐 [${target.server_name}] network 저장 (${network.length}개 인터페이스)`)
|
||||||
for (const iface of network) {
|
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(`
|
await execute(`
|
||||||
INSERT INTO server_networks (
|
INSERT INTO server_networks (
|
||||||
target_id, interface_name, bytes_recv, bytes_sent,
|
target_id, interface_name, bytes_recv, bytes_sent,
|
||||||
@@ -567,10 +597,10 @@ async function collectServerData(target: ServerTarget) {
|
|||||||
`, [
|
`, [
|
||||||
target.target_id,
|
target.target_id,
|
||||||
iface.interface_name || null,
|
iface.interface_name || null,
|
||||||
iface.bytes_recv || iface.cumulative_rx || null,
|
bytesRecv ? Math.round(bytesRecv) : null, // bigint: 정수 변환
|
||||||
iface.bytes_sent || iface.cumulative_tx || null,
|
bytesSent ? Math.round(bytesSent) : null, // bigint: 정수 변환
|
||||||
iface.packets_recv || null,
|
packetsRecv ? Math.round(packetsRecv) : null, // bigint: 정수 변환
|
||||||
iface.packets_sent || null,
|
packetsSent ? Math.round(packetsSent) : null, // bigint: 정수 변환
|
||||||
iface.bytes_recv_rate_per_sec || iface.rx || iface.bytes_recv_rate || null,
|
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.bytes_sent_rate_per_sec || iface.tx || iface.bytes_sent_rate || null,
|
||||||
iface.is_up ? 1 : 0,
|
iface.is_up ? 1 : 0,
|
||||||
|
|||||||
BIN
database/osolit-monitor.db
Normal file
BIN
database/osolit-monitor.db
Normal file
Binary file not shown.
@@ -35,27 +35,36 @@
|
|||||||
<div class="server-info" @dblclick="goToServerStatus(server.target_id)">
|
<div class="server-info" @dblclick="goToServerStatus(server.target_id)">
|
||||||
<div class="server-name">
|
<div class="server-name">
|
||||||
<span class="level-icon">{{ levelIcon(server.level) }}</span>
|
<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>
|
<span class="container-count" v-if="server.level !== 'offline'">📦{{ server.container_summary.total }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="server.level !== 'offline'">
|
<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">
|
<div class="metric-row">
|
||||||
<span class="metric-label">CPU</span>
|
<span class="metric-label">CPU</span>
|
||||||
<div class="progress-bar">
|
<div class="progress-bar">
|
||||||
<div :class="['progress-fill', server.cpu_level]" :style="{ width: (server.cpu_percent || 0) + '%' }"></div>
|
<div :class="['progress-fill', server.cpu_level]" :style="{ width: (server.cpu_percent || 0) + '%' }"></div>
|
||||||
</div>
|
</div>
|
||||||
<span :class="['metric-value', server.cpu_level, { 'value-changed': isChanged(server.target_id, 'cpu') }]">
|
<span :class="['metric-value', server.cpu_level, { 'value-changed': isChanged(server.target_id, 'cpu') }]">
|
||||||
{{ server.cpu_percent?.toFixed(0) || '-' }}
|
{{ server.cpu_percent?.toFixed(0) || '-' }}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-row">
|
<div class="metric-row">
|
||||||
<span class="metric-label">MEM</span>
|
<span class="metric-label">MEM</span>
|
||||||
<div class="progress-bar">
|
<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>
|
</div>
|
||||||
<span :class="['metric-value', server.memory_level, { 'value-changed': isChanged(server.target_id, 'mem') }]">
|
<span :class="['metric-value', server.memory_level, { 'value-changed': isChanged(server.target_id, 'mem') }]">
|
||||||
{{ server.memory_percent?.toFixed(0) || '-' }}
|
{{ calcMemPercent(server).toFixed(0) }}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-row">
|
<div class="metric-row">
|
||||||
@@ -64,9 +73,27 @@
|
|||||||
<div :class="['progress-fill', server.disk_level]" :style="{ width: (server.disk_percent || 0) + '%' }"></div>
|
<div :class="['progress-fill', server.disk_level]" :style="{ width: (server.disk_percent || 0) + '%' }"></div>
|
||||||
</div>
|
</div>
|
||||||
<span :class="['metric-value', server.disk_level, { 'value-changed': isChanged(server.target_id, 'disk') }]">
|
<span :class="['metric-value', server.disk_level, { 'value-changed': isChanged(server.target_id, 'disk') }]">
|
||||||
{{ server.disk_percent?.toFixed(0) || '-' }}
|
{{ server.disk_percent?.toFixed(0) || '-' }}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -99,7 +126,7 @@
|
|||||||
<div :class="['mini-fill', getContainerCpuLevel(container)]" :style="{ width: Math.min(container.cpu_percent || 0, 100) + '%' }"></div>
|
<div :class="['mini-fill', getContainerCpuLevel(container)]" :style="{ width: Math.min(container.cpu_percent || 0, 100) + '%' }"></div>
|
||||||
</div>
|
</div>
|
||||||
<span :class="['value', { 'value-changed': isContainerChanged(server.target_id, container.name, 'cpu') }]">
|
<span :class="['value', { 'value-changed': isContainerChanged(server.target_id, container.name, 'cpu') }]">
|
||||||
{{ container.cpu_percent?.toFixed(0) || '-' }}%
|
{{ formatCpuPercent(container.cpu_percent) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-metric">
|
<div class="card-metric">
|
||||||
@@ -157,13 +184,22 @@ interface ContainerStatus {
|
|||||||
interface ServerStatus {
|
interface ServerStatus {
|
||||||
target_id: number
|
target_id: number
|
||||||
server_name: string
|
server_name: string
|
||||||
|
server_ip: string
|
||||||
level: string
|
level: string
|
||||||
cpu_percent: number | null
|
cpu_percent: number | null
|
||||||
cpu_level: string
|
cpu_level: string
|
||||||
memory_percent: number | null
|
memory_percent: number | null
|
||||||
memory_level: string
|
memory_level: string
|
||||||
|
memory_total: number | null
|
||||||
|
memory_free: number | null
|
||||||
|
memory_used: number | null
|
||||||
disk_percent: number | null
|
disk_percent: number | null
|
||||||
disk_level: string
|
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
|
last_collected: string | null
|
||||||
containers: ContainerStatus[]
|
containers: ContainerStatus[]
|
||||||
container_summary: { total: number; normal: number; warning: number; critical: number; stopped: number }
|
container_summary: { total: number; normal: number; warning: number; critical: number; stopped: number }
|
||||||
@@ -265,6 +301,31 @@ function isContainerChanged(serverId: number, containerName: string, metric: str
|
|||||||
return changedKeys.value.has(`container-${serverId}-${containerName}-${metric}`)
|
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[]) {
|
function sortContainers(containers: ContainerStatus[]) {
|
||||||
return [...containers].sort((a, b) => a.name.localeCompare(b.name))
|
return [...containers].sort((a, b) => a.name.localeCompare(b.name))
|
||||||
}
|
}
|
||||||
@@ -301,6 +362,15 @@ function getMemPercent(c: ContainerStatus): number {
|
|||||||
return (Number(c.memory_usage) / Number(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 {
|
function formatMemoryShort(bytes: number | null): string {
|
||||||
if (bytes === null || bytes === undefined) return '-'
|
if (bytes === null || bytes === undefined) return '-'
|
||||||
const numBytes = Number(bytes)
|
const numBytes = Number(bytes)
|
||||||
@@ -353,12 +423,14 @@ function formatTimeAgo(datetime: string | null): string {
|
|||||||
.server-unit.danger { border-left: 5px solid #ef4444; }
|
.server-unit.danger { border-left: 5px solid #ef4444; }
|
||||||
.server-unit.offline { border-left: 5px solid #6b7280; opacity: 0.7; }
|
.server-unit.offline { border-left: 5px solid #6b7280; opacity: 0.7; }
|
||||||
|
|
||||||
.server-info { width: 200px; min-width: 200px; padding: 16px; 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-info:hover { background: var(--bg-tertiary, #f8fafc); }
|
||||||
|
|
||||||
.server-name { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
|
.server-name { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
|
||||||
.server-name .level-icon { font-size: 16px; flex-shrink: 0; }
|
.server-name .level-icon { font-size: 16px; flex-shrink: 0; }
|
||||||
.server-name .name { font-size: 19px; font-weight: 700; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
.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; }
|
.server-name .container-count { font-size: 15px; color: var(--text-muted); flex-shrink: 0; }
|
||||||
|
|
||||||
.metric-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
.metric-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
||||||
@@ -369,19 +441,23 @@ function formatTimeAgo(datetime: string | null): string {
|
|||||||
.progress-fill.warning { background: #eab308; }
|
.progress-fill.warning { background: #eab308; }
|
||||||
.progress-fill.critical { background: #f97316; }
|
.progress-fill.critical { background: #f97316; }
|
||||||
.progress-fill.danger { background: #ef4444; }
|
.progress-fill.danger { background: #ef4444; }
|
||||||
.metric-value { font-size: 17px; font-weight: 700; width: 36px; text-align: right; transition: all 0.3s; padding: 2px 4px; border-radius: 4px; }
|
.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.normal { color: #16a34a; }
|
||||||
.metric-value.warning { color: #ca8a04; }
|
.metric-value.warning { color: #ca8a04; }
|
||||||
.metric-value.critical { color: #ea580c; }
|
.metric-value.critical { color: #ea580c; }
|
||||||
.metric-value.danger { color: #dc2626; }
|
.metric-value.danger { color: #dc2626; }
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
|
||||||
.offline-info { text-align: center; padding: 24px 0; color: var(--text-muted); }
|
.offline-info { text-align: center; padding: 24px 0; color: var(--text-muted); }
|
||||||
.offline-text { font-size: 18px; margin-bottom: 8px; }
|
.offline-text { font-size: 18px; margin-bottom: 8px; }
|
||||||
.offline-time { font-size: 15px; opacity: 0.7; }
|
.offline-time { font-size: 15px; opacity: 0.7; }
|
||||||
|
|
||||||
.container-area { display: flex; flex-wrap: wrap; gap: 12px; padding: 14px; align-content: flex-start; min-width: 140px; }
|
.container-area { display: flex; flex-wrap: wrap; gap: 10px; padding: 12px; align-content: flex-start; min-width: 140px; }
|
||||||
|
|
||||||
.container-card { width: 260px; padding: 14px; border-radius: 10px; border: 1px solid var(--border-color); background: var(--bg-secondary); cursor: pointer; transition: all 0.15s; overflow: hidden; }
|
.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: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.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.warning { background: var(--container-warning-bg, #fefce8); border-color: var(--container-warning-border, #fde047); }
|
||||||
@@ -389,22 +465,22 @@ function formatTimeAgo(datetime: string | null): string {
|
|||||||
.container-card.danger { background: var(--container-danger-bg, #fef2f2); border-color: var(--container-danger-border, #fca5a5); }
|
.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); }
|
.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: 12px; overflow: hidden; }
|
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; overflow: hidden; }
|
||||||
.card-name { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; overflow: hidden; }
|
.card-name { display: flex; align-items: center; gap: 6px; flex: 1; min-width: 0; overflow: hidden; }
|
||||||
.card-name .card-level { font-size: 14px; flex-shrink: 0; }
|
.card-name .card-level { font-size: 12px; flex-shrink: 0; }
|
||||||
.card-name .name { font-size: 17px; font-weight: 700; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.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: 14px; color: var(--text-muted); flex-shrink: 0; margin-left: 12px; 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: 8px 12px; }
|
.card-metrics { display: flex; flex-wrap: wrap; gap: 5px 8px; }
|
||||||
.card-metric { display: flex; align-items: center; gap: 8px; width: calc(50% - 6px); overflow: hidden; }
|
.card-metric { display: flex; align-items: center; gap: 4px; width: calc(50% - 4px); overflow: visible; }
|
||||||
.card-metric .label { font-size: 13px; color: var(--text-muted); width: 28px; flex-shrink: 0; font-weight: 500; }
|
.card-metric .label { font-size: 11px; color: var(--text-muted); width: 22px; flex-shrink: 0; font-weight: 500; }
|
||||||
.mini-bar { flex: 1; height: 8px; background: rgba(0,0,0,0.1); border-radius: 4px; overflow: hidden; min-width: 28px; }
|
.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: 4px; }
|
.mini-fill { height: 100%; border-radius: 3px; }
|
||||||
.mini-fill.normal { background: #22c55e; }
|
.mini-fill.normal { background: #22c55e; }
|
||||||
.mini-fill.warning { background: #eab308; }
|
.mini-fill.warning { background: #eab308; }
|
||||||
.mini-fill.critical { background: #f97316; }
|
.mini-fill.critical { background: #f97316; }
|
||||||
.mini-fill.danger { background: #ef4444; }
|
.mini-fill.danger { background: #ef4444; }
|
||||||
.card-metric .value { font-size: 15px; font-weight: 700; color: var(--text-secondary); width: 50px; text-align: right; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; transition: all 0.3s; padding: 2px 4px; border-radius: 4px; }
|
.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.mem-highlight { color: #dc2626; font-weight: 800; }
|
||||||
.card-metric .value.net { color: var(--text-muted); font-weight: 600; }
|
.card-metric .value.net { color: var(--text-muted); font-weight: 600; }
|
||||||
|
|
||||||
@@ -412,24 +488,25 @@ function formatTimeAgo(datetime: string | null): string {
|
|||||||
|
|
||||||
.no-container { font-size: 16px; color: var(--text-muted); padding: 16px; 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 {
|
@keyframes valueFlash {
|
||||||
0% {
|
0% {
|
||||||
background-color: rgba(59, 130, 246, 0.6);
|
color: #3b82f6;
|
||||||
transform: scale(1.1);
|
text-shadow: 0 0 8px rgba(59, 130, 246, 0.8);
|
||||||
|
transform: scale(1.15);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
background-color: rgba(59, 130, 246, 0.3);
|
color: #60a5fa;
|
||||||
transform: scale(1.05);
|
text-shadow: 0 0 4px rgba(59, 130, 246, 0.5);
|
||||||
|
transform: scale(1.08);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
background-color: transparent;
|
text-shadow: none;
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.value-changed {
|
.value-changed {
|
||||||
animation: valueFlash 1.5s ease-out;
|
animation: valueFlash 1.5s ease-out;
|
||||||
border-radius: 4px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -405,24 +405,24 @@ async function fetchSnapshots() {
|
|||||||
load: validLoad.length ? (validLoad.reduce((a: number, b: number) => a + b, 0) / validLoad.length).toFixed(1) : '-'
|
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 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, [
|
memChart = createLineChart(memChartRef.value!, labels, [
|
||||||
{ label: 'Memory %', data: memData, borderColor: chartColors[1] },
|
{ label: 'Memory %', data: memData, borderColor: chartColors[1] },
|
||||||
{ label: 'Swap %', data: swapData, borderColor: chartColors[2] }
|
{ label: 'Swap %', data: swapData, borderColor: chartColors[2] }
|
||||||
])
|
])
|
||||||
|
|
||||||
// 평균 계산 (Memory, Swap) + 사용량/전체용량 (BigInt는 문자열로 반환되므로 Number로 변환)
|
// 평균 계산 (Memory, Swap) + 사용량/전체용량 (BigInt는 문자열로 반환되므로 Number로 변환)
|
||||||
// 메모리 사용량 = total - free (free가 있으면 사용, 없으면 used 사용)
|
// 메모리 사용량 = memory_used (DB에서 계산된 값)
|
||||||
const validMem = memData.filter((v: number) => v > 0)
|
const validMem = memData.filter((v: number) => v > 0)
|
||||||
const validSwap = swapData.filter((v: number) => v >= 0)
|
const validSwap = swapData.filter((v: number) => v >= 0)
|
||||||
const memUsedData = data.map((d: any) => {
|
const memUsedData = data.map((d: any) => Number(d.memory_used) || 0).filter((v: number) => v > 0)
|
||||||
const total = Number(d.memory_total) || 0
|
|
||||||
const free = Number(d.memory_free) || 0
|
|
||||||
const used = Number(d.memory_used) || 0
|
|
||||||
return free > 0 ? (total - free) : used
|
|
||||||
}).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 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 ? (Number(data[0].memory_total) / (1024 * 1024 * 1024)).toFixed(1) : '-'
|
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 swapUsedData = data.map((d: any) => Number(d.swap_used) || 0).filter((v: number) => v >= 0)
|
||||||
|
|||||||
15
package-lock.json
generated
15
package-lock.json
generated
@@ -50,7 +50,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@@ -3294,7 +3293,6 @@
|
|||||||
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
|
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -3543,7 +3541,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz",
|
||||||
"integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==",
|
"integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.28.5",
|
"@babel/parser": "^7.28.5",
|
||||||
"@vue/compiler-core": "3.5.26",
|
"@vue/compiler-core": "3.5.26",
|
||||||
@@ -3714,7 +3711,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -4059,7 +4055,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -4161,7 +4156,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||||
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -4239,7 +4233,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
||||||
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
|
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"consola": "^3.2.3"
|
"consola": "^3.2.3"
|
||||||
}
|
}
|
||||||
@@ -7645,7 +7638,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.102.0.tgz",
|
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.102.0.tgz",
|
||||||
"integrity": "sha512-xMiyHgr2FZsphQ12ZCsXRvSYzmKXCm1ejmyG4GDZIiKOmhyt5iKtWq0klOfFsEQ6jcgbwrUdwcCVYzr1F+h5og==",
|
"integrity": "sha512-xMiyHgr2FZsphQ12ZCsXRvSYzmKXCm1ejmyG4GDZIiKOmhyt5iKtWq0klOfFsEQ6jcgbwrUdwcCVYzr1F+h5og==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/types": "^0.102.0"
|
"@oxc-project/types": "^0.102.0"
|
||||||
},
|
},
|
||||||
@@ -7829,7 +7821,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||||
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.9.1",
|
"pg-connection-string": "^2.9.1",
|
||||||
"pg-pool": "^3.10.1",
|
"pg-pool": "^3.10.1",
|
||||||
@@ -7962,7 +7953,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -8714,7 +8704,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
|
||||||
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
|
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
@@ -9575,7 +9564,6 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -10021,7 +10009,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
||||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -10378,7 +10365,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
|
||||||
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
|
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.26",
|
"@vue/compiler-dom": "3.5.26",
|
||||||
"@vue/compiler-sfc": "3.5.26",
|
"@vue/compiler-sfc": "3.5.26",
|
||||||
@@ -10415,7 +10401,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||||
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
|
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/devtools-api": "^6.6.4"
|
"@vue/devtools-api": "^6.6.4"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user