시스템 모니터
This commit is contained in:
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Database
|
||||
database/*.db
|
||||
database/*.db-shm
|
||||
database/*.db-wal
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
285
README.md
285
README.md
@@ -1 +1,284 @@
|
||||
1
|
||||
# OSOLIT Monitor
|
||||
|
||||
시스템 헬스체크 & 모니터링 대시보드 (Nuxt 3 + SQLite + SSE)
|
||||
|
||||
## 주요 기능
|
||||
|
||||
### 📊 실시간 대시보드 (웹소켓/SSE 기반)
|
||||
- **네트워크 점검** 포틀릿 (단일, 핵심)
|
||||
- Server-Sent Events (SSE)로 실시간 업데이트
|
||||
- 점검 시간과 상태 즉시 반영
|
||||
- 연결 상태 표시 (🟢 연결됨 / 🔴 연결 끊김)
|
||||
- 향후 추가 포틀릿 확장 가능
|
||||
|
||||
### 🔄 적응형 스케줄러
|
||||
- **정상 시**: 5분 간격 점검
|
||||
- **장애 시**: 1분 간격 점검 (빠른 복구 감지)
|
||||
- 자동으로 간격 조정
|
||||
- DB 시간 기준으로 로그 저장
|
||||
|
||||
### 🌐 네트워크 점검
|
||||
- World Time API를 통한 외부 네트워크 상태 확인
|
||||
- 응답 시간 측정
|
||||
- 에러 메시지 상세 표시
|
||||
|
||||
### 💾 데이터 저장
|
||||
- **SQLite 파일 DB** (`database/health_logs.db`)
|
||||
- 별도 DB 서버 설치 불필요
|
||||
- 상태: UP(정상) / DOWN(장애)
|
||||
|
||||
## 설치 및 실행
|
||||
|
||||
### 1. 의존성 설치
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. 개발 서버 실행
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
서버 시작 시:
|
||||
- DB 자동 초기화
|
||||
- 즉시 첫 네트워크 점검 실행
|
||||
- SSE 서버 시작
|
||||
- 개발 서버: http://localhost:3000
|
||||
|
||||
### 3. 프로덕션 빌드
|
||||
```bash
|
||||
npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## 화면 구성
|
||||
|
||||
### 홈 (실시간 대시보드)
|
||||
**http://localhost:3000**
|
||||
|
||||
#### 네트워크 점검 포틀릿
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ 🌐 네트워크 점검 ✓ │ ← 상태 아이콘
|
||||
├─────────────────────────────────┤
|
||||
│ [ UP ] │ ← 상태 뱃지
|
||||
│ │
|
||||
│ 점검 시간: 12/25 14:35:20 │ ← 마지막 점검 시간
|
||||
│ 응답 시간: 123ms │ ← 응답 속도
|
||||
├─────────────────────────────────┤
|
||||
│ 5분 전 ⏱️ 5분 간격 │ ← 상대시간 & 점검주기
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
**실시간 업데이트:**
|
||||
- 점검 완료 시 즉시 화면 반영
|
||||
- 별도 새로고침 불필요
|
||||
- SSE 연결 상태 표시
|
||||
|
||||
**향후 확장:**
|
||||
- 데이터베이스 점검 포틀릿
|
||||
- 서버 상태 포틀릿
|
||||
- 디스크 용량 포틀릿
|
||||
- 기타 모니터링 항목
|
||||
|
||||
### 로그 & 통계
|
||||
**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 엔드포인트
|
||||
|
||||
### GET /api/dashboard
|
||||
초기 대시보드 데이터
|
||||
|
||||
**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"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/stream (SSE)
|
||||
실시간 업데이트 스트림
|
||||
|
||||
**Event Stream:**
|
||||
```
|
||||
data: {"type":"health_update","data":{...}}
|
||||
|
||||
data: {"type":"ping","timestamp":"2024-12-25T12:34:56.789Z"}
|
||||
```
|
||||
|
||||
### 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;
|
||||
```
|
||||
|
||||
## 브라우저 호환성
|
||||
|
||||
SSE 지원 브라우저:
|
||||
- ✅ Chrome
|
||||
- ✅ Firefox
|
||||
- ✅ Safari
|
||||
- ✅ Edge
|
||||
- ❌ IE (미지원)
|
||||
|
||||
## 다음 단계
|
||||
|
||||
- [ ] 데이터베이스 점검 포틀릿 추가
|
||||
- [ ] 서버 상태 점검 포틀릿 추가
|
||||
- [ ] 디스크 용량 점검 포틀릿 추가
|
||||
- [ ] Jenkins Job 통합
|
||||
- [ ] 장애 알림 (이메일/Slack)
|
||||
- [ ] 차트/그래프
|
||||
- [ ] 오래된 로그 자동 삭제
|
||||
|
||||
## 기술 스택
|
||||
|
||||
- **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** - 별도 서버 불필요
|
||||
|
||||
178
backend/api/anomaly/baseline.get.ts
Normal file
178
backend/api/anomaly/baseline.get.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { getDb } from '../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const db = getDb()
|
||||
const DEVIATION_THRESHOLD = 2.0
|
||||
|
||||
const servers = db.prepare(`
|
||||
SELECT target_id, server_name
|
||||
FROM server_targets
|
||||
WHERE is_active = 1
|
||||
ORDER BY server_name
|
||||
`).all() as any[]
|
||||
|
||||
const now = new Date()
|
||||
const currentHour = now.getHours()
|
||||
const currentDayOfWeek = now.getDay()
|
||||
const isWeekend = currentDayOfWeek === 0 || currentDayOfWeek === 6
|
||||
const dayType = isWeekend ? 'weekend' : 'weekday'
|
||||
|
||||
const anomalies: any[] = []
|
||||
const serverResults: any[] = []
|
||||
|
||||
// 로그 저장용
|
||||
const insertLog = db.prepare(`
|
||||
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
|
||||
VALUES (?, ?, 'baseline', ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
const recentLogExists = db.prepare(`
|
||||
SELECT 1 FROM anomaly_logs
|
||||
WHERE target_id = ? AND detect_type = 'baseline' AND metric = ?
|
||||
AND detected_at > datetime('now', '-1 minute', 'localtime')
|
||||
LIMIT 1
|
||||
`)
|
||||
|
||||
for (const server of servers) {
|
||||
const historicalData = db.prepare(`
|
||||
SELECT cpu_percent, memory_percent, collected_at
|
||||
FROM server_snapshots
|
||||
WHERE target_id = ?
|
||||
AND collected_at >= datetime('now', '-14 days', 'localtime')
|
||||
AND strftime('%H', collected_at) = ?
|
||||
AND (
|
||||
(? = 'weekend' AND strftime('%w', collected_at) IN ('0', '6'))
|
||||
OR
|
||||
(? = 'weekday' AND strftime('%w', collected_at) NOT IN ('0', '6'))
|
||||
)
|
||||
ORDER BY collected_at DESC
|
||||
`).all(server.target_id, currentHour.toString().padStart(2, '0'), dayType, dayType) as any[]
|
||||
|
||||
const current = db.prepare(`
|
||||
SELECT cpu_percent, memory_percent
|
||||
FROM server_snapshots
|
||||
WHERE target_id = ?
|
||||
ORDER BY collected_at DESC
|
||||
LIMIT 1
|
||||
`).get(server.target_id) as any
|
||||
|
||||
if (!current || historicalData.length < 5) {
|
||||
serverResults.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
current_hour: currentHour,
|
||||
day_type: dayType,
|
||||
cpu_current: current?.cpu_percent ?? null,
|
||||
mem_current: current?.memory_percent ?? null,
|
||||
cpu_baseline: null,
|
||||
mem_baseline: null,
|
||||
cpu_deviation: null,
|
||||
mem_deviation: null,
|
||||
sample_count: historicalData.length,
|
||||
status: 'insufficient'
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const currCpu = current.cpu_percent ?? 0
|
||||
const currMem = current.memory_percent ?? 0
|
||||
|
||||
const cpuValues = historicalData.map(s => s.cpu_percent ?? 0)
|
||||
const memValues = historicalData.map(s => s.memory_percent ?? 0)
|
||||
|
||||
const cpuAvg = cpuValues.reduce((a, b) => a + b, 0) / cpuValues.length
|
||||
const memAvg = memValues.reduce((a, b) => a + b, 0) / memValues.length
|
||||
|
||||
const cpuVariance = cpuValues.reduce((sum, val) => sum + Math.pow(val - cpuAvg, 2), 0) / cpuValues.length
|
||||
const memVariance = memValues.reduce((sum, val) => sum + Math.pow(val - memAvg, 2), 0) / memValues.length
|
||||
const cpuStd = Math.sqrt(cpuVariance)
|
||||
const memStd = Math.sqrt(memVariance)
|
||||
|
||||
const cpuDeviation = cpuStd > 0.1 ? (currCpu - cpuAvg) / cpuStd : 0
|
||||
const memDeviation = memStd > 0.1 ? (currMem - memAvg) / memStd : 0
|
||||
|
||||
let status = 'normal'
|
||||
const maxDev = Math.max(Math.abs(cpuDeviation), Math.abs(memDeviation))
|
||||
if (maxDev >= 3.0) status = 'danger'
|
||||
else if (maxDev >= DEVIATION_THRESHOLD) status = 'warning'
|
||||
|
||||
serverResults.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
current_hour: currentHour,
|
||||
day_type: dayType,
|
||||
cpu_current: currCpu,
|
||||
mem_current: currMem,
|
||||
cpu_baseline: { avg: cpuAvg, std: cpuStd },
|
||||
mem_baseline: { avg: memAvg, std: memStd },
|
||||
cpu_deviation: cpuDeviation,
|
||||
mem_deviation: memDeviation,
|
||||
sample_count: historicalData.length,
|
||||
status
|
||||
})
|
||||
|
||||
// CPU 이상감지 + 로그 저장
|
||||
if (Math.abs(cpuDeviation) >= DEVIATION_THRESHOLD) {
|
||||
const level = Math.abs(cpuDeviation) >= 3.0 ? 'danger' : 'warning'
|
||||
const direction = cpuDeviation >= 0 ? '높음' : '낮음'
|
||||
const dayLabel = isWeekend ? '주말' : '평일'
|
||||
const message = `CPU ${dayLabel} ${currentHour}시 베이스라인 대비 ${Math.abs(cpuDeviation).toFixed(1)}σ ${direction} (기준: ${cpuAvg.toFixed(1)}%, 현재: ${currCpu.toFixed(1)}%)`
|
||||
|
||||
anomalies.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
metric: 'CPU',
|
||||
current: currCpu,
|
||||
baseline_avg: cpuAvg,
|
||||
deviation: cpuDeviation,
|
||||
direction: cpuDeviation >= 0 ? 'up' : 'down',
|
||||
level,
|
||||
hour: currentHour,
|
||||
day_type: dayType
|
||||
})
|
||||
|
||||
if (!recentLogExists.get(server.target_id, 'CPU')) {
|
||||
insertLog.run(server.target_id, server.server_name, 'CPU', level, currCpu, cpuDeviation, message)
|
||||
}
|
||||
}
|
||||
|
||||
// Memory 이상감지 + 로그 저장
|
||||
if (Math.abs(memDeviation) >= DEVIATION_THRESHOLD) {
|
||||
const level = Math.abs(memDeviation) >= 3.0 ? 'danger' : 'warning'
|
||||
const direction = memDeviation >= 0 ? '높음' : '낮음'
|
||||
const dayLabel = isWeekend ? '주말' : '평일'
|
||||
const message = `Memory ${dayLabel} ${currentHour}시 베이스라인 대비 ${Math.abs(memDeviation).toFixed(1)}σ ${direction} (기준: ${memAvg.toFixed(1)}%, 현재: ${currMem.toFixed(1)}%)`
|
||||
|
||||
anomalies.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
metric: 'Memory',
|
||||
current: currMem,
|
||||
baseline_avg: memAvg,
|
||||
deviation: memDeviation,
|
||||
direction: memDeviation >= 0 ? 'up' : 'down',
|
||||
level,
|
||||
hour: currentHour,
|
||||
day_type: dayType
|
||||
})
|
||||
|
||||
if (!recentLogExists.get(server.target_id, 'Memory')) {
|
||||
insertLog.run(server.target_id, server.server_name, 'Memory', level, currMem, memDeviation, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anomalies.sort((a, b) => Math.abs(b.deviation) - Math.abs(a.deviation))
|
||||
|
||||
return {
|
||||
anomalies,
|
||||
servers: serverResults,
|
||||
context: {
|
||||
current_hour: currentHour,
|
||||
day_type: dayType,
|
||||
day_type_label: isWeekend ? '주말' : '평일',
|
||||
threshold: DEVIATION_THRESHOLD
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
80
backend/api/anomaly/chart.get.ts
Normal file
80
backend/api/anomaly/chart.get.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { getDb } from '../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const db = getDb()
|
||||
const query = getQuery(event)
|
||||
|
||||
const type = query.type as string || 'short-term'
|
||||
const period = query.period as string || '24h'
|
||||
|
||||
// 기간/간격 계산
|
||||
let intervalClause = ''
|
||||
let groupFormat = ''
|
||||
|
||||
if (period === '1h') {
|
||||
intervalClause = `'-1 hours'`
|
||||
groupFormat = '%Y-%m-%d %H:%M' // 분 단위
|
||||
} else if (period === '6h') {
|
||||
intervalClause = `'-6 hours'`
|
||||
groupFormat = '%Y-%m-%d %H:00' // 시간 단위
|
||||
} else if (period === '12h') {
|
||||
intervalClause = `'-12 hours'`
|
||||
groupFormat = '%Y-%m-%d %H:00'
|
||||
} else if (period === '24h') {
|
||||
intervalClause = `'-24 hours'`
|
||||
groupFormat = '%Y-%m-%d %H:00'
|
||||
} else if (period === '7d') {
|
||||
intervalClause = `'-7 days'`
|
||||
groupFormat = '%Y-%m-%d' // 일 단위
|
||||
} else if (period === '30d') {
|
||||
intervalClause = `'-30 days'`
|
||||
groupFormat = '%Y-%m-%d'
|
||||
} else {
|
||||
intervalClause = `'-24 hours'`
|
||||
groupFormat = '%Y-%m-%d %H:00'
|
||||
}
|
||||
|
||||
// 시간대별 집계
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
strftime('${groupFormat}', detected_at) 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 = ?
|
||||
AND detected_at >= datetime('now', ${intervalClause}, 'localtime')
|
||||
GROUP BY time_slot
|
||||
ORDER BY time_slot ASC
|
||||
`).all(type) as any[]
|
||||
|
||||
// 시간 포맷 변환
|
||||
const data = rows.map(r => ({
|
||||
time: formatTimeLabel(r.time_slot, period),
|
||||
warning: r.warning,
|
||||
danger: r.danger
|
||||
}))
|
||||
|
||||
return {
|
||||
data,
|
||||
type,
|
||||
period,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
|
||||
function formatTimeLabel(timeSlot: string, period: string): string {
|
||||
if (!timeSlot) return ''
|
||||
|
||||
if (period === '7d' || period === '30d') {
|
||||
// 일 단위: MM/DD
|
||||
const parts = timeSlot.split('-')
|
||||
return `${parts[1]}/${parts[2]}`
|
||||
} else {
|
||||
// 시간 단위: HH:MM
|
||||
const parts = timeSlot.split(' ')
|
||||
if (parts.length === 2) {
|
||||
return parts[1].substring(0, 5)
|
||||
}
|
||||
return timeSlot.substring(11, 16)
|
||||
}
|
||||
}
|
||||
38
backend/api/anomaly/logs.get.ts
Normal file
38
backend/api/anomaly/logs.get.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { getDb } from '../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const db = getDb()
|
||||
const query = getQuery(event)
|
||||
|
||||
const type = query.type as string || 'short-term'
|
||||
const period = query.period as string || '24h'
|
||||
|
||||
// 기간 계산
|
||||
let intervalClause = ''
|
||||
if (period.endsWith('h')) {
|
||||
const hours = parseInt(period)
|
||||
intervalClause = `'-${hours} hours'`
|
||||
} else if (period.endsWith('d')) {
|
||||
const days = parseInt(period)
|
||||
intervalClause = `'-${days} days'`
|
||||
} else {
|
||||
intervalClause = `'-24 hours'`
|
||||
}
|
||||
|
||||
const logs = db.prepare(`
|
||||
SELECT id, target_id, server_name, detect_type, metric, level,
|
||||
current_value, threshold_value, message, detected_at
|
||||
FROM anomaly_logs
|
||||
WHERE detect_type = ?
|
||||
AND detected_at >= datetime('now', ${intervalClause}, 'localtime')
|
||||
ORDER BY detected_at DESC
|
||||
LIMIT 100
|
||||
`).all(type) as any[]
|
||||
|
||||
return {
|
||||
logs,
|
||||
type,
|
||||
period,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
141
backend/api/anomaly/short-term.get.ts
Normal file
141
backend/api/anomaly/short-term.get.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { getDb } from '../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const db = getDb()
|
||||
const THRESHOLD = 30 // 30% 이상 변화 시 이상 감지
|
||||
|
||||
// 활성 서버 목록
|
||||
const servers = db.prepare(`
|
||||
SELECT target_id, server_name
|
||||
FROM server_targets
|
||||
WHERE is_active = 1
|
||||
ORDER BY server_name
|
||||
`).all() as any[]
|
||||
|
||||
const anomalies: any[] = []
|
||||
const serverResults: any[] = []
|
||||
|
||||
// 로그 저장용 prepared statement
|
||||
const insertLog = db.prepare(`
|
||||
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
|
||||
VALUES (?, ?, 'short-term', ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
// 최근 1분 내 동일 로그 존재 여부 확인 (중복 방지)
|
||||
const recentLogExists = db.prepare(`
|
||||
SELECT 1 FROM anomaly_logs
|
||||
WHERE target_id = ? AND detect_type = 'short-term' AND metric = ?
|
||||
AND detected_at > datetime('now', '-1 minute', 'localtime')
|
||||
LIMIT 1
|
||||
`)
|
||||
|
||||
for (const server of servers) {
|
||||
const snapshots = db.prepare(`
|
||||
SELECT cpu_percent, memory_percent, collected_at
|
||||
FROM server_snapshots
|
||||
WHERE target_id = ?
|
||||
ORDER BY collected_at DESC
|
||||
LIMIT 20
|
||||
`).all(server.target_id) as any[]
|
||||
|
||||
if (snapshots.length < 4) {
|
||||
serverResults.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
cpu_change: null,
|
||||
mem_change: null,
|
||||
status: 'normal'
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const half = Math.floor(snapshots.length / 2)
|
||||
const currSnapshots = snapshots.slice(0, half)
|
||||
const prevSnapshots = snapshots.slice(half)
|
||||
|
||||
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 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
|
||||
|
||||
let cpuChange: number | null = null
|
||||
let memChange: number | null = null
|
||||
|
||||
if (prevCpuAvg > 1) {
|
||||
cpuChange = ((currCpuAvg - prevCpuAvg) / prevCpuAvg) * 100
|
||||
} else {
|
||||
cpuChange = currCpuAvg - prevCpuAvg
|
||||
}
|
||||
|
||||
if (prevMemAvg > 1) {
|
||||
memChange = ((currMemAvg - prevMemAvg) / prevMemAvg) * 100
|
||||
} else {
|
||||
memChange = currMemAvg - prevMemAvg
|
||||
}
|
||||
|
||||
let status = 'normal'
|
||||
const maxChange = Math.max(Math.abs(cpuChange || 0), Math.abs(memChange || 0))
|
||||
if (maxChange >= 100) status = 'danger'
|
||||
else if (maxChange >= THRESHOLD) status = 'warning'
|
||||
|
||||
serverResults.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
cpu_change: cpuChange,
|
||||
mem_change: memChange,
|
||||
status
|
||||
})
|
||||
|
||||
// CPU 이상 감지 + 로그 저장
|
||||
if (cpuChange !== null && Math.abs(cpuChange) >= THRESHOLD) {
|
||||
const level = Math.abs(cpuChange) >= 100 ? 'danger' : 'warning'
|
||||
const direction = cpuChange >= 0 ? '증가' : '감소'
|
||||
const message = `CPU ${direction} 감지 (${prevCpuAvg.toFixed(1)}% → ${currCpuAvg.toFixed(1)}%)`
|
||||
|
||||
anomalies.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
metric: 'CPU',
|
||||
prev_avg: prevCpuAvg,
|
||||
curr_avg: currCpuAvg,
|
||||
change_rate: cpuChange,
|
||||
direction: cpuChange >= 0 ? 'up' : 'down'
|
||||
})
|
||||
|
||||
// 중복 아니면 로그 저장
|
||||
if (!recentLogExists.get(server.target_id, 'CPU')) {
|
||||
insertLog.run(server.target_id, server.server_name, 'CPU', level, currCpuAvg, cpuChange, message)
|
||||
}
|
||||
}
|
||||
|
||||
// Memory 이상 감지 + 로그 저장
|
||||
if (memChange !== null && Math.abs(memChange) >= THRESHOLD) {
|
||||
const level = Math.abs(memChange) >= 100 ? 'danger' : 'warning'
|
||||
const direction = memChange >= 0 ? '증가' : '감소'
|
||||
const message = `Memory ${direction} 감지 (${prevMemAvg.toFixed(1)}% → ${currMemAvg.toFixed(1)}%)`
|
||||
|
||||
anomalies.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
metric: 'Memory',
|
||||
prev_avg: prevMemAvg,
|
||||
curr_avg: currMemAvg,
|
||||
change_rate: memChange,
|
||||
direction: memChange >= 0 ? 'up' : 'down'
|
||||
})
|
||||
|
||||
if (!recentLogExists.get(server.target_id, 'Memory')) {
|
||||
insertLog.run(server.target_id, server.server_name, 'Memory', level, currMemAvg, memChange, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anomalies.sort((a, b) => Math.abs(b.change_rate) - Math.abs(a.change_rate))
|
||||
|
||||
return {
|
||||
anomalies,
|
||||
servers: serverResults,
|
||||
threshold: THRESHOLD,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
192
backend/api/anomaly/trend.get.ts
Normal file
192
backend/api/anomaly/trend.get.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { getDb } from '../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const db = getDb()
|
||||
const SLOPE_THRESHOLD = 0.5 // 분당 0.5% 이상 증가/감소 시 이상
|
||||
const MIN_SAMPLES = 10 // 최소 10개 샘플 필요
|
||||
const WINDOW_MINUTES = 30 // 30분 윈도우
|
||||
|
||||
const servers = db.prepare(`
|
||||
SELECT target_id, server_name
|
||||
FROM server_targets
|
||||
WHERE is_active = 1
|
||||
ORDER BY server_name
|
||||
`).all() as any[]
|
||||
|
||||
const anomalies: any[] = []
|
||||
const serverResults: any[] = []
|
||||
|
||||
// 로그 저장용
|
||||
const insertLog = db.prepare(`
|
||||
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
|
||||
VALUES (?, ?, 'trend', ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
const recentLogExists = db.prepare(`
|
||||
SELECT 1 FROM anomaly_logs
|
||||
WHERE target_id = ? AND detect_type = 'trend' AND metric = ?
|
||||
AND detected_at > datetime('now', '-1 minute', 'localtime')
|
||||
LIMIT 1
|
||||
`)
|
||||
|
||||
for (const server of servers) {
|
||||
// 최근 30분 데이터 조회
|
||||
const snapshots = db.prepare(`
|
||||
SELECT cpu_percent, memory_percent, collected_at,
|
||||
(julianday('now', 'localtime') - julianday(collected_at)) * 24 * 60 as minutes_ago
|
||||
FROM server_snapshots
|
||||
WHERE target_id = ? AND is_online = 1
|
||||
AND collected_at >= datetime('now', '-${WINDOW_MINUTES} minutes', 'localtime')
|
||||
ORDER BY collected_at ASC
|
||||
`).all(server.target_id) as any[]
|
||||
|
||||
if (snapshots.length < MIN_SAMPLES) {
|
||||
serverResults.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
cpu_current: snapshots.length > 0 ? snapshots[snapshots.length - 1].cpu_percent : null,
|
||||
mem_current: snapshots.length > 0 ? snapshots[snapshots.length - 1].memory_percent : null,
|
||||
cpu_slope: null,
|
||||
mem_slope: null,
|
||||
cpu_trend: null,
|
||||
mem_trend: null,
|
||||
sample_count: snapshots.length,
|
||||
status: 'insufficient'
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// 선형 회귀 계산 (최소제곱법)
|
||||
// y = ax + b, a = slope (기울기)
|
||||
const n = snapshots.length
|
||||
const current = snapshots[n - 1]
|
||||
const currCpu = current.cpu_percent ?? 0
|
||||
const currMem = current.memory_percent ?? 0
|
||||
|
||||
// x = 시간 (분), y = 값
|
||||
const cpuPoints = snapshots.map((s, i) => ({ x: i, y: s.cpu_percent ?? 0 }))
|
||||
const memPoints = snapshots.map((s, i) => ({ x: i, y: s.memory_percent ?? 0 }))
|
||||
|
||||
function linearRegression(points: { x: number, y: number }[]): { slope: number, intercept: number, r2: number } {
|
||||
const n = points.length
|
||||
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0, sumY2 = 0
|
||||
|
||||
for (const p of points) {
|
||||
sumX += p.x
|
||||
sumY += p.y
|
||||
sumXY += p.x * p.y
|
||||
sumX2 += p.x * p.x
|
||||
sumY2 += p.y * p.y
|
||||
}
|
||||
|
||||
const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX)
|
||||
const intercept = (sumY - slope * sumX) / n
|
||||
|
||||
// R² (결정계수) 계산
|
||||
const yMean = sumY / n
|
||||
let ssTotal = 0, ssResidual = 0
|
||||
for (const p of points) {
|
||||
const yPred = slope * p.x + intercept
|
||||
ssTotal += Math.pow(p.y - yMean, 2)
|
||||
ssResidual += Math.pow(p.y - yPred, 2)
|
||||
}
|
||||
const r2 = ssTotal > 0 ? 1 - (ssResidual / ssTotal) : 0
|
||||
|
||||
return { slope, intercept, r2 }
|
||||
}
|
||||
|
||||
const cpuReg = linearRegression(cpuPoints)
|
||||
const memReg = linearRegression(memPoints)
|
||||
|
||||
// 분당 기울기로 환산 (수집 간격 고려)
|
||||
const totalMinutes = WINDOW_MINUTES
|
||||
const cpuSlopePerMin = (cpuReg.slope * n) / totalMinutes
|
||||
const memSlopePerMin = (memReg.slope * n) / totalMinutes
|
||||
|
||||
// 추세 판단
|
||||
function getTrend(slope: number, r2: number): string {
|
||||
if (r2 < 0.3) return 'unstable' // 추세가 불안정
|
||||
if (slope >= SLOPE_THRESHOLD) return 'rising'
|
||||
if (slope <= -SLOPE_THRESHOLD) return 'falling'
|
||||
return 'stable'
|
||||
}
|
||||
|
||||
const cpuTrend = getTrend(cpuSlopePerMin, cpuReg.r2)
|
||||
const memTrend = getTrend(memSlopePerMin, memReg.r2)
|
||||
|
||||
// 상태 결정
|
||||
let status = 'normal'
|
||||
if (cpuTrend === 'rising' || memTrend === 'rising') status = 'warning'
|
||||
if (cpuSlopePerMin >= 1.0 || memSlopePerMin >= 1.0) status = 'danger' // 분당 1% 이상
|
||||
|
||||
serverResults.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
cpu_current: currCpu,
|
||||
mem_current: currMem,
|
||||
cpu_slope: cpuSlopePerMin,
|
||||
mem_slope: memSlopePerMin,
|
||||
cpu_trend: cpuTrend,
|
||||
mem_trend: memTrend,
|
||||
cpu_r2: cpuReg.r2,
|
||||
mem_r2: memReg.r2,
|
||||
sample_count: snapshots.length,
|
||||
status
|
||||
})
|
||||
|
||||
// CPU 이상감지 + 로그 저장
|
||||
if (cpuTrend === 'rising' && cpuReg.r2 >= 0.3) {
|
||||
const level = cpuSlopePerMin >= 1.0 ? 'danger' : 'warning'
|
||||
const message = `CPU 지속 상승 중 (분당 +${cpuSlopePerMin.toFixed(2)}%, R²=${cpuReg.r2.toFixed(2)})`
|
||||
|
||||
anomalies.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
metric: 'CPU',
|
||||
current: currCpu,
|
||||
slope: cpuSlopePerMin,
|
||||
r2: cpuReg.r2,
|
||||
trend: cpuTrend,
|
||||
level
|
||||
})
|
||||
|
||||
if (!recentLogExists.get(server.target_id, 'CPU')) {
|
||||
insertLog.run(server.target_id, server.server_name, 'CPU', level, currCpu, cpuSlopePerMin, message)
|
||||
}
|
||||
}
|
||||
|
||||
// Memory 이상감지 + 로그 저장
|
||||
if (memTrend === 'rising' && memReg.r2 >= 0.3) {
|
||||
const level = memSlopePerMin >= 1.0 ? 'danger' : 'warning'
|
||||
const message = `Memory 지속 상승 중 (분당 +${memSlopePerMin.toFixed(2)}%, R²=${memReg.r2.toFixed(2)})`
|
||||
|
||||
anomalies.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
metric: 'Memory',
|
||||
current: currMem,
|
||||
slope: memSlopePerMin,
|
||||
r2: memReg.r2,
|
||||
trend: memTrend,
|
||||
level
|
||||
})
|
||||
|
||||
if (!recentLogExists.get(server.target_id, 'Memory')) {
|
||||
insertLog.run(server.target_id, server.server_name, 'Memory', level, currMem, memSlopePerMin, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anomalies.sort((a, b) => b.slope - a.slope)
|
||||
|
||||
return {
|
||||
anomalies,
|
||||
servers: serverResults,
|
||||
config: {
|
||||
slope_threshold: SLOPE_THRESHOLD,
|
||||
window_minutes: WINDOW_MINUTES,
|
||||
min_samples: MIN_SAMPLES
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
149
backend/api/anomaly/zscore.get.ts
Normal file
149
backend/api/anomaly/zscore.get.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { getDb } from '../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const db = getDb()
|
||||
const WARNING_Z = 2.0
|
||||
const DANGER_Z = 3.0
|
||||
|
||||
const servers = db.prepare(`
|
||||
SELECT target_id, server_name
|
||||
FROM server_targets
|
||||
WHERE is_active = 1
|
||||
ORDER BY server_name
|
||||
`).all() as any[]
|
||||
|
||||
const anomalies: any[] = []
|
||||
const serverResults: any[] = []
|
||||
|
||||
// 로그 저장용
|
||||
const insertLog = db.prepare(`
|
||||
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
|
||||
VALUES (?, ?, 'zscore', ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
const recentLogExists = db.prepare(`
|
||||
SELECT 1 FROM anomaly_logs
|
||||
WHERE target_id = ? AND detect_type = 'zscore' AND metric = ?
|
||||
AND detected_at > datetime('now', '-1 minute', 'localtime')
|
||||
LIMIT 1
|
||||
`)
|
||||
|
||||
for (const server of servers) {
|
||||
const snapshots = db.prepare(`
|
||||
SELECT cpu_percent, memory_percent, collected_at
|
||||
FROM server_snapshots
|
||||
WHERE target_id = ?
|
||||
AND collected_at >= datetime('now', '-1 hour', 'localtime')
|
||||
ORDER BY collected_at DESC
|
||||
`).all(server.target_id) as any[]
|
||||
|
||||
if (snapshots.length < 10) {
|
||||
serverResults.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
cpu_zscore: null,
|
||||
mem_zscore: null,
|
||||
cpu_avg: null,
|
||||
cpu_std: null,
|
||||
mem_avg: null,
|
||||
mem_std: null,
|
||||
sample_count: snapshots.length,
|
||||
status: 'insufficient'
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const current = snapshots[0]
|
||||
const currCpu = current.cpu_percent ?? 0
|
||||
const currMem = current.memory_percent ?? 0
|
||||
|
||||
const cpuValues = snapshots.map(s => s.cpu_percent ?? 0)
|
||||
const memValues = snapshots.map(s => s.memory_percent ?? 0)
|
||||
|
||||
const cpuAvg = cpuValues.reduce((a, b) => a + b, 0) / cpuValues.length
|
||||
const memAvg = memValues.reduce((a, b) => a + b, 0) / memValues.length
|
||||
|
||||
const cpuVariance = cpuValues.reduce((sum, val) => sum + Math.pow(val - cpuAvg, 2), 0) / cpuValues.length
|
||||
const memVariance = memValues.reduce((sum, val) => sum + Math.pow(val - memAvg, 2), 0) / memValues.length
|
||||
const cpuStd = Math.sqrt(cpuVariance)
|
||||
const memStd = Math.sqrt(memVariance)
|
||||
|
||||
const cpuZscore = cpuStd > 0.1 ? (currCpu - cpuAvg) / cpuStd : 0
|
||||
const memZscore = memStd > 0.1 ? (currMem - memAvg) / memStd : 0
|
||||
|
||||
let status = 'normal'
|
||||
const maxZ = Math.max(Math.abs(cpuZscore), Math.abs(memZscore))
|
||||
if (maxZ >= DANGER_Z) status = 'danger'
|
||||
else if (maxZ >= WARNING_Z) status = 'warning'
|
||||
|
||||
serverResults.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
cpu_current: currCpu,
|
||||
mem_current: currMem,
|
||||
cpu_zscore: cpuZscore,
|
||||
mem_zscore: memZscore,
|
||||
cpu_avg: cpuAvg,
|
||||
cpu_std: cpuStd,
|
||||
mem_avg: memAvg,
|
||||
mem_std: memStd,
|
||||
sample_count: snapshots.length,
|
||||
status
|
||||
})
|
||||
|
||||
// CPU 이상 감지 + 로그 저장
|
||||
if (Math.abs(cpuZscore) >= WARNING_Z) {
|
||||
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)}%)`
|
||||
|
||||
anomalies.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
metric: 'CPU',
|
||||
current: currCpu,
|
||||
avg: cpuAvg,
|
||||
std: cpuStd,
|
||||
zscore: cpuZscore,
|
||||
direction: cpuZscore >= 0 ? 'up' : 'down',
|
||||
level
|
||||
})
|
||||
|
||||
if (!recentLogExists.get(server.target_id, 'CPU')) {
|
||||
insertLog.run(server.target_id, server.server_name, 'CPU', level, currCpu, cpuZscore, message)
|
||||
}
|
||||
}
|
||||
|
||||
// Memory 이상 감지 + 로그 저장
|
||||
if (Math.abs(memZscore) >= WARNING_Z) {
|
||||
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)}%)`
|
||||
|
||||
anomalies.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
metric: 'Memory',
|
||||
current: currMem,
|
||||
avg: memAvg,
|
||||
std: memStd,
|
||||
zscore: memZscore,
|
||||
direction: memZscore >= 0 ? 'up' : 'down',
|
||||
level
|
||||
})
|
||||
|
||||
if (!recentLogExists.get(server.target_id, 'Memory')) {
|
||||
insertLog.run(server.target_id, server.server_name, 'Memory', level, currMem, memZscore, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anomalies.sort((a, b) => Math.abs(b.zscore) - Math.abs(a.zscore))
|
||||
|
||||
return {
|
||||
anomalies,
|
||||
servers: serverResults,
|
||||
thresholds: { warning: WARNING_Z, danger: DANGER_Z },
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
78
backend/api/network/privnet/chart.get.ts
Normal file
78
backend/api/network/privnet/chart.get.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event)
|
||||
const { year, month, week } = query as { year?: string, month?: string, week?: string }
|
||||
|
||||
if (!year || !month || !week) {
|
||||
return { error: 'year, month, week are required' }
|
||||
}
|
||||
|
||||
// 해당 월의 첫날과 마지막날
|
||||
const y = parseInt(year)
|
||||
const m = parseInt(month)
|
||||
const w = parseInt(week)
|
||||
|
||||
// 해당 월의 첫날
|
||||
const firstDayOfMonth = new Date(y, m - 1, 1)
|
||||
const firstDayWeekday = firstDayOfMonth.getDay() // 0=일, 1=월, ...
|
||||
|
||||
// 주차의 시작일 계산 (월요일 기준)
|
||||
// 1주차: 1일이 포함된 주
|
||||
const mondayOffset = firstDayWeekday === 0 ? -6 : 1 - firstDayWeekday
|
||||
const firstMondayOfMonth = new Date(y, m - 1, 1 + mondayOffset)
|
||||
|
||||
// 선택한 주차의 월요일
|
||||
const weekStart = new Date(firstMondayOfMonth)
|
||||
weekStart.setDate(weekStart.getDate() + (w - 1) * 7)
|
||||
|
||||
// 주차의 일요일
|
||||
const weekEnd = new Date(weekStart)
|
||||
weekEnd.setDate(weekEnd.getDate() + 6)
|
||||
|
||||
// 해당 주의 날짜 목록 생성
|
||||
const weekDates: string[] = []
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(weekStart)
|
||||
d.setDate(d.getDate() + i)
|
||||
const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
weekDates.push(dateStr)
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
|
||||
// 시작/종료 날짜
|
||||
const startDate = weekDates[0]
|
||||
const endDate = weekDates[6]
|
||||
|
||||
// 1시간 단위 성공률 조회
|
||||
const heatmapData = db.prepare(`
|
||||
SELECT
|
||||
date(checked_at) as date,
|
||||
strftime('%H', checked_at) || ':00' as time_slot,
|
||||
COUNT(*) as total_count,
|
||||
SUM(CASE WHEN is_success = 1 THEN 1 ELSE 0 END) as success_count,
|
||||
ROUND(SUM(CASE WHEN is_success = 1 THEN 1.0 ELSE 0.0 END) / COUNT(*) * 100, 1) as success_rate
|
||||
FROM privnet_logs
|
||||
WHERE date(checked_at) >= ? AND date(checked_at) <= ?
|
||||
GROUP BY date, time_slot
|
||||
ORDER BY date, time_slot
|
||||
`).all(startDate, endDate)
|
||||
|
||||
// 해당 월의 주차 수 계산
|
||||
const lastDayOfMonth = new Date(y, m, 0)
|
||||
const lastDate = lastDayOfMonth.getDate()
|
||||
|
||||
// 마지막 날이 몇 주차인지 계산
|
||||
const lastDayFromFirstMonday = Math.floor((lastDayOfMonth.getTime() - firstMondayOfMonth.getTime()) / (1000 * 60 * 60 * 24))
|
||||
const totalWeeks = Math.ceil((lastDayFromFirstMonday + 1) / 7)
|
||||
|
||||
return {
|
||||
heatmapData,
|
||||
weekDates,
|
||||
totalWeeks: Math.max(totalWeeks, 1),
|
||||
year: y,
|
||||
month: m,
|
||||
week: w
|
||||
}
|
||||
})
|
||||
33
backend/api/network/privnet/logs.get.ts
Normal file
33
backend/api/network/privnet/logs.get.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event)
|
||||
const { year, month, day, hour } = query as {
|
||||
year?: string, month?: string, day?: string, hour?: string
|
||||
}
|
||||
|
||||
if (!year || !month || !day || !hour) {
|
||||
return { error: 'year, month, day, hour are required' }
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
|
||||
// 해당 시간대 로그 조회
|
||||
const startTime = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')} ${hour.padStart(2, '0')}:00:00`
|
||||
const endTime = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')} ${hour.padStart(2, '0')}:59:59`
|
||||
|
||||
const logs = db.prepare(`
|
||||
SELECT
|
||||
l.id,
|
||||
l.checked_at,
|
||||
l.is_success,
|
||||
t.name as target_name,
|
||||
t.url as target_url
|
||||
FROM privnet_logs l
|
||||
JOIN privnet_targets t ON l.target_id = t.id
|
||||
WHERE l.checked_at >= ? AND l.checked_at <= ?
|
||||
ORDER BY l.checked_at DESC
|
||||
`).all(startTime, endTime)
|
||||
|
||||
return { logs }
|
||||
})
|
||||
11
backend/api/network/privnet/scheduler/start.post.ts
Normal file
11
backend/api/network/privnet/scheduler/start.post.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { privnetScheduler } from '../../../../utils/privnet-scheduler'
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
privnetScheduler.start()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Privnet scheduler started',
|
||||
isRunning: privnetScheduler.getIsRunning()
|
||||
}
|
||||
})
|
||||
11
backend/api/network/privnet/scheduler/stop.post.ts
Normal file
11
backend/api/network/privnet/scheduler/stop.post.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { privnetScheduler } from '../../../../utils/privnet-scheduler'
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
privnetScheduler.stop()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Privnet scheduler stopped',
|
||||
isRunning: privnetScheduler.getIsRunning()
|
||||
}
|
||||
})
|
||||
41
backend/api/network/privnet/status.get.ts
Normal file
41
backend/api/network/privnet/status.get.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
import { privnetScheduler } from '../../../utils/privnet-scheduler'
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
const db = getDb()
|
||||
|
||||
// 현재 상태 조회
|
||||
const status = db.prepare(`
|
||||
SELECT
|
||||
ps.*,
|
||||
pt.name as last_target_name,
|
||||
pt.url as last_target_url
|
||||
FROM privnet_status ps
|
||||
LEFT JOIN privnet_targets pt ON ps.last_target_id = pt.id
|
||||
WHERE ps.id = 1
|
||||
`).get()
|
||||
|
||||
// 최근 10개 로그
|
||||
const recentLogs = db.prepare(`
|
||||
SELECT
|
||||
pl.*,
|
||||
pt.name as target_name,
|
||||
pt.url as target_url
|
||||
FROM privnet_logs pl
|
||||
JOIN privnet_targets pt ON pl.target_id = pt.id
|
||||
ORDER BY pl.checked_at DESC
|
||||
LIMIT 10
|
||||
`).all()
|
||||
|
||||
// 활성 타겟 수
|
||||
const targetCount = db.prepare(`
|
||||
SELECT COUNT(*) as cnt FROM privnet_targets WHERE is_active = 1
|
||||
`).get() as { cnt: number }
|
||||
|
||||
return {
|
||||
status,
|
||||
recentLogs,
|
||||
targetCount: targetCount.cnt,
|
||||
schedulerRunning: privnetScheduler.getIsRunning()
|
||||
}
|
||||
})
|
||||
10
backend/api/network/privnet/targets/[id].delete.ts
Normal file
10
backend/api/network/privnet/targets/[id].delete.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { getDb } from '../../../../utils/db'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const db = getDb()
|
||||
const id = getRouterParam(event, 'id')
|
||||
|
||||
db.prepare(`DELETE FROM privnet_targets WHERE id = ?`).run(id)
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
29
backend/api/network/privnet/targets/[id].put.ts
Normal file
29
backend/api/network/privnet/targets/[id].put.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { getDb } from '../../../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const db = getDb()
|
||||
const id = getRouterParam(event, 'id')
|
||||
const body = await readBody(event)
|
||||
|
||||
const { name, url, is_active } = body
|
||||
|
||||
if (!name || !url) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'name과 url은 필수입니다'
|
||||
})
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE privnet_targets
|
||||
SET name = ?, url = ?, is_active = ?, updated_at = datetime('now', 'localtime')
|
||||
WHERE id = ?
|
||||
`).run(name, url, is_active ? 1 : 0, id)
|
||||
|
||||
return {
|
||||
id: Number(id),
|
||||
name,
|
||||
url,
|
||||
is_active: is_active ? 1 : 0
|
||||
}
|
||||
})
|
||||
12
backend/api/network/privnet/targets/index.get.ts
Normal file
12
backend/api/network/privnet/targets/index.get.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { getDb } from '../../../../utils/db'
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
const db = getDb()
|
||||
|
||||
const targets = db.prepare(`
|
||||
SELECT * FROM privnet_targets
|
||||
ORDER BY id ASC
|
||||
`).all()
|
||||
|
||||
return targets
|
||||
})
|
||||
27
backend/api/network/privnet/targets/index.post.ts
Normal file
27
backend/api/network/privnet/targets/index.post.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { getDb } from '../../../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const db = getDb()
|
||||
const body = await readBody(event)
|
||||
|
||||
const { name, url, is_active } = body
|
||||
|
||||
if (!name || !url) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'name과 url은 필수입니다'
|
||||
})
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO privnet_targets (name, url, is_active)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(name, url, is_active ? 1 : 0)
|
||||
|
||||
return {
|
||||
id: result.lastInsertRowid,
|
||||
name,
|
||||
url,
|
||||
is_active: is_active ? 1 : 0
|
||||
}
|
||||
})
|
||||
78
backend/api/network/pubnet/chart.get.ts
Normal file
78
backend/api/network/pubnet/chart.get.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event)
|
||||
const { year, month, week } = query as { year?: string, month?: string, week?: string }
|
||||
|
||||
if (!year || !month || !week) {
|
||||
return { error: 'year, month, week are required' }
|
||||
}
|
||||
|
||||
// 해당 월의 첫날과 마지막날
|
||||
const y = parseInt(year)
|
||||
const m = parseInt(month)
|
||||
const w = parseInt(week)
|
||||
|
||||
// 해당 월의 첫날
|
||||
const firstDayOfMonth = new Date(y, m - 1, 1)
|
||||
const firstDayWeekday = firstDayOfMonth.getDay() // 0=일, 1=월, ...
|
||||
|
||||
// 주차의 시작일 계산 (월요일 기준)
|
||||
// 1주차: 1일이 포함된 주
|
||||
const mondayOffset = firstDayWeekday === 0 ? -6 : 1 - firstDayWeekday
|
||||
const firstMondayOfMonth = new Date(y, m - 1, 1 + mondayOffset)
|
||||
|
||||
// 선택한 주차의 월요일
|
||||
const weekStart = new Date(firstMondayOfMonth)
|
||||
weekStart.setDate(weekStart.getDate() + (w - 1) * 7)
|
||||
|
||||
// 주차의 일요일
|
||||
const weekEnd = new Date(weekStart)
|
||||
weekEnd.setDate(weekEnd.getDate() + 6)
|
||||
|
||||
// 해당 주의 날짜 목록 생성
|
||||
const weekDates: string[] = []
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(weekStart)
|
||||
d.setDate(d.getDate() + i)
|
||||
const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
weekDates.push(dateStr)
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
|
||||
// 시작/종료 날짜
|
||||
const startDate = weekDates[0]
|
||||
const endDate = weekDates[6]
|
||||
|
||||
// 1시간 단위 성공률 조회
|
||||
const heatmapData = db.prepare(`
|
||||
SELECT
|
||||
date(checked_at) as date,
|
||||
strftime('%H', checked_at) || ':00' as time_slot,
|
||||
COUNT(*) as total_count,
|
||||
SUM(CASE WHEN is_success = 1 THEN 1 ELSE 0 END) as success_count,
|
||||
ROUND(SUM(CASE WHEN is_success = 1 THEN 1.0 ELSE 0.0 END) / COUNT(*) * 100, 1) as success_rate
|
||||
FROM pubnet_logs
|
||||
WHERE date(checked_at) >= ? AND date(checked_at) <= ?
|
||||
GROUP BY date, time_slot
|
||||
ORDER BY date, time_slot
|
||||
`).all(startDate, endDate)
|
||||
|
||||
// 해당 월의 주차 수 계산
|
||||
const lastDayOfMonth = new Date(y, m, 0)
|
||||
const lastDate = lastDayOfMonth.getDate()
|
||||
|
||||
// 마지막 날이 몇 주차인지 계산
|
||||
const lastDayFromFirstMonday = Math.floor((lastDayOfMonth.getTime() - firstMondayOfMonth.getTime()) / (1000 * 60 * 60 * 24))
|
||||
const totalWeeks = Math.ceil((lastDayFromFirstMonday + 1) / 7)
|
||||
|
||||
return {
|
||||
heatmapData,
|
||||
weekDates,
|
||||
totalWeeks: Math.max(totalWeeks, 1),
|
||||
year: y,
|
||||
month: m,
|
||||
week: w
|
||||
}
|
||||
})
|
||||
33
backend/api/network/pubnet/logs.get.ts
Normal file
33
backend/api/network/pubnet/logs.get.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event)
|
||||
const { year, month, day, hour } = query as {
|
||||
year?: string, month?: string, day?: string, hour?: string
|
||||
}
|
||||
|
||||
if (!year || !month || !day || !hour) {
|
||||
return { error: 'year, month, day, hour are required' }
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
|
||||
// 해당 시간대 로그 조회
|
||||
const startTime = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')} ${hour.padStart(2, '0')}:00:00`
|
||||
const endTime = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')} ${hour.padStart(2, '0')}:59:59`
|
||||
|
||||
const logs = db.prepare(`
|
||||
SELECT
|
||||
l.id,
|
||||
l.checked_at,
|
||||
l.is_success,
|
||||
t.name as target_name,
|
||||
t.url as target_url
|
||||
FROM pubnet_logs l
|
||||
JOIN pubnet_targets t ON l.target_id = t.id
|
||||
WHERE l.checked_at >= ? AND l.checked_at <= ?
|
||||
ORDER BY l.checked_at DESC
|
||||
`).all(startTime, endTime)
|
||||
|
||||
return { logs }
|
||||
})
|
||||
11
backend/api/network/pubnet/scheduler/start.post.ts
Normal file
11
backend/api/network/pubnet/scheduler/start.post.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { pubnetScheduler } from '../../../../utils/pubnet-scheduler'
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
pubnetScheduler.start()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Pubnet scheduler started',
|
||||
isRunning: pubnetScheduler.getIsRunning()
|
||||
}
|
||||
})
|
||||
11
backend/api/network/pubnet/scheduler/stop.post.ts
Normal file
11
backend/api/network/pubnet/scheduler/stop.post.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { pubnetScheduler } from '../../../../utils/pubnet-scheduler'
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
pubnetScheduler.stop()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Pubnet scheduler stopped',
|
||||
isRunning: pubnetScheduler.getIsRunning()
|
||||
}
|
||||
})
|
||||
41
backend/api/network/pubnet/status.get.ts
Normal file
41
backend/api/network/pubnet/status.get.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
import { pubnetScheduler } from '../../../utils/pubnet-scheduler'
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
const db = getDb()
|
||||
|
||||
// 현재 상태 조회
|
||||
const status = db.prepare(`
|
||||
SELECT
|
||||
ps.*,
|
||||
pt.name as last_target_name,
|
||||
pt.url as last_target_url
|
||||
FROM pubnet_status ps
|
||||
LEFT JOIN pubnet_targets pt ON ps.last_target_id = pt.id
|
||||
WHERE ps.id = 1
|
||||
`).get()
|
||||
|
||||
// 최근 10개 로그
|
||||
const recentLogs = db.prepare(`
|
||||
SELECT
|
||||
pl.*,
|
||||
pt.name as target_name,
|
||||
pt.url as target_url
|
||||
FROM pubnet_logs pl
|
||||
JOIN pubnet_targets pt ON pl.target_id = pt.id
|
||||
ORDER BY pl.checked_at DESC
|
||||
LIMIT 10
|
||||
`).all()
|
||||
|
||||
// 활성 타겟 수
|
||||
const targetCount = db.prepare(`
|
||||
SELECT COUNT(*) as cnt FROM pubnet_targets WHERE is_active = 1
|
||||
`).get() as { cnt: number }
|
||||
|
||||
return {
|
||||
status,
|
||||
recentLogs,
|
||||
targetCount: targetCount.cnt,
|
||||
schedulerRunning: pubnetScheduler.getIsRunning()
|
||||
}
|
||||
})
|
||||
10
backend/api/network/pubnet/targets/[id].delete.ts
Normal file
10
backend/api/network/pubnet/targets/[id].delete.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { getDb } from '../../../../utils/db'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const db = getDb()
|
||||
const id = getRouterParam(event, 'id')
|
||||
|
||||
db.prepare(`DELETE FROM pubnet_targets WHERE id = ?`).run(id)
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
29
backend/api/network/pubnet/targets/[id].put.ts
Normal file
29
backend/api/network/pubnet/targets/[id].put.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { getDb } from '../../../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const db = getDb()
|
||||
const id = getRouterParam(event, 'id')
|
||||
const body = await readBody(event)
|
||||
|
||||
const { name, url, is_active } = body
|
||||
|
||||
if (!name || !url) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'name과 url은 필수입니다'
|
||||
})
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE pubnet_targets
|
||||
SET name = ?, url = ?, is_active = ?, updated_at = datetime('now', 'localtime')
|
||||
WHERE id = ?
|
||||
`).run(name, url, is_active ? 1 : 0, id)
|
||||
|
||||
return {
|
||||
id: Number(id),
|
||||
name,
|
||||
url,
|
||||
is_active: is_active ? 1 : 0
|
||||
}
|
||||
})
|
||||
12
backend/api/network/pubnet/targets/index.get.ts
Normal file
12
backend/api/network/pubnet/targets/index.get.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { getDb } from '../../../../utils/db'
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
const db = getDb()
|
||||
|
||||
const targets = db.prepare(`
|
||||
SELECT * FROM pubnet_targets
|
||||
ORDER BY id ASC
|
||||
`).all()
|
||||
|
||||
return targets
|
||||
})
|
||||
27
backend/api/network/pubnet/targets/index.post.ts
Normal file
27
backend/api/network/pubnet/targets/index.post.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { getDb } from '../../../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const db = getDb()
|
||||
const body = await readBody(event)
|
||||
|
||||
const { name, url, is_active } = body
|
||||
|
||||
if (!name || !url) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'name과 url은 필수입니다'
|
||||
})
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO pubnet_targets (name, url, is_active)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(name, url, is_active ? 1 : 0)
|
||||
|
||||
return {
|
||||
id: result.lastInsertRowid,
|
||||
name,
|
||||
url,
|
||||
is_active: is_active ? 1 : 0
|
||||
}
|
||||
})
|
||||
30
backend/api/server/history/container-list.get.ts
Normal file
30
backend/api/server/history/container-list.get.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const query = getQuery(event)
|
||||
const targetId = query.target_id as string
|
||||
|
||||
if (!targetId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'target_id is required'
|
||||
})
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
|
||||
// 최신 수집 시간 기준 컨테이너 목록
|
||||
const containers = db.prepare(`
|
||||
SELECT DISTINCT container_name
|
||||
FROM server_containers
|
||||
WHERE target_id = ?
|
||||
AND collected_at = (
|
||||
SELECT MAX(collected_at)
|
||||
FROM server_containers
|
||||
WHERE target_id = ?
|
||||
)
|
||||
ORDER BY container_name ASC
|
||||
`).all(targetId, targetId)
|
||||
|
||||
return containers.map((c: any) => c.container_name)
|
||||
})
|
||||
57
backend/api/server/history/containers.get.ts
Normal file
57
backend/api/server/history/containers.get.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const query = getQuery(event)
|
||||
const targetId = query.target_id as string
|
||||
const period = (query.period as string) || '1h'
|
||||
|
||||
if (!targetId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'target_id is required'
|
||||
})
|
||||
}
|
||||
|
||||
const periodMap: Record<string, string> = {
|
||||
'1h': '-1 hour',
|
||||
'2h': '-2 hours',
|
||||
'3h': '-3 hours',
|
||||
'4h': '-4 hours',
|
||||
'5h': '-5 hours',
|
||||
'6h': '-6 hours',
|
||||
'12h': '-12 hours',
|
||||
'18h': '-18 hours',
|
||||
'24h': '-24 hours',
|
||||
'7d': '-7 days',
|
||||
'30d': '-30 days'
|
||||
}
|
||||
|
||||
const timeOffset = periodMap[period] || '-1 hour'
|
||||
|
||||
const db = getDb()
|
||||
|
||||
const containers = db.prepare(`
|
||||
SELECT
|
||||
container_id,
|
||||
container_name,
|
||||
container_status,
|
||||
cpu_percent,
|
||||
memory_usage,
|
||||
memory_limit,
|
||||
memory_percent,
|
||||
uptime,
|
||||
network_rx,
|
||||
network_tx,
|
||||
collected_at
|
||||
FROM server_containers
|
||||
WHERE target_id = ?
|
||||
AND collected_at >= datetime('now', 'localtime', ?)
|
||||
ORDER BY collected_at ASC, container_name ASC
|
||||
`).all(targetId, timeOffset)
|
||||
|
||||
return {
|
||||
target_id: targetId,
|
||||
period,
|
||||
data: containers
|
||||
}
|
||||
})
|
||||
29
backend/api/server/history/disk-list.get.ts
Normal file
29
backend/api/server/history/disk-list.get.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const query = getQuery(event)
|
||||
const targetId = query.target_id as string
|
||||
|
||||
if (!targetId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'target_id is required'
|
||||
})
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
|
||||
// 최신 수집 시간 기준 디스크 목록 (물리 디스크만)
|
||||
const disks = db.prepare(`
|
||||
SELECT DISTINCT device_name, mount_point, fs_type, disk_total, disk_used, disk_percent
|
||||
FROM server_disks
|
||||
WHERE target_id = ?
|
||||
AND collected_at = (SELECT MAX(collected_at) FROM server_disks WHERE target_id = ?)
|
||||
AND device_name NOT LIKE '%loop%'
|
||||
AND mount_point NOT LIKE '%/snap%'
|
||||
AND fs_type NOT IN ('tmpfs', 'squashfs', 'overlay')
|
||||
ORDER BY mount_point ASC
|
||||
`).all(targetId, targetId)
|
||||
|
||||
return disks
|
||||
})
|
||||
54
backend/api/server/history/disks.get.ts
Normal file
54
backend/api/server/history/disks.get.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const query = getQuery(event)
|
||||
const targetId = query.target_id as string
|
||||
const period = (query.period as string) || '1h'
|
||||
|
||||
if (!targetId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'target_id is required'
|
||||
})
|
||||
}
|
||||
|
||||
const periodMap: Record<string, string> = {
|
||||
'1h': '-1 hour',
|
||||
'2h': '-2 hours',
|
||||
'3h': '-3 hours',
|
||||
'4h': '-4 hours',
|
||||
'5h': '-5 hours',
|
||||
'6h': '-6 hours',
|
||||
'12h': '-12 hours',
|
||||
'18h': '-18 hours',
|
||||
'24h': '-24 hours',
|
||||
'7d': '-7 days',
|
||||
'30d': '-30 days'
|
||||
}
|
||||
|
||||
const timeOffset = periodMap[period] || '-1 hour'
|
||||
|
||||
const db = getDb()
|
||||
|
||||
const disks = db.prepare(`
|
||||
SELECT
|
||||
disk_id,
|
||||
device_name,
|
||||
mount_point,
|
||||
fs_type,
|
||||
disk_total,
|
||||
disk_used,
|
||||
disk_percent,
|
||||
collected_at
|
||||
FROM server_disks
|
||||
WHERE target_id = ?
|
||||
AND collected_at >= datetime('now', 'localtime', ?)
|
||||
ORDER BY collected_at ASC, mount_point ASC
|
||||
`).all(targetId, timeOffset)
|
||||
|
||||
return {
|
||||
target_id: targetId,
|
||||
period,
|
||||
data: disks
|
||||
}
|
||||
})
|
||||
32
backend/api/server/history/latest.get.ts
Normal file
32
backend/api/server/history/latest.get.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const query = getQuery(event)
|
||||
const targetId = query.target_id as string
|
||||
|
||||
if (!targetId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'target_id is required'
|
||||
})
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
|
||||
// 최신 스냅샷
|
||||
const snapshot = db.prepare(`
|
||||
SELECT
|
||||
s.*,
|
||||
t.server_name,
|
||||
t.server_ip,
|
||||
t.glances_url,
|
||||
t.collect_interval
|
||||
FROM server_snapshots s
|
||||
JOIN server_targets t ON s.target_id = t.target_id
|
||||
WHERE s.target_id = ?
|
||||
ORDER BY s.collected_at DESC
|
||||
LIMIT 1
|
||||
`).get(targetId)
|
||||
|
||||
return snapshot || null
|
||||
})
|
||||
54
backend/api/server/history/networks.get.ts
Normal file
54
backend/api/server/history/networks.get.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const query = getQuery(event)
|
||||
const targetId = query.target_id as string
|
||||
const period = (query.period as string) || '1h'
|
||||
|
||||
if (!targetId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'target_id is required'
|
||||
})
|
||||
}
|
||||
|
||||
const periodMap: Record<string, string> = {
|
||||
'1h': '-1 hour',
|
||||
'2h': '-2 hours',
|
||||
'3h': '-3 hours',
|
||||
'4h': '-4 hours',
|
||||
'5h': '-5 hours',
|
||||
'6h': '-6 hours',
|
||||
'12h': '-12 hours',
|
||||
'18h': '-18 hours',
|
||||
'24h': '-24 hours',
|
||||
'7d': '-7 days',
|
||||
'30d': '-30 days'
|
||||
}
|
||||
|
||||
const timeOffset = periodMap[period] || '-1 hour'
|
||||
|
||||
const db = getDb()
|
||||
|
||||
const networks = db.prepare(`
|
||||
SELECT
|
||||
network_id,
|
||||
interface_name,
|
||||
bytes_recv,
|
||||
bytes_sent,
|
||||
speed_recv,
|
||||
speed_sent,
|
||||
is_up,
|
||||
collected_at
|
||||
FROM server_networks
|
||||
WHERE target_id = ?
|
||||
AND collected_at >= datetime('now', 'localtime', ?)
|
||||
ORDER BY collected_at ASC, interface_name ASC
|
||||
`).all(targetId, timeOffset)
|
||||
|
||||
return {
|
||||
target_id: targetId,
|
||||
period,
|
||||
data: networks
|
||||
}
|
||||
})
|
||||
59
backend/api/server/history/snapshots.get.ts
Normal file
59
backend/api/server/history/snapshots.get.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const query = getQuery(event)
|
||||
const targetId = query.target_id as string
|
||||
const period = (query.period as string) || '1h'
|
||||
|
||||
if (!targetId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'target_id is required'
|
||||
})
|
||||
}
|
||||
|
||||
// 기간별 시간 계산
|
||||
const periodMap: Record<string, string> = {
|
||||
'1h': '-1 hour',
|
||||
'2h': '-2 hours',
|
||||
'3h': '-3 hours',
|
||||
'4h': '-4 hours',
|
||||
'5h': '-5 hours',
|
||||
'6h': '-6 hours',
|
||||
'12h': '-12 hours',
|
||||
'18h': '-18 hours',
|
||||
'24h': '-24 hours',
|
||||
'7d': '-7 days',
|
||||
'30d': '-30 days'
|
||||
}
|
||||
|
||||
const timeOffset = periodMap[period] || '-1 hour'
|
||||
|
||||
const db = getDb()
|
||||
|
||||
const snapshots = db.prepare(`
|
||||
SELECT
|
||||
snapshot_id,
|
||||
cpu_percent,
|
||||
cpu_temp,
|
||||
load_percent,
|
||||
memory_percent,
|
||||
memory_used,
|
||||
memory_total,
|
||||
swap_percent,
|
||||
swap_used,
|
||||
swap_total,
|
||||
is_online,
|
||||
collected_at
|
||||
FROM server_snapshots
|
||||
WHERE target_id = ?
|
||||
AND collected_at >= datetime('now', 'localtime', ?)
|
||||
ORDER BY collected_at ASC
|
||||
`).all(targetId, timeOffset)
|
||||
|
||||
return {
|
||||
target_id: targetId,
|
||||
period,
|
||||
data: snapshots
|
||||
}
|
||||
})
|
||||
6
backend/api/server/scheduler/start.post.ts
Normal file
6
backend/api/server/scheduler/start.post.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { startServerScheduler } from '../../../utils/server-scheduler'
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
startServerScheduler()
|
||||
return { success: true, message: 'Server scheduler started' }
|
||||
})
|
||||
6
backend/api/server/scheduler/stop.post.ts
Normal file
6
backend/api/server/scheduler/stop.post.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { stopServerScheduler } from '../../../utils/server-scheduler'
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
stopServerScheduler()
|
||||
return { success: true, message: 'Server scheduler stopped' }
|
||||
})
|
||||
5
backend/api/server/status.get.ts
Normal file
5
backend/api/server/status.get.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { getServerSchedulerStatus } from '../../utils/server-scheduler'
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
return getServerSchedulerStatus()
|
||||
})
|
||||
27
backend/api/server/targets/[id].delete.ts
Normal file
27
backend/api/server/targets/[id].delete.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
import { refreshServerTimer } from '../../../utils/server-scheduler'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const targetId = getRouterParam(event, 'id')
|
||||
|
||||
if (!targetId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'target_id is required'
|
||||
})
|
||||
}
|
||||
|
||||
// 스케줄러에서 제거
|
||||
refreshServerTimer(Number(targetId))
|
||||
|
||||
const db = getDb()
|
||||
|
||||
const result = db.prepare(`
|
||||
DELETE FROM server_targets WHERE target_id = ?
|
||||
`).run(targetId)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
changes: result.changes
|
||||
}
|
||||
})
|
||||
36
backend/api/server/targets/[id].put.ts
Normal file
36
backend/api/server/targets/[id].put.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
import { refreshServerTimer } from '../../../utils/server-scheduler'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const targetId = getRouterParam(event, 'id')
|
||||
const body = await readBody(event)
|
||||
const { server_name, server_ip, glances_url, is_active, collect_interval } = body
|
||||
|
||||
if (!targetId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'target_id is required'
|
||||
})
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
|
||||
const result = db.prepare(`
|
||||
UPDATE server_targets
|
||||
SET server_name = ?,
|
||||
server_ip = ?,
|
||||
glances_url = ?,
|
||||
is_active = ?,
|
||||
collect_interval = ?,
|
||||
updated_at = datetime('now', 'localtime')
|
||||
WHERE target_id = ?
|
||||
`).run(server_name, server_ip, glances_url, is_active ? 1 : 0, collect_interval || 60, targetId)
|
||||
|
||||
// 스케줄러에 반영
|
||||
refreshServerTimer(Number(targetId))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
changes: result.changes
|
||||
}
|
||||
})
|
||||
12
backend/api/server/targets/index.get.ts
Normal file
12
backend/api/server/targets/index.get.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
const db = getDb()
|
||||
|
||||
const targets = db.prepare(`
|
||||
SELECT * FROM server_targets
|
||||
ORDER BY target_id ASC
|
||||
`).all()
|
||||
|
||||
return targets
|
||||
})
|
||||
33
backend/api/server/targets/index.post.ts
Normal file
33
backend/api/server/targets/index.post.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
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
|
||||
|
||||
if (!server_name || !server_ip || !glances_url) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'server_name, server_ip, glances_url are required'
|
||||
})
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO server_targets (server_name, server_ip, glances_url, is_active, collect_interval)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(server_name, server_ip, glances_url, is_active ? 1 : 0, collect_interval)
|
||||
|
||||
const targetId = result.lastInsertRowid as number
|
||||
|
||||
// 스케줄러에 반영
|
||||
if (is_active) {
|
||||
refreshServerTimer(targetId)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
target_id: targetId
|
||||
}
|
||||
})
|
||||
27
backend/api/settings/thresholds.get.ts
Normal file
27
backend/api/settings/thresholds.get.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { getDb } from '../../utils/db'
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
const db = getDb()
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT category, metric, warning, critical, danger, updated_at
|
||||
FROM thresholds
|
||||
ORDER BY category, metric
|
||||
`).all() as any[]
|
||||
|
||||
// 카테고리별로 그룹화
|
||||
const result: Record<string, Record<string, { warning: number; critical: number; danger: number }>> = {}
|
||||
|
||||
for (const row of rows) {
|
||||
if (!result[row.category]) {
|
||||
result[row.category] = {}
|
||||
}
|
||||
result[row.category][row.metric] = {
|
||||
warning: row.warning,
|
||||
critical: row.critical,
|
||||
danger: row.danger
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
54
backend/api/settings/thresholds.put.ts
Normal file
54
backend/api/settings/thresholds.put.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { getDb } from '../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
|
||||
if (!body || typeof body !== 'object') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Invalid request body'
|
||||
})
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const now = new Date().toLocaleString('sv-SE', { timeZone: 'Asia/Seoul' }).replace('T', ' ')
|
||||
|
||||
const stmt = db.prepare(`
|
||||
UPDATE thresholds
|
||||
SET warning = ?, critical = ?, danger = ?, updated_at = ?
|
||||
WHERE category = ? AND metric = ?
|
||||
`)
|
||||
|
||||
let updated = 0
|
||||
|
||||
for (const [category, metrics] of Object.entries(body)) {
|
||||
if (typeof metrics !== 'object') continue
|
||||
|
||||
for (const [metric, values] of Object.entries(metrics as Record<string, any>)) {
|
||||
if (!values || typeof values !== 'object') continue
|
||||
|
||||
const { warning, critical, danger } = values
|
||||
|
||||
// 유효성 검사
|
||||
if (typeof warning !== 'number' || typeof critical !== 'number' || typeof danger !== 'number') {
|
||||
continue
|
||||
}
|
||||
|
||||
if (warning < 0 || warning > 100 || critical < 0 || critical > 100 || danger < 0 || danger > 100) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (warning >= critical || critical >= danger) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: `Invalid thresholds for ${category}.${metric}: warning < critical < danger 순서여야 합니다.`
|
||||
})
|
||||
}
|
||||
|
||||
const result = stmt.run(warning, critical, danger, now, category, metric)
|
||||
if (result.changes > 0) updated++
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, updated }
|
||||
})
|
||||
20
backend/plugins/privnet-init.ts
Normal file
20
backend/plugins/privnet-init.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { initPrivnetTables } from '../utils/db'
|
||||
import { privnetScheduler } from '../utils/privnet-scheduler'
|
||||
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
console.log('[Plugin] privnet-init starting...')
|
||||
|
||||
// DB 테이블 초기화
|
||||
initPrivnetTables()
|
||||
|
||||
// 스케줄러 자동 시작
|
||||
privnetScheduler.start()
|
||||
|
||||
// 서버 종료 시 클린업
|
||||
nitroApp.hooks.hook('close', () => {
|
||||
console.log('[Plugin] Shutting down privnet scheduler...')
|
||||
privnetScheduler.stop()
|
||||
})
|
||||
|
||||
console.log('[Plugin] privnet-init completed')
|
||||
})
|
||||
20
backend/plugins/pubnet-init.ts
Normal file
20
backend/plugins/pubnet-init.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { initPubnetTables } from '../utils/db'
|
||||
import { pubnetScheduler } from '../utils/pubnet-scheduler'
|
||||
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
console.log('[Plugin] pubnet-init starting...')
|
||||
|
||||
// DB 테이블 초기화
|
||||
initPubnetTables()
|
||||
|
||||
// 스케줄러 자동 시작
|
||||
pubnetScheduler.start()
|
||||
|
||||
// 서버 종료 시 클린업
|
||||
nitroApp.hooks.hook('close', () => {
|
||||
console.log('[Plugin] Shutting down pubnet scheduler...')
|
||||
pubnetScheduler.stop()
|
||||
})
|
||||
|
||||
console.log('[Plugin] pubnet-init completed')
|
||||
})
|
||||
8
backend/plugins/server-init.ts
Normal file
8
backend/plugins/server-init.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { startServerScheduler } from '../utils/server-scheduler'
|
||||
|
||||
export default defineNitroPlugin(() => {
|
||||
// 서버 시작 시 스케줄러 자동 시작
|
||||
startServerScheduler()
|
||||
|
||||
console.log('[Server] Plugin initialized - scheduler auto-started')
|
||||
})
|
||||
411
backend/routes/_ws.ts
Normal file
411
backend/routes/_ws.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
import { getDb } from '../utils/db'
|
||||
|
||||
interface Client {
|
||||
ws: any
|
||||
interval: number
|
||||
timer: ReturnType<typeof setInterval> | null
|
||||
autoRefresh: boolean
|
||||
}
|
||||
|
||||
const clients = new Map<any, Client>()
|
||||
|
||||
function getNetworkStatus() {
|
||||
const db = getDb()
|
||||
|
||||
// pubnet 상태
|
||||
const pubnetStatus = db.prepare(`
|
||||
SELECT
|
||||
ps.*,
|
||||
pt.name as last_target_name,
|
||||
pt.url as last_target_url
|
||||
FROM pubnet_status ps
|
||||
LEFT JOIN pubnet_targets pt ON ps.last_target_id = pt.id
|
||||
WHERE ps.id = 1
|
||||
`).get()
|
||||
|
||||
const pubnetLogs = db.prepare(`
|
||||
SELECT
|
||||
pl.*,
|
||||
pt.name as target_name,
|
||||
pt.url as target_url
|
||||
FROM pubnet_logs pl
|
||||
JOIN pubnet_targets pt ON pl.target_id = pt.id
|
||||
ORDER BY pl.checked_at DESC
|
||||
LIMIT 5
|
||||
`).all()
|
||||
|
||||
// privnet 상태
|
||||
const privnetStatus = db.prepare(`
|
||||
SELECT
|
||||
ps.*,
|
||||
pt.name as last_target_name,
|
||||
pt.url as last_target_url
|
||||
FROM privnet_status ps
|
||||
LEFT JOIN privnet_targets pt ON ps.last_target_id = pt.id
|
||||
WHERE ps.id = 1
|
||||
`).get()
|
||||
|
||||
const privnetLogs = db.prepare(`
|
||||
SELECT
|
||||
pl.*,
|
||||
pt.name as target_name,
|
||||
pt.url as target_url
|
||||
FROM privnet_logs pl
|
||||
JOIN privnet_targets pt ON pl.target_id = pt.id
|
||||
ORDER BY pl.checked_at DESC
|
||||
LIMIT 5
|
||||
`).all()
|
||||
|
||||
return {
|
||||
pubnet: {
|
||||
status: pubnetStatus,
|
||||
logs: pubnetLogs
|
||||
},
|
||||
privnet: {
|
||||
status: privnetStatus,
|
||||
logs: privnetLogs
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
function getHistoricalData(datetime: string) {
|
||||
const db = getDb()
|
||||
|
||||
// 특정 시간 이전의 로그 조회
|
||||
const pubnetLogs = db.prepare(`
|
||||
SELECT
|
||||
pl.*,
|
||||
pt.name as target_name,
|
||||
pt.url as target_url
|
||||
FROM pubnet_logs pl
|
||||
JOIN pubnet_targets pt ON pl.target_id = pt.id
|
||||
WHERE pl.checked_at <= @datetime
|
||||
ORDER BY pl.checked_at DESC
|
||||
LIMIT 5
|
||||
`).all({ datetime })
|
||||
|
||||
const privnetLogs = db.prepare(`
|
||||
SELECT
|
||||
pl.*,
|
||||
pt.name as target_name,
|
||||
pt.url as target_url
|
||||
FROM privnet_logs pl
|
||||
JOIN privnet_targets pt ON pl.target_id = pt.id
|
||||
WHERE pl.checked_at <= @datetime
|
||||
ORDER BY pl.checked_at DESC
|
||||
LIMIT 5
|
||||
`).all({ datetime })
|
||||
|
||||
// 해당 시점의 최신 상태 (로그 기준)
|
||||
const pubnetLatest = pubnetLogs[0] || null
|
||||
const privnetLatest = privnetLogs[0] || null
|
||||
|
||||
return {
|
||||
pubnet: {
|
||||
status: pubnetLatest ? {
|
||||
is_healthy: pubnetLatest.is_success,
|
||||
last_checked_at: pubnetLatest.checked_at,
|
||||
last_target_name: pubnetLatest.target_name
|
||||
} : null,
|
||||
logs: pubnetLogs
|
||||
},
|
||||
privnet: {
|
||||
status: privnetLatest ? {
|
||||
is_healthy: privnetLatest.is_success,
|
||||
last_checked_at: privnetLatest.checked_at,
|
||||
last_target_name: privnetLatest.target_name
|
||||
} : null,
|
||||
logs: privnetLogs
|
||||
},
|
||||
timestamp: datetime
|
||||
}
|
||||
}
|
||||
|
||||
// 임계값 조회
|
||||
function getThresholds() {
|
||||
const db = getDb()
|
||||
const rows = db.prepare(`SELECT category, metric, warning, critical, danger FROM thresholds`).all() as any[]
|
||||
|
||||
const result: Record<string, Record<string, { warning: number; critical: number; danger: number }>> = {}
|
||||
for (const row of rows) {
|
||||
if (!result[row.category]) result[row.category] = {}
|
||||
result[row.category][row.metric] = { warning: row.warning, critical: row.critical, danger: row.danger }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// 레벨 계산
|
||||
function getLevel(value: number | null, threshold: { warning: number; critical: number; danger: number }): string {
|
||||
if (value === null || value === undefined) return 'normal'
|
||||
if (value >= threshold.danger) return 'danger'
|
||||
if (value >= threshold.critical) return 'critical'
|
||||
if (value >= threshold.warning) return 'warning'
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
// 레벨 우선순위
|
||||
const levelPriority: Record<string, number> = { normal: 0, warning: 1, critical: 2, danger: 3, offline: 4, stopped: 3 }
|
||||
|
||||
function getHighestLevel(levels: string[]): string {
|
||||
let highest = 'normal'
|
||||
for (const level of levels) {
|
||||
if ((levelPriority[level] || 0) > (levelPriority[highest] || 0)) {
|
||||
highest = level
|
||||
}
|
||||
}
|
||||
return highest
|
||||
}
|
||||
|
||||
// 서버 대시보드 데이터
|
||||
function getServerDashboard() {
|
||||
const db = getDb()
|
||||
const thresholds = getThresholds()
|
||||
const now = new Date()
|
||||
const offlineThreshold = 5 * 60 * 1000 // 5분
|
||||
|
||||
// 서버 목록
|
||||
const servers = db.prepare(`SELECT target_id, server_name, is_active FROM server_targets WHERE is_active = 1 ORDER BY server_name`).all() as any[]
|
||||
|
||||
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 = db.prepare(`
|
||||
SELECT cpu_percent, memory_percent, collected_at
|
||||
FROM server_snapshots
|
||||
WHERE target_id = ?
|
||||
ORDER BY collected_at DESC
|
||||
LIMIT 1
|
||||
`).get(server.target_id) as any
|
||||
|
||||
// 디스크 정보 조회 (루트 마운트 또는 최대 사용률)
|
||||
const disk = db.prepare(`
|
||||
SELECT disk_percent
|
||||
FROM server_disks
|
||||
WHERE target_id = ?
|
||||
ORDER BY
|
||||
CASE WHEN mount_point = '/' THEN 0 ELSE 1 END,
|
||||
disk_percent DESC
|
||||
LIMIT 1
|
||||
`).get(server.target_id) as any
|
||||
|
||||
// 오프라인 체크
|
||||
let isOffline = true
|
||||
let lastCollected = null
|
||||
if (snapshot && snapshot.collected_at) {
|
||||
lastCollected = snapshot.collected_at
|
||||
const collectedTime = new Date(snapshot.collected_at.replace(' ', 'T') + '+09:00').getTime()
|
||||
isOffline = (now.getTime() - collectedTime) > offlineThreshold
|
||||
}
|
||||
|
||||
// 서버 레벨 계산
|
||||
let serverLevel = 'offline'
|
||||
let cpuLevel = 'normal', memLevel = 'normal', diskLevel = 'normal'
|
||||
|
||||
if (!isOffline && snapshot) {
|
||||
cpuLevel = getLevel(snapshot.cpu_percent, thresholds.server?.cpu || { warning: 70, critical: 85, danger: 95 })
|
||||
memLevel = getLevel(snapshot.memory_percent, thresholds.server?.memory || { warning: 80, critical: 90, danger: 95 })
|
||||
diskLevel = getLevel(disk?.disk_percent ?? null, thresholds.server?.disk || { warning: 80, critical: 90, danger: 95 })
|
||||
serverLevel = getHighestLevel([cpuLevel, memLevel, diskLevel])
|
||||
}
|
||||
|
||||
// 컨테이너 조회 (최신 데이터, 중복 제거)
|
||||
const containers = db.prepare(`
|
||||
SELECT container_name, container_status, cpu_percent, memory_usage, memory_limit, uptime, network_rx, network_tx
|
||||
FROM server_containers
|
||||
WHERE target_id = ? AND collected_at = (
|
||||
SELECT MAX(collected_at) FROM server_containers WHERE target_id = ?
|
||||
)
|
||||
GROUP BY container_name
|
||||
ORDER BY container_name
|
||||
`).all(server.target_id, server.target_id) as any[]
|
||||
|
||||
const containerStatuses: any[] = []
|
||||
const containerSummary = { total: containers.length, normal: 0, warning: 0, critical: 0, danger: 0, stopped: 0 }
|
||||
|
||||
for (const c of containers) {
|
||||
let containerLevel = 'normal'
|
||||
|
||||
if (c.container_status !== 'running') {
|
||||
containerLevel = 'stopped'
|
||||
containerSummary.stopped++
|
||||
} else {
|
||||
const cCpuLevel = getLevel(c.cpu_percent, thresholds.container?.cpu || { warning: 80, critical: 90, danger: 95 })
|
||||
const cMemPercent = c.memory_limit ? (c.memory_usage / c.memory_limit * 100) : null
|
||||
const cMemLevel = getLevel(cMemPercent, thresholds.container?.memory || { warning: 80, critical: 90, danger: 95 })
|
||||
containerLevel = getHighestLevel([cCpuLevel, cMemLevel])
|
||||
|
||||
if (containerLevel === 'normal') containerSummary.normal++
|
||||
else if (containerLevel === 'warning') containerSummary.warning++
|
||||
else if (containerLevel === 'critical') containerSummary.critical++
|
||||
else if (containerLevel === 'danger') containerSummary.danger++
|
||||
}
|
||||
|
||||
containerStatuses.push({
|
||||
name: c.container_name,
|
||||
status: c.container_status,
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
// 요약 집계
|
||||
summaryContainers.total += containerSummary.total
|
||||
summaryContainers.normal += containerSummary.normal
|
||||
summaryContainers.warning += containerSummary.warning
|
||||
summaryContainers.critical += containerSummary.critical
|
||||
summaryContainers.danger += containerSummary.danger
|
||||
summaryContainers.stopped += containerSummary.stopped
|
||||
|
||||
if (serverLevel === 'offline') summaryServers.offline++
|
||||
else if (serverLevel === 'danger') summaryServers.danger++
|
||||
else if (serverLevel === 'critical') summaryServers.critical++
|
||||
else if (serverLevel === 'warning') summaryServers.warning++
|
||||
else summaryServers.normal++
|
||||
|
||||
serverStatuses.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
level: serverLevel,
|
||||
cpu_percent: snapshot?.cpu_percent ?? null,
|
||||
cpu_level: cpuLevel,
|
||||
memory_percent: snapshot?.memory_percent ?? null,
|
||||
memory_level: memLevel,
|
||||
disk_percent: disk?.disk_percent ?? null,
|
||||
disk_level: diskLevel,
|
||||
last_collected: lastCollected,
|
||||
containers: containerStatuses,
|
||||
container_summary: containerSummary
|
||||
})
|
||||
}
|
||||
|
||||
// 서버 정렬: 컨테이너 많은 순 → 이름 순
|
||||
serverStatuses.sort((a, b) => {
|
||||
const containerDiff = (b.container_summary?.total || 0) - (a.container_summary?.total || 0)
|
||||
if (containerDiff !== 0) return containerDiff
|
||||
return a.server_name.localeCompare(b.server_name)
|
||||
})
|
||||
|
||||
return {
|
||||
summary: { servers: summaryServers, containers: summaryContainers },
|
||||
servers: serverStatuses,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
function startAutoRefresh(client: Client) {
|
||||
if (client.timer) {
|
||||
clearInterval(client.timer)
|
||||
}
|
||||
|
||||
if (client.autoRefresh) {
|
||||
client.timer = setInterval(() => {
|
||||
const data = getNetworkStatus()
|
||||
client.ws.send(JSON.stringify({ type: 'status', data }))
|
||||
|
||||
// 서버 대시보드 데이터도 전송
|
||||
const serverData = getServerDashboard()
|
||||
client.ws.send(JSON.stringify({ type: 'server', data: serverData }))
|
||||
}, client.interval)
|
||||
}
|
||||
}
|
||||
|
||||
export default defineWebSocketHandler({
|
||||
open(peer) {
|
||||
console.log('[WebSocket] Client connected')
|
||||
|
||||
const client: Client = {
|
||||
ws: peer,
|
||||
interval: 60 * 1000, // 기본 1분
|
||||
timer: null,
|
||||
autoRefresh: true
|
||||
}
|
||||
|
||||
clients.set(peer, client)
|
||||
|
||||
// 초기 데이터 전송
|
||||
const data = getNetworkStatus()
|
||||
peer.send(JSON.stringify({ type: 'status', data }))
|
||||
|
||||
// 서버 대시보드 데이터 전송
|
||||
const serverData = getServerDashboard()
|
||||
peer.send(JSON.stringify({ type: 'server', data: serverData }))
|
||||
|
||||
// 자동 갱신 시작
|
||||
startAutoRefresh(client)
|
||||
},
|
||||
|
||||
message(peer, message) {
|
||||
const client = clients.get(peer)
|
||||
if (!client) return
|
||||
|
||||
try {
|
||||
const msg = JSON.parse(message.text())
|
||||
|
||||
switch (msg.type) {
|
||||
case 'set_interval':
|
||||
// 간격 변경 (분 단위로 받음)
|
||||
client.interval = msg.interval * 60 * 1000
|
||||
console.log(`[WebSocket] Interval changed to ${msg.interval} min`)
|
||||
startAutoRefresh(client)
|
||||
break
|
||||
|
||||
case 'set_auto_refresh':
|
||||
// 자동 갱신 ON/OFF
|
||||
client.autoRefresh = msg.enabled
|
||||
console.log(`[WebSocket] Auto refresh: ${msg.enabled}`)
|
||||
if (msg.enabled) {
|
||||
startAutoRefresh(client)
|
||||
} else if (client.timer) {
|
||||
clearInterval(client.timer)
|
||||
client.timer = null
|
||||
}
|
||||
break
|
||||
|
||||
case 'fetch_at':
|
||||
// 특정 시간 데이터 조회
|
||||
const historicalData = getHistoricalData(msg.datetime)
|
||||
peer.send(JSON.stringify({ type: 'historical', data: historicalData }))
|
||||
break
|
||||
|
||||
case 'refresh':
|
||||
// 즉시 갱신 요청
|
||||
const currentData = getNetworkStatus()
|
||||
peer.send(JSON.stringify({ type: 'status', data: currentData }))
|
||||
// 서버 데이터도 전송
|
||||
const currentServerData = getServerDashboard()
|
||||
peer.send(JSON.stringify({ type: 'server', data: currentServerData }))
|
||||
break
|
||||
|
||||
case 'refresh_server':
|
||||
// 서버 대시보드만 즉시 갱신
|
||||
const serverDashData = getServerDashboard()
|
||||
peer.send(JSON.stringify({ type: 'server', data: serverDashData }))
|
||||
break
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WebSocket] Message parse error:', err)
|
||||
}
|
||||
},
|
||||
|
||||
close(peer) {
|
||||
console.log('[WebSocket] Client disconnected')
|
||||
const client = clients.get(peer)
|
||||
if (client?.timer) {
|
||||
clearInterval(client.timer)
|
||||
}
|
||||
clients.delete(peer)
|
||||
},
|
||||
|
||||
error(peer, error) {
|
||||
console.error('[WebSocket] Error:', error)
|
||||
}
|
||||
})
|
||||
113
backend/utils/db.ts
Normal file
113
backend/utils/db.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import { resolve } from 'path'
|
||||
|
||||
// 싱글톤 DB 인스턴스
|
||||
let db: Database.Database | null = null
|
||||
|
||||
export function getDb(): Database.Database {
|
||||
if (!db) {
|
||||
const dbPath = resolve(process.cwd(), 'database/osolit-monitor.db')
|
||||
db = new Database(dbPath)
|
||||
db.pragma('journal_mode = WAL')
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
export function initPubnetTables(): void {
|
||||
const db = getDb()
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS pubnet_targets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now', 'localtime')),
|
||||
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||
)
|
||||
`)
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS pubnet_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
target_id INTEGER NOT NULL,
|
||||
is_success INTEGER NOT NULL,
|
||||
checked_at TEXT DEFAULT (datetime('now', 'localtime')),
|
||||
FOREIGN KEY (target_id) REFERENCES pubnet_targets(id)
|
||||
)
|
||||
`)
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS pubnet_status (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
current_index INTEGER DEFAULT 0,
|
||||
check_interval INTEGER DEFAULT 300000,
|
||||
is_healthy INTEGER DEFAULT 1,
|
||||
last_target_id INTEGER,
|
||||
last_checked_at TEXT,
|
||||
scheduler_running INTEGER DEFAULT 0,
|
||||
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||
)
|
||||
`)
|
||||
|
||||
const statusExists = db.prepare('SELECT COUNT(*) as cnt FROM pubnet_status').get() as { cnt: number }
|
||||
if (statusExists.cnt === 0) {
|
||||
db.prepare('INSERT INTO pubnet_status (id) VALUES (1)').run()
|
||||
}
|
||||
|
||||
console.log('[DB] pubnet tables initialized')
|
||||
}
|
||||
|
||||
export function initPrivnetTables(): void {
|
||||
const db = getDb()
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS privnet_targets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now', 'localtime')),
|
||||
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||
)
|
||||
`)
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS privnet_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
target_id INTEGER NOT NULL,
|
||||
is_success INTEGER NOT NULL,
|
||||
checked_at TEXT DEFAULT (datetime('now', 'localtime')),
|
||||
FOREIGN KEY (target_id) REFERENCES privnet_targets(id)
|
||||
)
|
||||
`)
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS privnet_status (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
current_index INTEGER DEFAULT 0,
|
||||
check_interval INTEGER DEFAULT 300000,
|
||||
is_healthy INTEGER DEFAULT 1,
|
||||
last_target_id INTEGER,
|
||||
last_checked_at TEXT,
|
||||
scheduler_running INTEGER DEFAULT 0,
|
||||
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||
)
|
||||
`)
|
||||
|
||||
const statusExists = db.prepare('SELECT COUNT(*) as cnt FROM privnet_status').get() as { cnt: number }
|
||||
if (statusExists.cnt === 0) {
|
||||
db.prepare('INSERT INTO privnet_status (id) VALUES (1)').run()
|
||||
}
|
||||
|
||||
console.log('[DB] privnet tables initialized')
|
||||
}
|
||||
|
||||
export function closeDb(): void {
|
||||
if (db) {
|
||||
db.close()
|
||||
db = null
|
||||
}
|
||||
}
|
||||
221
backend/utils/privnet-scheduler.ts
Normal file
221
backend/utils/privnet-scheduler.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { getDb } from './db'
|
||||
|
||||
// 상수 정의
|
||||
const INTERVAL_SUCCESS = 5 * 60 * 1000 // 5분
|
||||
const INTERVAL_FAILURE = 1 * 60 * 1000 // 1분
|
||||
const REQUEST_TIMEOUT = 10 * 1000 // 10초
|
||||
|
||||
// 타입 정의
|
||||
interface PrivnetTarget {
|
||||
id: number
|
||||
name: string
|
||||
url: string
|
||||
is_active: number
|
||||
}
|
||||
|
||||
interface PrivnetStatus {
|
||||
id: number
|
||||
current_index: number
|
||||
check_interval: number
|
||||
is_healthy: number
|
||||
last_target_id: number | null
|
||||
last_checked_at: string | null
|
||||
scheduler_running: number
|
||||
}
|
||||
|
||||
interface CheckResult {
|
||||
targetId: number
|
||||
targetName: string
|
||||
url: string
|
||||
isSuccess: boolean
|
||||
}
|
||||
|
||||
class PrivnetScheduler {
|
||||
private timer: ReturnType<typeof setTimeout> | null = null
|
||||
private isRunning: boolean = false
|
||||
|
||||
/**
|
||||
* 스케줄러 시작
|
||||
*/
|
||||
start(): void {
|
||||
if (this.isRunning) {
|
||||
console.log('[PrivnetScheduler] Already running')
|
||||
return
|
||||
}
|
||||
|
||||
this.isRunning = true
|
||||
this.updateSchedulerRunning(1)
|
||||
console.log('[PrivnetScheduler] Started')
|
||||
|
||||
// 즉시 첫 체크 실행
|
||||
this.runCheck()
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄러 중지
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
this.isRunning = false
|
||||
this.updateSchedulerRunning(0)
|
||||
console.log('[PrivnetScheduler] Stopped')
|
||||
}
|
||||
|
||||
/**
|
||||
* 실행 상태 확인
|
||||
*/
|
||||
getIsRunning(): boolean {
|
||||
return this.isRunning
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크 실행
|
||||
*/
|
||||
private async runCheck(): Promise<void> {
|
||||
if (!this.isRunning) return
|
||||
|
||||
try {
|
||||
const result = await this.checkCurrentTarget()
|
||||
|
||||
// 결과에 따라 다음 간격 결정
|
||||
const nextInterval = result.isSuccess ? INTERVAL_SUCCESS : INTERVAL_FAILURE
|
||||
|
||||
// 로그 저장
|
||||
this.saveLog(result)
|
||||
|
||||
// 상태 업데이트 (인덱스 증가)
|
||||
this.updateStatus(result, nextInterval)
|
||||
|
||||
console.log(
|
||||
`[PrivnetScheduler] ${result.targetName} (${result.url}) - ` +
|
||||
`${result.isSuccess ? 'SUCCESS' : 'FAILED'} - ` +
|
||||
`Next check in ${nextInterval / 1000}s`
|
||||
)
|
||||
|
||||
// 다음 체크 예약
|
||||
this.timer = setTimeout(() => this.runCheck(), nextInterval)
|
||||
|
||||
} catch (error) {
|
||||
console.error('[PrivnetScheduler] Error:', error)
|
||||
// 에러 발생 시 1분 후 재시도
|
||||
this.timer = setTimeout(() => this.runCheck(), INTERVAL_FAILURE)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 타겟 URL 체크
|
||||
*/
|
||||
private async checkCurrentTarget(): Promise<CheckResult> {
|
||||
const db = getDb()
|
||||
|
||||
// 활성화된 타겟 목록 조회
|
||||
const targets = db.prepare(`
|
||||
SELECT * FROM privnet_targets
|
||||
WHERE is_active = 1
|
||||
ORDER BY id ASC
|
||||
`).all() as PrivnetTarget[]
|
||||
|
||||
if (targets.length === 0) {
|
||||
throw new Error('No active targets found')
|
||||
}
|
||||
|
||||
// 현재 인덱스 조회
|
||||
const status = db.prepare('SELECT * FROM privnet_status WHERE id = 1').get() as PrivnetStatus
|
||||
const currentIndex = status.current_index % targets.length
|
||||
const target = targets[currentIndex]
|
||||
|
||||
// HTTP 요청 실행
|
||||
let isSuccess = false
|
||||
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT)
|
||||
|
||||
const response = await fetch(target.url, {
|
||||
method: 'HEAD',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent': 'OSOLIT-Monitor/1.0'
|
||||
}
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
isSuccess = response.status === 200
|
||||
|
||||
} catch (err: any) {
|
||||
isSuccess = false
|
||||
}
|
||||
|
||||
return {
|
||||
targetId: target.id,
|
||||
targetName: target.name,
|
||||
url: target.url,
|
||||
isSuccess
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크 결과 로그 저장
|
||||
*/
|
||||
private saveLog(result: CheckResult): void {
|
||||
const db = getDb()
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO privnet_logs (target_id, is_success)
|
||||
VALUES (@targetId, @isSuccess)
|
||||
`).run({
|
||||
targetId: result.targetId,
|
||||
isSuccess: result.isSuccess ? 1 : 0
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 업데이트 (인덱스 순환)
|
||||
*/
|
||||
private updateStatus(result: CheckResult, nextInterval: number): void {
|
||||
const db = getDb()
|
||||
|
||||
// 활성 타겟 수 조회
|
||||
const countResult = db.prepare(`
|
||||
SELECT COUNT(*) as cnt FROM privnet_targets WHERE is_active = 1
|
||||
`).get() as { cnt: number }
|
||||
|
||||
const status = db.prepare('SELECT current_index FROM privnet_status WHERE id = 1').get() as { current_index: number }
|
||||
const nextIndex = (status.current_index + 1) % countResult.cnt
|
||||
|
||||
db.prepare(`
|
||||
UPDATE privnet_status SET
|
||||
current_index = @nextIndex,
|
||||
check_interval = @checkInterval,
|
||||
is_healthy = @isHealthy,
|
||||
last_target_id = @lastTargetId,
|
||||
last_checked_at = datetime('now', 'localtime'),
|
||||
updated_at = datetime('now', 'localtime')
|
||||
WHERE id = 1
|
||||
`).run({
|
||||
nextIndex,
|
||||
checkInterval: nextInterval,
|
||||
isHealthy: result.isSuccess ? 1 : 0,
|
||||
lastTargetId: result.targetId
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄러 실행 상태 업데이트
|
||||
*/
|
||||
private updateSchedulerRunning(running: number): void {
|
||||
const db = getDb()
|
||||
db.prepare(`
|
||||
UPDATE privnet_status SET
|
||||
scheduler_running = @running,
|
||||
updated_at = datetime('now', 'localtime')
|
||||
WHERE id = 1
|
||||
`).run({ running })
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스
|
||||
export const privnetScheduler = new PrivnetScheduler()
|
||||
223
backend/utils/pubnet-scheduler.ts
Normal file
223
backend/utils/pubnet-scheduler.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { getDb } from './db'
|
||||
|
||||
// 상수 정의
|
||||
const INTERVAL_SUCCESS = 5 * 60 * 1000 // 5분
|
||||
const INTERVAL_FAILURE = 1 * 60 * 1000 // 1분
|
||||
const REQUEST_TIMEOUT = 10 * 1000 // 10초
|
||||
|
||||
// 타입 정의
|
||||
interface PubnetTarget {
|
||||
id: number
|
||||
name: string
|
||||
url: string
|
||||
is_active: number
|
||||
}
|
||||
|
||||
interface PubnetStatus {
|
||||
id: number
|
||||
current_index: number
|
||||
check_interval: number
|
||||
is_healthy: number
|
||||
last_target_id: number | null
|
||||
last_checked_at: string | null
|
||||
scheduler_running: number
|
||||
}
|
||||
|
||||
interface CheckResult {
|
||||
targetId: number
|
||||
targetName: string
|
||||
url: string
|
||||
isSuccess: boolean
|
||||
}
|
||||
|
||||
class PubnetScheduler {
|
||||
private timer: ReturnType<typeof setTimeout> | null = null
|
||||
private isRunning: boolean = false
|
||||
|
||||
/**
|
||||
* 스케줄러 시작
|
||||
*/
|
||||
start(): void {
|
||||
if (this.isRunning) {
|
||||
console.log('[PubnetScheduler] Already running')
|
||||
return
|
||||
}
|
||||
|
||||
this.isRunning = true
|
||||
this.updateSchedulerRunning(1)
|
||||
console.log('[PubnetScheduler] Started')
|
||||
|
||||
// 즉시 첫 체크 실행
|
||||
this.runCheck()
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄러 중지
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
this.isRunning = false
|
||||
this.updateSchedulerRunning(0)
|
||||
console.log('[PubnetScheduler] Stopped')
|
||||
}
|
||||
|
||||
/**
|
||||
* 실행 상태 확인
|
||||
*/
|
||||
getIsRunning(): boolean {
|
||||
return this.isRunning
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크 실행
|
||||
*/
|
||||
private async runCheck(): Promise<void> {
|
||||
if (!this.isRunning) return
|
||||
|
||||
try {
|
||||
const result = await this.checkCurrentTargets()
|
||||
|
||||
// 결과에 따라 다음 간격 결정
|
||||
const nextInterval = result.isSuccess ? INTERVAL_SUCCESS : INTERVAL_FAILURE
|
||||
|
||||
// 로그 저장 (1개만)
|
||||
this.saveLog(result)
|
||||
|
||||
// 상태 업데이트 (인덱스 +1)
|
||||
this.updateStatus(result, nextInterval)
|
||||
|
||||
console.log(
|
||||
`[PubnetScheduler] ${result.targetName} (${result.url}) - ` +
|
||||
`${result.isSuccess ? 'SUCCESS' : 'FAILED'} - ` +
|
||||
`Next check in ${nextInterval / 1000}s`
|
||||
)
|
||||
|
||||
// 다음 체크 예약
|
||||
this.timer = setTimeout(() => this.runCheck(), nextInterval)
|
||||
|
||||
} catch (error) {
|
||||
console.error('[PubnetScheduler] Error:', error)
|
||||
// 에러 발생 시 1분 후 재시도
|
||||
this.timer = setTimeout(() => this.runCheck(), INTERVAL_FAILURE)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 타겟 1개 체크
|
||||
*/
|
||||
private async checkCurrentTargets(): Promise<CheckResult> {
|
||||
const db = getDb()
|
||||
|
||||
// 활성화된 타겟 목록 조회
|
||||
const targets = db.prepare(`
|
||||
SELECT * FROM pubnet_targets
|
||||
WHERE is_active = 1
|
||||
ORDER BY id ASC
|
||||
`).all() as PubnetTarget[]
|
||||
|
||||
if (targets.length === 0) {
|
||||
throw new Error('No active targets found')
|
||||
}
|
||||
|
||||
// 현재 인덱스 조회
|
||||
const status = db.prepare('SELECT * FROM pubnet_status WHERE id = 1').get() as PubnetStatus
|
||||
|
||||
// 1개 타겟 선택
|
||||
const idx = status.current_index % targets.length
|
||||
const target = targets[idx]
|
||||
|
||||
// 단일 타겟 체크
|
||||
let isSuccess = false
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT)
|
||||
|
||||
const response = await fetch(target.url, {
|
||||
method: 'HEAD',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent': 'OSOLIT-Monitor/1.0'
|
||||
}
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
isSuccess = response.status === 200
|
||||
} catch (err: any) {
|
||||
isSuccess = false
|
||||
}
|
||||
|
||||
return {
|
||||
targetId: target.id,
|
||||
targetName: target.name,
|
||||
url: target.url,
|
||||
isSuccess
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크 결과 로그 저장
|
||||
*/
|
||||
private saveLog(result: CheckResult): void {
|
||||
const db = getDb()
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO pubnet_logs (target_id, is_success)
|
||||
VALUES (@targetId, @isSuccess)
|
||||
`).run({
|
||||
targetId: result.targetId,
|
||||
isSuccess: result.isSuccess ? 1 : 0
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 업데이트 (인덱스 +1 순환)
|
||||
*/
|
||||
private updateStatus(result: CheckResult, nextInterval: number): void {
|
||||
const db = getDb()
|
||||
|
||||
// 활성 타겟 수 조회
|
||||
const countResult = db.prepare(`
|
||||
SELECT COUNT(*) as cnt FROM pubnet_targets WHERE is_active = 1
|
||||
`).get() as { cnt: number }
|
||||
|
||||
const status = db.prepare('SELECT current_index FROM pubnet_status WHERE id = 1').get() as { current_index: number }
|
||||
|
||||
// 인덱스 +1 (순환)
|
||||
const nextIndex = (status.current_index + 1) % countResult.cnt
|
||||
|
||||
db.prepare(`
|
||||
UPDATE pubnet_status SET
|
||||
current_index = @nextIndex,
|
||||
check_interval = @checkInterval,
|
||||
is_healthy = @isHealthy,
|
||||
last_target_id = @lastTargetId,
|
||||
last_checked_at = datetime('now', 'localtime'),
|
||||
updated_at = datetime('now', 'localtime')
|
||||
WHERE id = 1
|
||||
`).run({
|
||||
nextIndex,
|
||||
checkInterval: nextInterval,
|
||||
isHealthy: result.isSuccess ? 1 : 0,
|
||||
lastTargetId: result.targetId
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄러 실행 상태 업데이트
|
||||
*/
|
||||
private updateSchedulerRunning(running: number): void {
|
||||
const db = getDb()
|
||||
db.prepare(`
|
||||
UPDATE pubnet_status SET
|
||||
scheduler_running = @running,
|
||||
updated_at = datetime('now', 'localtime')
|
||||
WHERE id = 1
|
||||
`).run({ running })
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스
|
||||
export const pubnetScheduler = new PubnetScheduler()
|
||||
672
backend/utils/server-scheduler.ts
Normal file
672
backend/utils/server-scheduler.ts
Normal file
@@ -0,0 +1,672 @@
|
||||
import { getDb } from './db'
|
||||
|
||||
interface ServerTarget {
|
||||
target_id: number
|
||||
server_name: string
|
||||
server_ip: string
|
||||
glances_url: string
|
||||
is_active: number
|
||||
collect_interval: number
|
||||
}
|
||||
|
||||
// 서버별 타이머 관리
|
||||
const serverTimers = new Map<number, ReturnType<typeof setInterval>>()
|
||||
// 서버별 API 버전 캐시
|
||||
const apiVersionCache = new Map<number, string>()
|
||||
let isRunning = false
|
||||
|
||||
// 타임스탬프 생성
|
||||
function timestamp(): string {
|
||||
return new Date().toLocaleString('sv-SE', { timeZone: 'Asia/Seoul' }).replace('T', ' ')
|
||||
}
|
||||
|
||||
// Glances API 호출 (버전 지정)
|
||||
async function fetchGlancesApi(baseUrl: string, endpoint: string, version: string): Promise<any> {
|
||||
try {
|
||||
const url = `${baseUrl}/api/${version}/${endpoint}`
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(5000)
|
||||
})
|
||||
if (!response.ok) return null
|
||||
return await response.json()
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// API 버전 자동 감지 (v4 우선, 실패 시 v3)
|
||||
async function detectApiVersion(baseUrl: string, serverName: string): Promise<string | null> {
|
||||
const now = timestamp()
|
||||
|
||||
// v4 먼저 시도
|
||||
console.log(`[${now}] 🔍 [${serverName}] API 버전 감지 중... (v4 시도)`)
|
||||
const v4Result = await fetchGlancesApi(baseUrl, 'system', '4')
|
||||
if (v4Result && v4Result.os_name) {
|
||||
console.log(`[${now}] ✅ [${serverName}] API v4 감지됨`)
|
||||
return '4'
|
||||
}
|
||||
|
||||
// v3 시도
|
||||
console.log(`[${now}] 🔍 [${serverName}] API 버전 감지 중... (v3 시도)`)
|
||||
const v3Result = await fetchGlancesApi(baseUrl, 'system', '3')
|
||||
if (v3Result && v3Result.os_name) {
|
||||
console.log(`[${now}] ✅ [${serverName}] API v3 감지됨`)
|
||||
return '3'
|
||||
}
|
||||
|
||||
console.log(`[${now}] ❌ [${serverName}] API 버전 감지 실패`)
|
||||
return null
|
||||
}
|
||||
|
||||
// 이상감지 실행
|
||||
async function detectAnomalies(targetId: number, serverName: string) {
|
||||
const db = getDb()
|
||||
const now = timestamp()
|
||||
|
||||
try {
|
||||
// === 단기 변화율 감지 ===
|
||||
const SHORT_TERM_THRESHOLD = 30
|
||||
|
||||
const snapshots = db.prepare(`
|
||||
SELECT cpu_percent, memory_percent
|
||||
FROM server_snapshots
|
||||
WHERE target_id = ? AND is_online = 1
|
||||
ORDER BY collected_at DESC
|
||||
LIMIT 20
|
||||
`).all(targetId) as any[]
|
||||
|
||||
if (snapshots.length >= 4) {
|
||||
const half = Math.floor(snapshots.length / 2)
|
||||
const currSnapshots = snapshots.slice(0, half)
|
||||
const prevSnapshots = snapshots.slice(half)
|
||||
|
||||
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 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
|
||||
|
||||
// 중복 체크용
|
||||
const recentLogExists = db.prepare(`
|
||||
SELECT 1 FROM anomaly_logs
|
||||
WHERE target_id = ? AND detect_type = ? AND metric = ?
|
||||
AND detected_at > datetime('now', '-1 minute', 'localtime')
|
||||
LIMIT 1
|
||||
`)
|
||||
|
||||
const insertLog = db.prepare(`
|
||||
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
// CPU 단기 변화율 체크
|
||||
if (Math.abs(cpuChange) >= SHORT_TERM_THRESHOLD) {
|
||||
const level = Math.abs(cpuChange) >= 100 ? 'danger' : 'warning'
|
||||
const direction = cpuChange >= 0 ? '증가' : '감소'
|
||||
const message = `CPU ${direction} 감지 (${prevCpuAvg.toFixed(1)}% → ${currCpuAvg.toFixed(1)}%)`
|
||||
|
||||
if (!recentLogExists.get(targetId, 'short-term', 'CPU')) {
|
||||
insertLog.run(targetId, serverName, 'short-term', 'CPU', level, currCpuAvg, cpuChange, message)
|
||||
console.log(`[${now}] 🚨 [${serverName}] 단기변화율 이상감지: CPU ${cpuChange.toFixed(1)}% (${level})`)
|
||||
}
|
||||
}
|
||||
|
||||
// Memory 단기 변화율 체크
|
||||
if (Math.abs(memChange) >= SHORT_TERM_THRESHOLD) {
|
||||
const level = Math.abs(memChange) >= 100 ? 'danger' : 'warning'
|
||||
const direction = memChange >= 0 ? '증가' : '감소'
|
||||
const message = `Memory ${direction} 감지 (${prevMemAvg.toFixed(1)}% → ${currMemAvg.toFixed(1)}%)`
|
||||
|
||||
if (!recentLogExists.get(targetId, 'short-term', 'Memory')) {
|
||||
insertLog.run(targetId, serverName, 'short-term', 'Memory', level, currMemAvg, memChange, message)
|
||||
console.log(`[${now}] 🚨 [${serverName}] 단기변화율 이상감지: Memory ${memChange.toFixed(1)}% (${level})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Z-Score 감지 ===
|
||||
const WARNING_Z = 2.0
|
||||
const DANGER_Z = 3.0
|
||||
|
||||
const hourSnapshots = db.prepare(`
|
||||
SELECT cpu_percent, memory_percent
|
||||
FROM server_snapshots
|
||||
WHERE target_id = ? AND is_online = 1
|
||||
AND collected_at >= datetime('now', '-1 hour', 'localtime')
|
||||
ORDER BY collected_at DESC
|
||||
`).all(targetId) as any[]
|
||||
|
||||
if (hourSnapshots.length >= 10) {
|
||||
const current = hourSnapshots[0]
|
||||
const currCpu = current.cpu_percent ?? 0
|
||||
const currMem = current.memory_percent ?? 0
|
||||
|
||||
const cpuValues = hourSnapshots.map(s => s.cpu_percent ?? 0)
|
||||
const memValues = hourSnapshots.map(s => s.memory_percent ?? 0)
|
||||
|
||||
const cpuAvg = cpuValues.reduce((a, b) => a + b, 0) / cpuValues.length
|
||||
const memAvg = memValues.reduce((a, b) => a + b, 0) / memValues.length
|
||||
|
||||
const cpuVariance = cpuValues.reduce((sum, val) => sum + Math.pow(val - cpuAvg, 2), 0) / cpuValues.length
|
||||
const memVariance = memValues.reduce((sum, val) => sum + Math.pow(val - memAvg, 2), 0) / memValues.length
|
||||
const cpuStd = Math.sqrt(cpuVariance)
|
||||
const memStd = Math.sqrt(memVariance)
|
||||
|
||||
const cpuZscore = cpuStd > 0.1 ? (currCpu - cpuAvg) / cpuStd : 0
|
||||
const memZscore = memStd > 0.1 ? (currMem - memAvg) / memStd : 0
|
||||
|
||||
const recentLogExists = db.prepare(`
|
||||
SELECT 1 FROM anomaly_logs
|
||||
WHERE target_id = ? AND detect_type = 'zscore' AND metric = ?
|
||||
AND detected_at > datetime('now', '-1 minute', 'localtime')
|
||||
LIMIT 1
|
||||
`)
|
||||
|
||||
const insertLog = db.prepare(`
|
||||
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
|
||||
VALUES (?, ?, 'zscore', ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
// CPU Z-Score 체크
|
||||
if (Math.abs(cpuZscore) >= WARNING_Z) {
|
||||
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)}%)`
|
||||
|
||||
if (!recentLogExists.get(targetId, 'CPU')) {
|
||||
insertLog.run(targetId, serverName, 'CPU', level, currCpu, cpuZscore, message)
|
||||
console.log(`[${now}] 🚨 [${serverName}] Z-Score 이상감지: CPU Z=${cpuZscore.toFixed(2)} (${level})`)
|
||||
}
|
||||
}
|
||||
|
||||
// Memory Z-Score 체크
|
||||
if (Math.abs(memZscore) >= WARNING_Z) {
|
||||
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)}%)`
|
||||
|
||||
if (!recentLogExists.get(targetId, 'Memory')) {
|
||||
insertLog.run(targetId, serverName, 'Memory', level, currMem, memZscore, message)
|
||||
console.log(`[${now}] 🚨 [${serverName}] Z-Score 이상감지: Memory Z=${memZscore.toFixed(2)} (${level})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === 시간대별 베이스라인 감지 ===
|
||||
const DEVIATION_THRESHOLD = 2.0
|
||||
const currentHour = new Date().getHours()
|
||||
const currentDayOfWeek = new Date().getDay()
|
||||
const isWeekend = currentDayOfWeek === 0 || currentDayOfWeek === 6
|
||||
const dayType = isWeekend ? 'weekend' : 'weekday'
|
||||
|
||||
const baselineData = db.prepare(`
|
||||
SELECT cpu_percent, memory_percent
|
||||
FROM server_snapshots
|
||||
WHERE target_id = ? AND is_online = 1
|
||||
AND collected_at >= datetime('now', '-14 days', 'localtime')
|
||||
AND strftime('%H', collected_at) = ?
|
||||
AND (
|
||||
(? = 'weekend' AND strftime('%w', collected_at) IN ('0', '6'))
|
||||
OR
|
||||
(? = 'weekday' AND strftime('%w', collected_at) NOT IN ('0', '6'))
|
||||
)
|
||||
`).all(targetId, currentHour.toString().padStart(2, '0'), dayType, dayType) as any[]
|
||||
|
||||
const currentSnapshot = db.prepare(`
|
||||
SELECT cpu_percent, memory_percent
|
||||
FROM server_snapshots
|
||||
WHERE target_id = ? AND is_online = 1
|
||||
ORDER BY collected_at DESC LIMIT 1
|
||||
`).get(targetId) as any
|
||||
|
||||
if (baselineData.length >= 5 && currentSnapshot) {
|
||||
const currCpu = currentSnapshot.cpu_percent ?? 0
|
||||
const currMem = currentSnapshot.memory_percent ?? 0
|
||||
|
||||
const cpuValues = baselineData.map(s => s.cpu_percent ?? 0)
|
||||
const memValues = baselineData.map(s => s.memory_percent ?? 0)
|
||||
|
||||
const cpuAvg = cpuValues.reduce((a, b) => a + b, 0) / cpuValues.length
|
||||
const memAvg = memValues.reduce((a, b) => a + b, 0) / memValues.length
|
||||
|
||||
const cpuVariance = cpuValues.reduce((sum, val) => sum + Math.pow(val - cpuAvg, 2), 0) / cpuValues.length
|
||||
const memVariance = memValues.reduce((sum, val) => sum + Math.pow(val - memAvg, 2), 0) / memValues.length
|
||||
const cpuStd = Math.sqrt(cpuVariance)
|
||||
const memStd = Math.sqrt(memVariance)
|
||||
|
||||
const cpuDeviation = cpuStd > 0.1 ? (currCpu - cpuAvg) / cpuStd : 0
|
||||
const memDeviation = memStd > 0.1 ? (currMem - memAvg) / memStd : 0
|
||||
|
||||
const baselineLogExists = db.prepare(`
|
||||
SELECT 1 FROM anomaly_logs
|
||||
WHERE target_id = ? AND detect_type = 'baseline' AND metric = ?
|
||||
AND detected_at > datetime('now', '-1 minute', 'localtime')
|
||||
LIMIT 1
|
||||
`)
|
||||
|
||||
const baselineInsertLog = db.prepare(`
|
||||
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
|
||||
VALUES (?, ?, 'baseline', ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
if (Math.abs(cpuDeviation) >= DEVIATION_THRESHOLD) {
|
||||
const level = Math.abs(cpuDeviation) >= 3.0 ? 'danger' : 'warning'
|
||||
const direction = cpuDeviation >= 0 ? '높음' : '낮음'
|
||||
const dayLabel = isWeekend ? '주말' : '평일'
|
||||
const message = `CPU ${dayLabel} ${currentHour}시 베이스라인 대비 ${Math.abs(cpuDeviation).toFixed(1)}σ ${direction}`
|
||||
|
||||
if (!baselineLogExists.get(targetId, 'CPU')) {
|
||||
baselineInsertLog.run(targetId, serverName, 'CPU', level, currCpu, cpuDeviation, message)
|
||||
console.log(`[${now}] 🚨 [${serverName}] 베이스라인 이상감지: CPU σ=${cpuDeviation.toFixed(2)} (${level})`)
|
||||
}
|
||||
}
|
||||
|
||||
if (Math.abs(memDeviation) >= DEVIATION_THRESHOLD) {
|
||||
const level = Math.abs(memDeviation) >= 3.0 ? 'danger' : 'warning'
|
||||
const direction = memDeviation >= 0 ? '높음' : '낮음'
|
||||
const dayLabel = isWeekend ? '주말' : '평일'
|
||||
const message = `Memory ${dayLabel} ${currentHour}시 베이스라인 대비 ${Math.abs(memDeviation).toFixed(1)}σ ${direction}`
|
||||
|
||||
if (!baselineLogExists.get(targetId, 'Memory')) {
|
||||
baselineInsertLog.run(targetId, serverName, 'Memory', level, currMem, memDeviation, message)
|
||||
console.log(`[${now}] 🚨 [${serverName}] 베이스라인 이상감지: Memory σ=${memDeviation.toFixed(2)} (${level})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === 추세 분석 감지 ===
|
||||
const SLOPE_THRESHOLD = 0.5
|
||||
const WINDOW_MINUTES = 30
|
||||
|
||||
const trendSnapshots = db.prepare(`
|
||||
SELECT cpu_percent, memory_percent
|
||||
FROM server_snapshots
|
||||
WHERE target_id = ? AND is_online = 1
|
||||
AND collected_at >= datetime('now', '-${WINDOW_MINUTES} minutes', 'localtime')
|
||||
ORDER BY collected_at ASC
|
||||
`).all(targetId) as any[]
|
||||
|
||||
if (trendSnapshots.length >= 10) {
|
||||
const n = trendSnapshots.length
|
||||
const currCpu = trendSnapshots[n - 1].cpu_percent ?? 0
|
||||
const currMem = trendSnapshots[n - 1].memory_percent ?? 0
|
||||
|
||||
// 선형 회귀 계산
|
||||
function calcSlope(values: number[]): { slope: number, r2: number } {
|
||||
const n = values.length
|
||||
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0
|
||||
for (let i = 0; i < n; i++) {
|
||||
sumX += i; sumY += values[i]; sumXY += i * values[i]; sumX2 += i * i
|
||||
}
|
||||
const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX)
|
||||
const yMean = sumY / n
|
||||
let ssTotal = 0, ssResidual = 0
|
||||
for (let i = 0; i < n; i++) {
|
||||
const yPred = slope * i + (sumY - slope * sumX) / n
|
||||
ssTotal += Math.pow(values[i] - yMean, 2)
|
||||
ssResidual += Math.pow(values[i] - yPred, 2)
|
||||
}
|
||||
const r2 = ssTotal > 0 ? 1 - (ssResidual / ssTotal) : 0
|
||||
return { slope: (slope * n) / WINDOW_MINUTES, r2 }
|
||||
}
|
||||
|
||||
const cpuResult = calcSlope(trendSnapshots.map(s => s.cpu_percent ?? 0))
|
||||
const memResult = calcSlope(trendSnapshots.map(s => s.memory_percent ?? 0))
|
||||
|
||||
const trendLogExists = db.prepare(`
|
||||
SELECT 1 FROM anomaly_logs
|
||||
WHERE target_id = ? AND detect_type = 'trend' AND metric = ?
|
||||
AND detected_at > datetime('now', '-1 minute', 'localtime')
|
||||
LIMIT 1
|
||||
`)
|
||||
|
||||
const trendInsertLog = db.prepare(`
|
||||
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
|
||||
VALUES (?, ?, 'trend', ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
if (cpuResult.slope >= SLOPE_THRESHOLD && cpuResult.r2 >= 0.3) {
|
||||
const level = cpuResult.slope >= 1.0 ? 'danger' : 'warning'
|
||||
const message = `CPU 지속 상승 중 (분당 +${cpuResult.slope.toFixed(2)}%, R²=${cpuResult.r2.toFixed(2)})`
|
||||
|
||||
if (!trendLogExists.get(targetId, 'CPU')) {
|
||||
trendInsertLog.run(targetId, serverName, 'CPU', level, currCpu, cpuResult.slope, message)
|
||||
console.log(`[${now}] 🚨 [${serverName}] 추세 이상감지: CPU +${cpuResult.slope.toFixed(2)}/분 (${level})`)
|
||||
}
|
||||
}
|
||||
|
||||
if (memResult.slope >= SLOPE_THRESHOLD && memResult.r2 >= 0.3) {
|
||||
const level = memResult.slope >= 1.0 ? 'danger' : 'warning'
|
||||
const message = `Memory 지속 상승 중 (분당 +${memResult.slope.toFixed(2)}%, R²=${memResult.r2.toFixed(2)})`
|
||||
|
||||
if (!trendLogExists.get(targetId, 'Memory')) {
|
||||
trendInsertLog.run(targetId, serverName, 'Memory', level, currMem, memResult.slope, message)
|
||||
console.log(`[${now}] 🚨 [${serverName}] 추세 이상감지: Memory +${memResult.slope.toFixed(2)}/분 (${level})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error(`[${now}] ❌ [${serverName}] 이상감지 에러:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 서버 데이터 수집
|
||||
async function collectServerData(target: ServerTarget) {
|
||||
const db = getDb()
|
||||
const now = timestamp()
|
||||
|
||||
console.log(`[${now}] 📡 [${target.server_name}] 수집 시작... (${target.glances_url})`)
|
||||
|
||||
try {
|
||||
// API 버전 확인 (캐시 또는 자동 감지)
|
||||
let apiVersion = apiVersionCache.get(target.target_id)
|
||||
|
||||
if (!apiVersion) {
|
||||
apiVersion = await detectApiVersion(target.glances_url, target.server_name)
|
||||
if (apiVersion) {
|
||||
apiVersionCache.set(target.target_id, apiVersion)
|
||||
}
|
||||
}
|
||||
|
||||
if (!apiVersion) {
|
||||
console.log(`[${now}] ❌ [${target.server_name}] 연결 실패 - Offline 기록`)
|
||||
db.prepare(`
|
||||
INSERT INTO server_snapshots (target_id, is_online, collected_at)
|
||||
VALUES (?, 0, ?)
|
||||
`).run(target.target_id, now)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[${now}] 📡 [${target.server_name}] Glances API v${apiVersion} 호출 중...`)
|
||||
|
||||
// 병렬로 API 호출
|
||||
const [system, cpu, mem, memswap, fs, docker, network, quicklook, uptime, sensors, load] = await Promise.all([
|
||||
fetchGlancesApi(target.glances_url, 'system', apiVersion),
|
||||
fetchGlancesApi(target.glances_url, 'cpu', apiVersion),
|
||||
fetchGlancesApi(target.glances_url, 'mem', apiVersion),
|
||||
fetchGlancesApi(target.glances_url, 'memswap', apiVersion),
|
||||
fetchGlancesApi(target.glances_url, 'fs', apiVersion),
|
||||
fetchGlancesApi(target.glances_url, 'containers', apiVersion),
|
||||
fetchGlancesApi(target.glances_url, 'network', apiVersion),
|
||||
fetchGlancesApi(target.glances_url, 'quicklook', apiVersion),
|
||||
fetchGlancesApi(target.glances_url, 'uptime', apiVersion),
|
||||
fetchGlancesApi(target.glances_url, 'sensors', apiVersion),
|
||||
fetchGlancesApi(target.glances_url, 'load', apiVersion)
|
||||
])
|
||||
|
||||
const isOnline = system !== null
|
||||
|
||||
if (!isOnline) {
|
||||
// 캐시 클리어 후 재시도 위해
|
||||
apiVersionCache.delete(target.target_id)
|
||||
console.log(`[${now}] ❌ [${target.server_name}] 연결 실패 - Offline 기록`)
|
||||
db.prepare(`
|
||||
INSERT INTO server_snapshots (target_id, is_online, collected_at)
|
||||
VALUES (?, 0, ?)
|
||||
`).run(target.target_id, now)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[${now}] ✅ [${target.server_name}] 연결 성공 - 데이터 저장 중...`)
|
||||
|
||||
// CPU 온도 추출 (sensors 배열에서)
|
||||
let cpuTemp: number | null = null
|
||||
if (Array.isArray(sensors)) {
|
||||
const tempSensor = sensors.find((s: any) =>
|
||||
s.label?.toLowerCase().includes('cpu') ||
|
||||
s.label?.toLowerCase().includes('core') ||
|
||||
s.type === 'temperature_core'
|
||||
)
|
||||
cpuTemp = tempSensor?.value ?? null
|
||||
}
|
||||
|
||||
// server_snapshots INSERT (api_version 포함)
|
||||
console.log(`[${now}] 💾 [${target.server_name}] snapshot 저장 (API v${apiVersion}, CPU: ${cpu?.total?.toFixed(1) || 0}%, MEM: ${mem?.percent?.toFixed(1) || 0}%, TEMP: ${cpuTemp ?? 'N/A'}°C, LOAD: ${quicklook?.load?.toFixed(1) ?? 'N/A'}%)`)
|
||||
db.prepare(`
|
||||
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,
|
||||
swap_total, swap_used, swap_percent, is_online, api_version, cpu_temp,
|
||||
load_1, load_5, load_15, load_percent, collected_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
target.target_id,
|
||||
system?.os_name || system?.linux_distro || null,
|
||||
system?.os_version || null,
|
||||
system?.hostname || null,
|
||||
null,
|
||||
typeof uptime === 'string' ? uptime : null,
|
||||
target.glances_url.match(/https?:\/\/([^:\/]+)/)?.[1] || null,
|
||||
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?.percent || null,
|
||||
memswap?.total || null,
|
||||
memswap?.used || null,
|
||||
memswap?.percent || null,
|
||||
isOnline ? 1 : 0,
|
||||
apiVersion,
|
||||
cpuTemp,
|
||||
load?.min1 ?? null,
|
||||
load?.min5 ?? null,
|
||||
load?.min15 ?? null,
|
||||
quicklook?.load ?? null,
|
||||
now
|
||||
)
|
||||
|
||||
// server_disks INSERT (배열)
|
||||
if (Array.isArray(fs) && fs.length > 0) {
|
||||
console.log(`[${now}] 💾 [${target.server_name}] disk 저장 (${fs.length}개 파티션)`)
|
||||
const diskStmt = db.prepare(`
|
||||
INSERT INTO server_disks (
|
||||
target_id, device_name, mount_point, fs_type,
|
||||
disk_total, disk_used, disk_percent, collected_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
for (const disk of fs) {
|
||||
diskStmt.run(
|
||||
target.target_id,
|
||||
disk.device_name || null,
|
||||
disk.mnt_point || null,
|
||||
disk.fs_type || null,
|
||||
disk.size || null,
|
||||
disk.used || null,
|
||||
disk.percent || null,
|
||||
now
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// server_containers INSERT (배열)
|
||||
if (Array.isArray(docker) && docker.length > 0) {
|
||||
console.log(`[${now}] 🐳 [${target.server_name}] container 저장 (${docker.length}개 컨테이너)`)
|
||||
const containerStmt = db.prepare(`
|
||||
INSERT INTO server_containers (
|
||||
target_id, docker_id, container_name, container_image,
|
||||
container_status, cpu_percent, memory_usage, memory_limit,
|
||||
memory_percent, uptime, network_rx, network_tx, collected_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
for (const container of docker) {
|
||||
containerStmt.run(
|
||||
target.target_id,
|
||||
container.id || null,
|
||||
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,
|
||||
container.uptime || null,
|
||||
container.network?.rx ?? container.network_rx ?? null,
|
||||
container.network?.tx ?? container.network_tx ?? null,
|
||||
now
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// server_networks INSERT (배열)
|
||||
if (Array.isArray(network) && network.length > 0) {
|
||||
console.log(`[${now}] 🌐 [${target.server_name}] network 저장 (${network.length}개 인터페이스)`)
|
||||
const netStmt = db.prepare(`
|
||||
INSERT INTO server_networks (
|
||||
target_id, interface_name, bytes_recv, bytes_sent,
|
||||
packets_recv, packets_sent, speed_recv, speed_sent,
|
||||
is_up, collected_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
for (const iface of network) {
|
||||
netStmt.run(
|
||||
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,
|
||||
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,
|
||||
now
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[${now}] ✅ [${target.server_name}] 수집 완료!`)
|
||||
|
||||
// 이상감지 실행
|
||||
await detectAnomalies(target.target_id, target.server_name)
|
||||
|
||||
} catch (err) {
|
||||
console.error(`[${now}] ❌ [${target.server_name}] 수집 에러:`, err)
|
||||
|
||||
// 캐시 클리어
|
||||
apiVersionCache.delete(target.target_id)
|
||||
|
||||
// 오프라인 기록
|
||||
db.prepare(`
|
||||
INSERT INTO server_snapshots (target_id, is_online, collected_at)
|
||||
VALUES (?, 0, ?)
|
||||
`).run(target.target_id, now)
|
||||
}
|
||||
}
|
||||
|
||||
// 서버별 타이머 시작
|
||||
function startServerTimer(target: ServerTarget) {
|
||||
const now = timestamp()
|
||||
|
||||
// 기존 타이머 제거
|
||||
stopServerTimer(target.target_id)
|
||||
|
||||
console.log(`[${now}] ⏰ [${target.server_name}] 타이머 등록 (주기: ${target.collect_interval}초)`)
|
||||
|
||||
// 즉시 한 번 실행
|
||||
collectServerData(target)
|
||||
|
||||
// 주기적 실행
|
||||
const intervalMs = (target.collect_interval || 60) * 1000
|
||||
const timer = setInterval(() => {
|
||||
collectServerData(target)
|
||||
}, intervalMs)
|
||||
|
||||
serverTimers.set(target.target_id, timer)
|
||||
}
|
||||
|
||||
// 서버별 타이머 중지
|
||||
function stopServerTimer(targetId: number) {
|
||||
const timer = serverTimers.get(targetId)
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
serverTimers.delete(targetId)
|
||||
apiVersionCache.delete(targetId)
|
||||
console.log(`[${timestamp()}] ⏹️ 타이머 중지 (target_id: ${targetId})`)
|
||||
}
|
||||
}
|
||||
|
||||
// 스케줄러 시작 (모든 활성 서버)
|
||||
export function startServerScheduler() {
|
||||
const now = timestamp()
|
||||
|
||||
if (isRunning) {
|
||||
console.log(`[${now}] ⚠️ [Server Scheduler] 이미 실행 중`)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[${now}] 🚀 [Server Scheduler] ========== 스케줄러 시작 ==========`)
|
||||
|
||||
const db = getDb()
|
||||
const targets = db.prepare(`
|
||||
SELECT * FROM server_targets WHERE is_active = 1
|
||||
`).all() as ServerTarget[]
|
||||
|
||||
console.log(`[${now}] 📋 [Server Scheduler] 활성 서버: ${targets.length}개`)
|
||||
|
||||
for (const target of targets) {
|
||||
console.log(`[${now}] 📋 [Server Scheduler] - ${target.server_name} (${target.glances_url}) / ${target.collect_interval}초`)
|
||||
startServerTimer(target)
|
||||
}
|
||||
|
||||
isRunning = true
|
||||
console.log(`[${now}] ✅ [Server Scheduler] ========== 스케줄러 시작 완료 ==========`)
|
||||
}
|
||||
|
||||
// 스케줄러 중지 (모든 서버)
|
||||
export function stopServerScheduler() {
|
||||
const now = timestamp()
|
||||
|
||||
console.log(`[${now}] 🛑 [Server Scheduler] ========== 스케줄러 중지 ==========`)
|
||||
|
||||
for (const [targetId] of serverTimers) {
|
||||
stopServerTimer(targetId)
|
||||
}
|
||||
|
||||
isRunning = false
|
||||
console.log(`[${now}] ✅ [Server Scheduler] ========== 스케줄러 중지 완료 ==========`)
|
||||
}
|
||||
|
||||
// 스케줄러 상태
|
||||
export function getServerSchedulerStatus() {
|
||||
const db = getDb()
|
||||
const activeServers = serverTimers.size
|
||||
const targets = db.prepare(`
|
||||
SELECT * FROM server_targets WHERE is_active = 1
|
||||
`).all() as ServerTarget[]
|
||||
|
||||
return {
|
||||
is_running: isRunning,
|
||||
active_timers: activeServers,
|
||||
total_targets: targets.length,
|
||||
targets: targets.map(t => ({
|
||||
target_id: t.target_id,
|
||||
server_name: t.server_name,
|
||||
glances_url: t.glances_url,
|
||||
collect_interval: t.collect_interval,
|
||||
has_timer: serverTimers.has(t.target_id),
|
||||
api_version: apiVersionCache.get(t.target_id) || null
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 서버 타이머 갱신 (설정 변경 시)
|
||||
export function refreshServerTimer(targetId: number) {
|
||||
const now = timestamp()
|
||||
const db = getDb()
|
||||
const target = db.prepare(`
|
||||
SELECT * FROM server_targets WHERE target_id = ? AND is_active = 1
|
||||
`).get(targetId) as ServerTarget | undefined
|
||||
|
||||
if (target && isRunning) {
|
||||
console.log(`[${now}] 🔄 [${target.server_name}] 타이머 갱신`)
|
||||
apiVersionCache.delete(targetId) // 버전 재감지
|
||||
startServerTimer(target)
|
||||
} else {
|
||||
stopServerTimer(targetId)
|
||||
}
|
||||
}
|
||||
2
database/.gitkeep
Normal file
2
database/.gitkeep
Normal file
@@ -0,0 +1,2 @@
|
||||
# This file ensures the database directory is included in git
|
||||
# The actual .db files are ignored in .gitignore
|
||||
317
frontend/anomaly/baseline.vue
Normal file
317
frontend/anomaly/baseline.vue
Normal file
@@ -0,0 +1,317 @@
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<SidebarNav />
|
||||
<div class="main-content">
|
||||
<header class="main-header">
|
||||
<h1 class="page-title">🕐 시간대별 베이스라인</h1>
|
||||
<div class="header-actions">
|
||||
<span class="context-badge">{{ context.day_type_label }} {{ context.current_hour }}시</span>
|
||||
<button class="btn btn-primary" @click="refresh" :disabled="loading">
|
||||
{{ loading ? '분석 중...' : '🔄 새로고침' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main-body">
|
||||
<!-- 접히는 설명 카드 -->
|
||||
<div class="info-card collapsible" :class="{ collapsed: infoCollapsed }">
|
||||
<div class="info-header" @click="infoCollapsed = !infoCollapsed">
|
||||
<h3>📖 시간대별 베이스라인이란?</h3>
|
||||
<span class="collapse-icon">{{ infoCollapsed ? '▼' : '▲' }}</span>
|
||||
</div>
|
||||
<div class="info-content" v-show="!infoCollapsed">
|
||||
<p class="desc">
|
||||
과거 2주간 동일 시간대(평일/주말 구분)의 데이터를 기반으로 베이스라인을 구축하고, 현재 값이 정상 범위를 벗어났는지 감지합니다.<br>
|
||||
<code>편차 = (현재값 - 베이스라인평균) / 표준편차</code> | |편차| ≥ 2.0: 주의, |편차| ≥ 3.0: 위험
|
||||
</p>
|
||||
<div class="pros-cons">
|
||||
<div class="pros">
|
||||
<h4>✅ 장점</h4>
|
||||
<ul>
|
||||
<li>시간대별 패턴을 반영 (업무시간 vs 야간)</li>
|
||||
<li>평소보다 "낮은" 값도 감지 가능</li>
|
||||
<li>배치 작업 등 정기 패턴 오탐 방지</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="cons">
|
||||
<h4>❌ 단점</h4>
|
||||
<ul>
|
||||
<li>최소 1~2주 데이터 필요</li>
|
||||
<li>운영 패턴 변경 시 재학습 필요</li>
|
||||
<li>구현 및 유지보수 복잡</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 조회기간 + 차트/테이블 영역 -->
|
||||
<div class="content-section">
|
||||
<div class="period-row">
|
||||
<span class="period-label">조회 기간</span>
|
||||
<div class="period-buttons">
|
||||
<button
|
||||
v-for="p in periods"
|
||||
:key="p.value"
|
||||
:class="['period-btn', { active: selectedPeriod === p.value }]"
|
||||
@click="changePeriod(p.value)"
|
||||
>{{ p.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-table-grid">
|
||||
<div class="chart-section">
|
||||
<h3>📈 이상감지 추이</h3>
|
||||
<div class="chart-container">
|
||||
<canvas ref="chartRef"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-section">
|
||||
<h3>📋 현재 베이스라인 비교</h3>
|
||||
<table class="data-table compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>서버</th>
|
||||
<th>CPU (σ)</th>
|
||||
<th>MEM (σ)</th>
|
||||
<th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="server in allServers" :key="server.target_id">
|
||||
<td class="server-name">{{ server.server_name }}</td>
|
||||
<td :class="getDeviationClass(server.cpu_deviation)">{{ formatDeviation(server.cpu_current, server.cpu_deviation) }}</td>
|
||||
<td :class="getDeviationClass(server.mem_deviation)">{{ formatDeviation(server.mem_current, server.mem_deviation) }}</td>
|
||||
<td><span :class="['status-dot', server.status]"></span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 이상감지 로그 -->
|
||||
<div class="log-section">
|
||||
<div class="log-header">
|
||||
<h3>📜 이상감지 로그</h3>
|
||||
<span class="log-count" v-if="logs.length">{{ logs.length }}건</span>
|
||||
</div>
|
||||
|
||||
<div v-if="logs.length === 0" class="no-logs">
|
||||
이상감지 기록이 없습니다
|
||||
</div>
|
||||
|
||||
<div v-else class="log-list">
|
||||
<div
|
||||
v-for="log in logs"
|
||||
:key="log.id"
|
||||
:class="['log-item', log.level]"
|
||||
>
|
||||
<span class="log-time">{{ formatLogTime(log.detected_at) }}</span>
|
||||
<span :class="['log-level', log.level]">{{ log.level === 'danger' ? '🔴' : '🟡' }}</span>
|
||||
<span class="log-server">{{ log.server_name }}</span>
|
||||
<span class="log-metric">{{ log.metric }}</span>
|
||||
<span class="log-value">σ={{ log.threshold_value?.toFixed(1) }}</span>
|
||||
<span class="log-msg">{{ log.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
Chart.register(...registerables)
|
||||
|
||||
interface ServerBaseline {
|
||||
target_id: number
|
||||
server_name: string
|
||||
cpu_current: number | null
|
||||
mem_current: number | null
|
||||
cpu_deviation: number | null
|
||||
mem_deviation: number | null
|
||||
status: string
|
||||
}
|
||||
|
||||
interface AnomalyLog {
|
||||
id: number
|
||||
server_name: string
|
||||
metric: string
|
||||
level: string
|
||||
threshold_value: number
|
||||
message: string
|
||||
detected_at: string
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const infoCollapsed = ref(true)
|
||||
const allServers = ref<ServerBaseline[]>([])
|
||||
const logs = ref<AnomalyLog[]>([])
|
||||
const chartRef = ref<HTMLCanvasElement | null>(null)
|
||||
const context = ref({ current_hour: 0, day_type: 'weekday', day_type_label: '평일' })
|
||||
let chart: Chart | null = null
|
||||
|
||||
const selectedPeriod = ref('24h')
|
||||
const periods = [
|
||||
{ value: '1h', label: '1시간' },
|
||||
{ value: '6h', label: '6시간' },
|
||||
{ value: '12h', label: '12시간' },
|
||||
{ value: '24h', label: '24시간' },
|
||||
{ value: '7d', label: '7일' },
|
||||
{ value: '30d', label: '30일' }
|
||||
]
|
||||
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
const statusRes = await $fetch('/api/anomaly/baseline')
|
||||
allServers.value = statusRes.servers || []
|
||||
context.value = statusRes.context || { current_hour: 0, day_type: 'weekday', day_type_label: '평일' }
|
||||
|
||||
const logRes = await $fetch(`/api/anomaly/logs?type=baseline&period=${selectedPeriod.value}`)
|
||||
logs.value = logRes.logs || []
|
||||
|
||||
const chartRes = await $fetch(`/api/anomaly/chart?type=baseline&period=${selectedPeriod.value}`)
|
||||
updateChart(chartRes.data || [])
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch data:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function changePeriod(period: string) {
|
||||
selectedPeriod.value = period
|
||||
refresh()
|
||||
}
|
||||
|
||||
function updateChart(data: any[]) {
|
||||
if (!chartRef.value) return
|
||||
if (chart) chart.destroy()
|
||||
|
||||
chart = new Chart(chartRef.value, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.map(d => d.time),
|
||||
datasets: [
|
||||
{ label: 'Warning', data: data.map(d => d.warning), backgroundColor: '#fbbf24', borderRadius: 4 },
|
||||
{ label: 'Danger', data: data.map(d => d.danger), backgroundColor: '#ef4444', borderRadius: 4 }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'top', labels: { boxWidth: 12, font: { size: 11 } } } },
|
||||
scales: {
|
||||
x: { stacked: true, grid: { display: false }, ticks: { font: { size: 10 } } },
|
||||
y: { stacked: true, beginAtZero: true, ticks: { stepSize: 1, font: { size: 10 } } }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getDeviationClass(dev: number | null): string {
|
||||
if (dev === null) return ''
|
||||
const abs = Math.abs(dev)
|
||||
if (abs >= 3) return 'dev-danger'
|
||||
if (abs >= 2) return 'dev-warning'
|
||||
return ''
|
||||
}
|
||||
|
||||
function formatDeviation(current: number | null, dev: number | null): string {
|
||||
if (current === null || dev === null) return '-'
|
||||
return `${current.toFixed(0)}% (${dev >= 0 ? '+' : ''}${dev.toFixed(1)})`
|
||||
}
|
||||
|
||||
function formatLogTime(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
|
||||
onMounted(() => { refresh() })
|
||||
onUnmounted(() => { if (chart) chart.destroy() })
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.app-layout { display: flex; height: 100vh; background: var(--bg-primary); }
|
||||
.main-content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.main-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; border-bottom: 1px solid var(--border-color); background: var(--bg-secondary); }
|
||||
.page-title { margin: 0; font-size: 20px; font-weight: 600; color: var(--text-primary); }
|
||||
.header-actions { display: flex; align-items: center; gap: 12px; }
|
||||
.context-badge { background: #dbeafe; color: #1e40af; padding: 6px 12px; border-radius: 6px; font-size: 13px; font-weight: 500; }
|
||||
.main-body { flex: 1; padding: 20px 24px; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.btn { padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; }
|
||||
.btn-primary { background: #3b82f6; color: white; }
|
||||
.btn-primary:hover { background: #2563eb; }
|
||||
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
|
||||
|
||||
.info-card { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; overflow: hidden; }
|
||||
.info-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 20px; cursor: pointer; }
|
||||
.info-header:hover { background: var(--bg-tertiary); }
|
||||
.info-header h3 { margin: 0; font-size: 15px; color: var(--text-primary); }
|
||||
.collapse-icon { color: var(--text-muted); font-size: 12px; }
|
||||
.info-content { padding: 0 20px 16px; }
|
||||
.info-content .desc { color: var(--text-secondary); line-height: 1.6; margin-bottom: 12px; font-size: 13px; }
|
||||
.info-content code { background: var(--bg-tertiary); padding: 2px 6px; border-radius: 4px; font-size: 12px; }
|
||||
|
||||
.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 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); }
|
||||
|
||||
.content-section { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; padding: 16px; }
|
||||
.period-row { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--border-color); }
|
||||
.period-label { font-size: 13px; font-weight: 500; color: var(--text-muted); }
|
||||
.period-buttons { display: flex; gap: 6px; }
|
||||
.period-btn { padding: 6px 12px; border: 1px solid var(--border-color); border-radius: 6px; background: var(--bg-primary); color: var(--text-secondary); font-size: 12px; cursor: pointer; }
|
||||
.period-btn:hover { background: var(--bg-tertiary); }
|
||||
.period-btn.active { background: #3b82f6; color: white; border-color: #3b82f6; }
|
||||
|
||||
.chart-table-grid { display: grid; grid-template-columns: 1fr 300px; gap: 16px; }
|
||||
.chart-section, .table-section { background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 8px; padding: 12px; }
|
||||
.chart-section h3, .table-section h3 { margin: 0 0 12px 0; font-size: 14px; color: var(--text-primary); }
|
||||
.chart-container { height: 200px; }
|
||||
|
||||
.data-table.compact { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.data-table.compact th, .data-table.compact td { padding: 8px 6px; text-align: left; border-bottom: 1px solid var(--border-color); }
|
||||
.data-table.compact th { font-size: 11px; font-weight: 600; color: var(--text-muted); background: var(--bg-tertiary); }
|
||||
.data-table.compact .server-name { font-weight: 500; max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.dev-warning { color: #ca8a04 !important; font-weight: 500; }
|
||||
.dev-danger { color: #dc2626 !important; font-weight: 600; }
|
||||
|
||||
.status-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; }
|
||||
.status-dot.normal { background: #22c55e; }
|
||||
.status-dot.warning { background: #f59e0b; }
|
||||
.status-dot.danger { background: #ef4444; }
|
||||
.status-dot.insufficient { background: #9ca3af; }
|
||||
|
||||
.log-section { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; padding: 16px; flex: 1; min-height: 200px; }
|
||||
.log-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.log-header h3 { margin: 0; font-size: 14px; color: var(--text-primary); }
|
||||
.log-count { font-size: 12px; color: var(--text-muted); background: var(--bg-tertiary); padding: 2px 8px; border-radius: 10px; }
|
||||
|
||||
.no-logs { text-align: center; padding: 40px; color: var(--text-muted); font-size: 13px; }
|
||||
|
||||
.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-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-msg { color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
</style>
|
||||
351
frontend/anomaly/short-term.vue
Normal file
351
frontend/anomaly/short-term.vue
Normal file
@@ -0,0 +1,351 @@
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<SidebarNav />
|
||||
<div class="main-content">
|
||||
<header class="main-header">
|
||||
<h1 class="page-title">⚡ 단기 변화율 감지</h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-primary" @click="refresh" :disabled="loading">
|
||||
{{ loading ? '분석 중...' : '🔄 새로고침' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main-body">
|
||||
<!-- 접히는 설명 카드 -->
|
||||
<div class="info-card collapsible" :class="{ collapsed: infoCollapsed }">
|
||||
<div class="info-header" @click="infoCollapsed = !infoCollapsed">
|
||||
<h3>📖 단기 변화율 감지란?</h3>
|
||||
<span class="collapse-icon">{{ infoCollapsed ? '▼' : '▲' }}</span>
|
||||
</div>
|
||||
<div class="info-content" v-show="!infoCollapsed">
|
||||
<p class="desc">
|
||||
최근 5분 평균과 직전 5분 평균을 비교하여 급격한 변화를 감지합니다.<br>
|
||||
<code>변화율 = (현재구간 - 이전구간) / 이전구간 × 100</code>
|
||||
</p>
|
||||
<div class="pros-cons">
|
||||
<div class="pros">
|
||||
<h4>✅ 장점</h4>
|
||||
<ul>
|
||||
<li>구현이 간단하고 직관적</li>
|
||||
<li>즉각적인 변화 감지 가능</li>
|
||||
<li>임계값과 무관하게 이상 탐지</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="cons">
|
||||
<h4>❌ 단점</h4>
|
||||
<ul>
|
||||
<li>정상적인 변동도 이상으로 감지될 수 있음</li>
|
||||
<li>평소 변동이 큰 서버는 오탐 가능성</li>
|
||||
<li>절대값이 낮을 때 변화율이 과대평가됨</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 조회기간 + 차트/테이블 영역 -->
|
||||
<div class="content-section">
|
||||
<!-- 조회 기간 -->
|
||||
<div class="period-row">
|
||||
<span class="period-label">조회 기간</span>
|
||||
<div class="period-buttons">
|
||||
<button
|
||||
v-for="p in periods"
|
||||
:key="p.value"
|
||||
:class="['period-btn', { active: selectedPeriod === p.value }]"
|
||||
@click="changePeriod(p.value)"
|
||||
>{{ p.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 차트 + 테이블 그리드 -->
|
||||
<div class="chart-table-grid">
|
||||
<!-- 왼쪽: 차트 -->
|
||||
<div class="chart-section">
|
||||
<h3>📈 이상감지 추이</h3>
|
||||
<div class="chart-container">
|
||||
<canvas ref="chartRef"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽: 현재 상태 테이블 -->
|
||||
<div class="table-section">
|
||||
<h3>📋 현재 서버 상태</h3>
|
||||
<table class="data-table compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>서버</th>
|
||||
<th>CPU</th>
|
||||
<th>MEM</th>
|
||||
<th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="server in allServers" :key="server.target_id">
|
||||
<td class="server-name">{{ server.server_name }}</td>
|
||||
<td :class="getChangeClass(server.cpu_change)">{{ formatChange(server.cpu_change) }}</td>
|
||||
<td :class="getChangeClass(server.mem_change)">{{ formatChange(server.mem_change) }}</td>
|
||||
<td><span :class="['status-dot', server.status]"></span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 이상감지 로그 -->
|
||||
<div class="log-section">
|
||||
<div class="log-header">
|
||||
<h3>📜 이상감지 로그</h3>
|
||||
<span class="log-count" v-if="logs.length">{{ logs.length }}건</span>
|
||||
</div>
|
||||
|
||||
<div v-if="logs.length === 0" class="no-logs">
|
||||
이상감지 기록이 없습니다
|
||||
</div>
|
||||
|
||||
<div v-else class="log-list">
|
||||
<div
|
||||
v-for="log in logs"
|
||||
:key="log.id"
|
||||
:class="['log-item', log.level]"
|
||||
>
|
||||
<span class="log-time">{{ formatLogTime(log.detected_at) }}</span>
|
||||
<span :class="['log-level', log.level]">{{ log.level === 'danger' ? '🔴' : '🟡' }}</span>
|
||||
<span class="log-server">{{ log.server_name }}</span>
|
||||
<span class="log-metric">{{ log.metric }}</span>
|
||||
<span class="log-value">{{ formatChange(log.threshold_value) }}</span>
|
||||
<span class="log-msg">{{ log.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
Chart.register(...registerables)
|
||||
|
||||
interface ServerChange {
|
||||
target_id: number
|
||||
server_name: string
|
||||
cpu_change: number | null
|
||||
mem_change: number | null
|
||||
status: string
|
||||
}
|
||||
|
||||
interface AnomalyLog {
|
||||
id: number
|
||||
target_id: number
|
||||
server_name: string
|
||||
metric: string
|
||||
level: string
|
||||
current_value: number
|
||||
threshold_value: number
|
||||
message: string
|
||||
detected_at: string
|
||||
}
|
||||
|
||||
interface ChartData {
|
||||
time: string
|
||||
warning: number
|
||||
danger: number
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const infoCollapsed = ref(true)
|
||||
const allServers = ref<ServerChange[]>([])
|
||||
const logs = ref<AnomalyLog[]>([])
|
||||
const chartRef = ref<HTMLCanvasElement | null>(null)
|
||||
let chart: Chart | null = null
|
||||
|
||||
const selectedPeriod = ref('24h')
|
||||
const periods = [
|
||||
{ value: '1h', label: '1시간' },
|
||||
{ value: '6h', label: '6시간' },
|
||||
{ value: '12h', label: '12시간' },
|
||||
{ value: '24h', label: '24시간' },
|
||||
{ value: '7d', label: '7일' },
|
||||
{ value: '30d', label: '30일' }
|
||||
]
|
||||
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
// 현재 상태 조회
|
||||
const statusRes = await $fetch('/api/anomaly/short-term')
|
||||
allServers.value = statusRes.servers || []
|
||||
|
||||
// 로그 조회
|
||||
const logRes = await $fetch(`/api/anomaly/logs?type=short-term&period=${selectedPeriod.value}`)
|
||||
logs.value = logRes.logs || []
|
||||
|
||||
// 차트 데이터 조회
|
||||
const chartRes = await $fetch(`/api/anomaly/chart?type=short-term&period=${selectedPeriod.value}`)
|
||||
updateChart(chartRes.data || [])
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch data:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function changePeriod(period: string) {
|
||||
selectedPeriod.value = period
|
||||
refresh()
|
||||
}
|
||||
|
||||
function updateChart(data: ChartData[]) {
|
||||
if (!chartRef.value) return
|
||||
|
||||
if (chart) chart.destroy()
|
||||
|
||||
chart = new Chart(chartRef.value, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.map(d => d.time),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Warning',
|
||||
data: data.map(d => d.warning),
|
||||
backgroundColor: '#fbbf24',
|
||||
borderRadius: 4
|
||||
},
|
||||
{
|
||||
label: 'Danger',
|
||||
data: data.map(d => d.danger),
|
||||
backgroundColor: '#ef4444',
|
||||
borderRadius: 4
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'top', labels: { boxWidth: 12, font: { size: 11 } } }
|
||||
},
|
||||
scales: {
|
||||
x: { stacked: true, grid: { display: false }, ticks: { font: { size: 10 } } },
|
||||
y: { stacked: true, beginAtZero: true, ticks: { stepSize: 1, font: { size: 10 } } }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
function getChangeClass(change: number | null): string {
|
||||
if (change === null) return ''
|
||||
const abs = Math.abs(change)
|
||||
if (abs >= 100) return 'change-danger'
|
||||
if (abs >= 50) return 'change-warning'
|
||||
if (abs >= 30) return 'change-caution'
|
||||
return ''
|
||||
}
|
||||
|
||||
function formatChange(change: number | null): string {
|
||||
if (change === null) return '-'
|
||||
const sign = change >= 0 ? '+' : ''
|
||||
return `${sign}${change.toFixed(0)}%`
|
||||
}
|
||||
|
||||
function formatLogTime(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refresh()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chart) chart.destroy()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.app-layout { display: flex; height: 100vh; background: var(--bg-primary); }
|
||||
.main-content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.main-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; border-bottom: 1px solid var(--border-color); background: var(--bg-secondary); }
|
||||
.page-title { margin: 0; font-size: 20px; font-weight: 600; color: var(--text-primary); }
|
||||
.main-body { flex: 1; padding: 20px 24px; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.btn { padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; }
|
||||
.btn-primary { background: #3b82f6; color: white; }
|
||||
.btn-primary:hover { background: #2563eb; }
|
||||
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
|
||||
|
||||
/* 접히는 설명 카드 */
|
||||
.info-card { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; overflow: hidden; }
|
||||
.info-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 20px; cursor: pointer; }
|
||||
.info-header:hover { background: var(--bg-tertiary); }
|
||||
.info-header h3 { margin: 0; font-size: 15px; color: var(--text-primary); }
|
||||
.collapse-icon { color: var(--text-muted); font-size: 12px; }
|
||||
.info-content { padding: 0 20px 16px; }
|
||||
.info-content .desc { color: var(--text-secondary); line-height: 1.6; margin-bottom: 12px; font-size: 13px; }
|
||||
.info-content code { background: var(--bg-tertiary); padding: 2px 6px; border-radius: 4px; font-size: 12px; }
|
||||
|
||||
.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 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; }
|
||||
|
||||
/* 조회기간 + 차트/테이블 */
|
||||
.content-section { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; padding: 16px; }
|
||||
.period-row { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--border-color); }
|
||||
.period-label { font-size: 13px; font-weight: 500; color: var(--text-muted); }
|
||||
.period-buttons { display: flex; gap: 6px; }
|
||||
.period-btn { padding: 6px 12px; border: 1px solid var(--border-color); border-radius: 6px; background: var(--bg-primary); color: var(--text-secondary); font-size: 12px; cursor: pointer; }
|
||||
.period-btn:hover { background: var(--bg-tertiary); }
|
||||
.period-btn.active { background: #3b82f6; color: white; border-color: #3b82f6; }
|
||||
|
||||
.chart-table-grid { display: grid; grid-template-columns: 1fr 280px; gap: 16px; }
|
||||
.chart-section, .table-section { background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 8px; padding: 12px; }
|
||||
.chart-section h3, .table-section h3 { margin: 0 0 12px 0; font-size: 14px; color: var(--text-primary); }
|
||||
.chart-container { height: 200px; }
|
||||
|
||||
/* 간소화된 테이블 */
|
||||
.data-table.compact { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.data-table.compact th, .data-table.compact td { padding: 8px 6px; text-align: left; border-bottom: 1px solid var(--border-color); }
|
||||
.data-table.compact th { font-size: 11px; font-weight: 600; color: var(--text-muted); background: var(--bg-tertiary); }
|
||||
.data-table.compact .server-name { font-weight: 500; max-width: 100px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.change-danger { color: #dc2626 !important; font-weight: 600; }
|
||||
.change-warning { color: #ea580c !important; font-weight: 500; }
|
||||
.change-caution { color: #ca8a04 !important; }
|
||||
|
||||
.status-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; }
|
||||
.status-dot.normal { background: #22c55e; }
|
||||
.status-dot.warning { background: #f59e0b; }
|
||||
.status-dot.danger { background: #ef4444; }
|
||||
|
||||
/* 로그 섹션 */
|
||||
.log-section { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; padding: 16px; flex: 1; min-height: 200px; }
|
||||
.log-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.log-header h3 { margin: 0; font-size: 14px; color: var(--text-primary); }
|
||||
.log-count { font-size: 12px; color: var(--text-muted); background: var(--bg-tertiary); padding: 2px 8px; border-radius: 10px; }
|
||||
|
||||
.no-logs { text-align: center; padding: 40px; color: var(--text-muted); font-size: 13px; }
|
||||
|
||||
.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-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-msg { color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
</style>
|
||||
318
frontend/anomaly/trend.vue
Normal file
318
frontend/anomaly/trend.vue
Normal file
@@ -0,0 +1,318 @@
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<SidebarNav />
|
||||
<div class="main-content">
|
||||
<header class="main-header">
|
||||
<h1 class="page-title">📉 추세 분석</h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-primary" @click="refresh" :disabled="loading">
|
||||
{{ loading ? '분석 중...' : '🔄 새로고침' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main-body">
|
||||
<!-- 접히는 설명 카드 -->
|
||||
<div class="info-card collapsible" :class="{ collapsed: infoCollapsed }">
|
||||
<div class="info-header" @click="infoCollapsed = !infoCollapsed">
|
||||
<h3>📖 추세 분석이란?</h3>
|
||||
<span class="collapse-icon">{{ infoCollapsed ? '▼' : '▲' }}</span>
|
||||
</div>
|
||||
<div class="info-content" v-show="!infoCollapsed">
|
||||
<p class="desc">
|
||||
최근 30분간 데이터의 선형 회귀(Linear Regression) 기울기를 분석하여 지속적인 증가/감소 추세를 감지합니다.<br>
|
||||
<code>기울기 = 분당 변화율 (%/min)</code> | R² ≥ 0.3 이면 유의미한 추세 | 기울기 ≥ 0.5%/min: 상승 추세
|
||||
</p>
|
||||
<div class="pros-cons">
|
||||
<div class="pros">
|
||||
<h4>✅ 장점</h4>
|
||||
<ul>
|
||||
<li>임계값 도달 전 사전 경고 가능</li>
|
||||
<li>점진적 리소스 누수 감지에 효과적</li>
|
||||
<li>R²로 추세의 신뢰도 평가 가능</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="cons">
|
||||
<h4>❌ 단점</h4>
|
||||
<ul>
|
||||
<li>정상적인 부하 증가도 경고될 수 있음</li>
|
||||
<li>짧은 스파이크는 감지 못함</li>
|
||||
<li>변동이 심한 서버는 부정확할 수 있음</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 조회기간 + 차트/테이블 영역 -->
|
||||
<div class="content-section">
|
||||
<div class="period-row">
|
||||
<span class="period-label">조회 기간</span>
|
||||
<div class="period-buttons">
|
||||
<button
|
||||
v-for="p in periods"
|
||||
:key="p.value"
|
||||
:class="['period-btn', { active: selectedPeriod === p.value }]"
|
||||
@click="changePeriod(p.value)"
|
||||
>{{ p.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-table-grid">
|
||||
<div class="chart-section">
|
||||
<h3>📈 이상감지 추이</h3>
|
||||
<div class="chart-container">
|
||||
<canvas ref="chartRef"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-section">
|
||||
<h3>📋 현재 추세 상태</h3>
|
||||
<table class="data-table compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>서버</th>
|
||||
<th>CPU</th>
|
||||
<th>MEM</th>
|
||||
<th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="server in allServers" :key="server.target_id">
|
||||
<td class="server-name">{{ server.server_name }}</td>
|
||||
<td :class="getTrendClass(server.cpu_trend)">{{ formatTrend(server.cpu_current, server.cpu_slope, server.cpu_trend) }}</td>
|
||||
<td :class="getTrendClass(server.mem_trend)">{{ formatTrend(server.mem_current, server.mem_slope, server.mem_trend) }}</td>
|
||||
<td><span :class="['status-dot', server.status]"></span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 이상감지 로그 -->
|
||||
<div class="log-section">
|
||||
<div class="log-header">
|
||||
<h3>📜 이상감지 로그</h3>
|
||||
<span class="log-count" v-if="logs.length">{{ logs.length }}건</span>
|
||||
</div>
|
||||
|
||||
<div v-if="logs.length === 0" class="no-logs">
|
||||
이상감지 기록이 없습니다
|
||||
</div>
|
||||
|
||||
<div v-else class="log-list">
|
||||
<div
|
||||
v-for="log in logs"
|
||||
:key="log.id"
|
||||
:class="['log-item', log.level]"
|
||||
>
|
||||
<span class="log-time">{{ formatLogTime(log.detected_at) }}</span>
|
||||
<span :class="['log-level', log.level]">{{ log.level === 'danger' ? '🔴' : '🟡' }}</span>
|
||||
<span class="log-server">{{ log.server_name }}</span>
|
||||
<span class="log-metric">{{ log.metric }}</span>
|
||||
<span class="log-value">+{{ log.threshold_value?.toFixed(2) }}/분</span>
|
||||
<span class="log-msg">{{ log.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
Chart.register(...registerables)
|
||||
|
||||
interface ServerTrend {
|
||||
target_id: number
|
||||
server_name: string
|
||||
cpu_current: number | null
|
||||
mem_current: number | null
|
||||
cpu_slope: number | null
|
||||
mem_slope: number | null
|
||||
cpu_trend: string | null
|
||||
mem_trend: string | null
|
||||
status: string
|
||||
}
|
||||
|
||||
interface AnomalyLog {
|
||||
id: number
|
||||
server_name: string
|
||||
metric: string
|
||||
level: string
|
||||
threshold_value: number
|
||||
message: string
|
||||
detected_at: string
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const infoCollapsed = ref(true)
|
||||
const allServers = ref<ServerTrend[]>([])
|
||||
const logs = ref<AnomalyLog[]>([])
|
||||
const chartRef = ref<HTMLCanvasElement | null>(null)
|
||||
let chart: Chart | null = null
|
||||
|
||||
const selectedPeriod = ref('24h')
|
||||
const periods = [
|
||||
{ value: '1h', label: '1시간' },
|
||||
{ value: '6h', label: '6시간' },
|
||||
{ value: '12h', label: '12시간' },
|
||||
{ value: '24h', label: '24시간' },
|
||||
{ value: '7d', label: '7일' },
|
||||
{ value: '30d', label: '30일' }
|
||||
]
|
||||
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
const statusRes = await $fetch('/api/anomaly/trend')
|
||||
allServers.value = statusRes.servers || []
|
||||
|
||||
const logRes = await $fetch(`/api/anomaly/logs?type=trend&period=${selectedPeriod.value}`)
|
||||
logs.value = logRes.logs || []
|
||||
|
||||
const chartRes = await $fetch(`/api/anomaly/chart?type=trend&period=${selectedPeriod.value}`)
|
||||
updateChart(chartRes.data || [])
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch data:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function changePeriod(period: string) {
|
||||
selectedPeriod.value = period
|
||||
refresh()
|
||||
}
|
||||
|
||||
function updateChart(data: any[]) {
|
||||
if (!chartRef.value) return
|
||||
if (chart) chart.destroy()
|
||||
|
||||
chart = new Chart(chartRef.value, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.map(d => d.time),
|
||||
datasets: [
|
||||
{ label: 'Warning', data: data.map(d => d.warning), backgroundColor: '#fbbf24', borderRadius: 4 },
|
||||
{ label: 'Danger', data: data.map(d => d.danger), backgroundColor: '#ef4444', borderRadius: 4 }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'top', labels: { boxWidth: 12, font: { size: 11 } } } },
|
||||
scales: {
|
||||
x: { stacked: true, grid: { display: false }, ticks: { font: { size: 10 } } },
|
||||
y: { stacked: true, beginAtZero: true, ticks: { stepSize: 1, font: { size: 10 } } }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getTrendClass(trend: string | null): string {
|
||||
if (!trend) return ''
|
||||
if (trend === 'rising') return 'trend-rising'
|
||||
if (trend === 'falling') return 'trend-falling'
|
||||
if (trend === 'unstable') return 'trend-unstable'
|
||||
return ''
|
||||
}
|
||||
|
||||
function formatTrend(current: number | null, slope: number | null, trend: string | null): string {
|
||||
if (current === null || slope === null || !trend) return '-'
|
||||
if (trend === 'unstable') return `${current.toFixed(0)}% (불안정)`
|
||||
const icon = slope >= 0 ? '↑' : '↓'
|
||||
return `${current.toFixed(0)}% ${icon}${Math.abs(slope).toFixed(2)}/분`
|
||||
}
|
||||
|
||||
function formatLogTime(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
|
||||
onMounted(() => { refresh() })
|
||||
onUnmounted(() => { if (chart) chart.destroy() })
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.app-layout { display: flex; height: 100vh; background: var(--bg-primary); }
|
||||
.main-content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.main-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; border-bottom: 1px solid var(--border-color); background: var(--bg-secondary); }
|
||||
.page-title { margin: 0; font-size: 20px; font-weight: 600; color: var(--text-primary); }
|
||||
.header-actions { display: flex; align-items: center; gap: 12px; }
|
||||
.main-body { flex: 1; padding: 20px 24px; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.btn { padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; }
|
||||
.btn-primary { background: #3b82f6; color: white; }
|
||||
.btn-primary:hover { background: #2563eb; }
|
||||
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
|
||||
|
||||
.info-card { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; overflow: hidden; }
|
||||
.info-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 20px; cursor: pointer; }
|
||||
.info-header:hover { background: var(--bg-tertiary); }
|
||||
.info-header h3 { margin: 0; font-size: 15px; color: var(--text-primary); }
|
||||
.collapse-icon { color: var(--text-muted); font-size: 12px; }
|
||||
.info-content { padding: 0 20px 16px; }
|
||||
.info-content .desc { color: var(--text-secondary); line-height: 1.6; margin-bottom: 12px; font-size: 13px; }
|
||||
.info-content code { background: var(--bg-tertiary); padding: 2px 6px; border-radius: 4px; font-size: 12px; }
|
||||
|
||||
.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 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); }
|
||||
|
||||
.content-section { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; padding: 16px; }
|
||||
.period-row { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--border-color); }
|
||||
.period-label { font-size: 13px; font-weight: 500; color: var(--text-muted); }
|
||||
.period-buttons { display: flex; gap: 6px; }
|
||||
.period-btn { padding: 6px 12px; border: 1px solid var(--border-color); border-radius: 6px; background: var(--bg-primary); color: var(--text-secondary); font-size: 12px; cursor: pointer; }
|
||||
.period-btn:hover { background: var(--bg-tertiary); }
|
||||
.period-btn.active { background: #3b82f6; color: white; border-color: #3b82f6; }
|
||||
|
||||
.chart-table-grid { display: grid; grid-template-columns: 1fr 320px; gap: 16px; }
|
||||
.chart-section, .table-section { background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 8px; padding: 12px; }
|
||||
.chart-section h3, .table-section h3 { margin: 0 0 12px 0; font-size: 14px; color: var(--text-primary); }
|
||||
.chart-container { height: 200px; }
|
||||
|
||||
.data-table.compact { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.data-table.compact th, .data-table.compact td { padding: 8px 6px; text-align: left; border-bottom: 1px solid var(--border-color); }
|
||||
.data-table.compact th { font-size: 11px; font-weight: 600; color: var(--text-muted); background: var(--bg-tertiary); }
|
||||
.data-table.compact .server-name { font-weight: 500; max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.trend-rising { color: #dc2626 !important; font-weight: 600; }
|
||||
.trend-falling { color: #16a34a !important; font-weight: 500; }
|
||||
.trend-unstable { color: #9ca3af !important; font-style: italic; }
|
||||
|
||||
.status-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; }
|
||||
.status-dot.normal { background: #22c55e; }
|
||||
.status-dot.warning { background: #f59e0b; }
|
||||
.status-dot.danger { background: #ef4444; }
|
||||
.status-dot.insufficient { background: #9ca3af; }
|
||||
|
||||
.log-section { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; padding: 16px; flex: 1; min-height: 200px; }
|
||||
.log-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.log-header h3 { margin: 0; font-size: 14px; color: var(--text-primary); }
|
||||
.log-count { font-size: 12px; color: var(--text-muted); background: var(--bg-tertiary); padding: 2px 8px; border-radius: 10px; }
|
||||
|
||||
.no-logs { text-align: center; padding: 40px; color: var(--text-muted); font-size: 13px; }
|
||||
|
||||
.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-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-msg { color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
</style>
|
||||
312
frontend/anomaly/zscore.vue
Normal file
312
frontend/anomaly/zscore.vue
Normal file
@@ -0,0 +1,312 @@
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<SidebarNav />
|
||||
<div class="main-content">
|
||||
<header class="main-header">
|
||||
<h1 class="page-title">📊 Z-Score 분석</h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-primary" @click="refresh" :disabled="loading">
|
||||
{{ loading ? '분석 중...' : '🔄 새로고침' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main-body">
|
||||
<!-- 접히는 설명 카드 -->
|
||||
<div class="info-card collapsible" :class="{ collapsed: infoCollapsed }">
|
||||
<div class="info-header" @click="infoCollapsed = !infoCollapsed">
|
||||
<h3>📖 Z-Score 분석이란?</h3>
|
||||
<span class="collapse-icon">{{ infoCollapsed ? '▼' : '▲' }}</span>
|
||||
</div>
|
||||
<div class="info-content" v-show="!infoCollapsed">
|
||||
<p class="desc">
|
||||
최근 1시간의 평균과 표준편차를 기반으로, 현재 값이 정상 범위에서 얼마나 벗어났는지 측정합니다.<br>
|
||||
<code>Z-Score = (현재값 - 평균) / 표준편차</code> | |Z| ≥ 2.0: 주의, |Z| ≥ 3.0: 위험
|
||||
</p>
|
||||
<div class="pros-cons">
|
||||
<div class="pros">
|
||||
<h4>✅ 장점</h4>
|
||||
<ul>
|
||||
<li>통계적 근거가 있는 이상 탐지</li>
|
||||
<li>서버별 특성(평소 사용량)을 자동 반영</li>
|
||||
<li>|Z| > 2 면 이상, |Z| > 3 이면 심각 등 기준 명확</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="cons">
|
||||
<h4>❌ 단점</h4>
|
||||
<ul>
|
||||
<li>충분한 히스토리 데이터 필요 (최소 1시간)</li>
|
||||
<li>데이터가 정규분포를 따르지 않으면 부정확</li>
|
||||
<li>신규 서버는 베이스라인 구축까지 사용 불가</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 조회기간 + 차트/테이블 영역 -->
|
||||
<div class="content-section">
|
||||
<div class="period-row">
|
||||
<span class="period-label">조회 기간</span>
|
||||
<div class="period-buttons">
|
||||
<button
|
||||
v-for="p in periods"
|
||||
:key="p.value"
|
||||
:class="['period-btn', { active: selectedPeriod === p.value }]"
|
||||
@click="changePeriod(p.value)"
|
||||
>{{ p.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-table-grid">
|
||||
<div class="chart-section">
|
||||
<h3>📈 이상감지 추이</h3>
|
||||
<div class="chart-container">
|
||||
<canvas ref="chartRef"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-section">
|
||||
<h3>📋 현재 서버 Z-Score</h3>
|
||||
<table class="data-table compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>서버</th>
|
||||
<th>CPU (Z)</th>
|
||||
<th>MEM (Z)</th>
|
||||
<th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="server in allServers" :key="server.target_id">
|
||||
<td class="server-name">{{ server.server_name }}</td>
|
||||
<td :class="getZscoreClass(server.cpu_zscore)">{{ formatZscore(server.cpu_current, server.cpu_zscore) }}</td>
|
||||
<td :class="getZscoreClass(server.mem_zscore)">{{ formatZscore(server.mem_current, server.mem_zscore) }}</td>
|
||||
<td><span :class="['status-dot', server.status]"></span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 이상감지 로그 -->
|
||||
<div class="log-section">
|
||||
<div class="log-header">
|
||||
<h3>📜 이상감지 로그</h3>
|
||||
<span class="log-count" v-if="logs.length">{{ logs.length }}건</span>
|
||||
</div>
|
||||
|
||||
<div v-if="logs.length === 0" class="no-logs">
|
||||
이상감지 기록이 없습니다
|
||||
</div>
|
||||
|
||||
<div v-else class="log-list">
|
||||
<div
|
||||
v-for="log in logs"
|
||||
:key="log.id"
|
||||
:class="['log-item', log.level]"
|
||||
>
|
||||
<span class="log-time">{{ formatLogTime(log.detected_at) }}</span>
|
||||
<span :class="['log-level', log.level]">{{ log.level === 'danger' ? '🔴' : '🟡' }}</span>
|
||||
<span class="log-server">{{ log.server_name }}</span>
|
||||
<span class="log-metric">{{ log.metric }}</span>
|
||||
<span class="log-value">Z={{ log.threshold_value?.toFixed(2) }}</span>
|
||||
<span class="log-msg">{{ log.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
Chart.register(...registerables)
|
||||
|
||||
interface ServerZscore {
|
||||
target_id: number
|
||||
server_name: string
|
||||
cpu_current: number
|
||||
mem_current: number
|
||||
cpu_zscore: number | null
|
||||
mem_zscore: number | null
|
||||
status: string
|
||||
}
|
||||
|
||||
interface AnomalyLog {
|
||||
id: number
|
||||
server_name: string
|
||||
metric: string
|
||||
level: string
|
||||
threshold_value: number
|
||||
message: string
|
||||
detected_at: string
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const infoCollapsed = ref(true)
|
||||
const allServers = ref<ServerZscore[]>([])
|
||||
const logs = ref<AnomalyLog[]>([])
|
||||
const chartRef = ref<HTMLCanvasElement | null>(null)
|
||||
let chart: Chart | null = null
|
||||
|
||||
const selectedPeriod = ref('24h')
|
||||
const periods = [
|
||||
{ value: '1h', label: '1시간' },
|
||||
{ value: '6h', label: '6시간' },
|
||||
{ value: '12h', label: '12시간' },
|
||||
{ value: '24h', label: '24시간' },
|
||||
{ value: '7d', label: '7일' },
|
||||
{ value: '30d', label: '30일' }
|
||||
]
|
||||
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
const statusRes = await $fetch('/api/anomaly/zscore')
|
||||
allServers.value = statusRes.servers || []
|
||||
|
||||
const logRes = await $fetch(`/api/anomaly/logs?type=zscore&period=${selectedPeriod.value}`)
|
||||
logs.value = logRes.logs || []
|
||||
|
||||
const chartRes = await $fetch(`/api/anomaly/chart?type=zscore&period=${selectedPeriod.value}`)
|
||||
updateChart(chartRes.data || [])
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch data:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function changePeriod(period: string) {
|
||||
selectedPeriod.value = period
|
||||
refresh()
|
||||
}
|
||||
|
||||
function updateChart(data: any[]) {
|
||||
if (!chartRef.value) return
|
||||
if (chart) chart.destroy()
|
||||
|
||||
chart = new Chart(chartRef.value, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.map(d => d.time),
|
||||
datasets: [
|
||||
{ label: 'Warning', data: data.map(d => d.warning), backgroundColor: '#fbbf24', borderRadius: 4 },
|
||||
{ label: 'Danger', data: data.map(d => d.danger), backgroundColor: '#ef4444', borderRadius: 4 }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'top', labels: { boxWidth: 12, font: { size: 11 } } } },
|
||||
scales: {
|
||||
x: { stacked: true, grid: { display: false }, ticks: { font: { size: 10 } } },
|
||||
y: { stacked: true, beginAtZero: true, ticks: { stepSize: 1, font: { size: 10 } } }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getZscoreClass(z: number | null): string {
|
||||
if (z === null) return ''
|
||||
const abs = Math.abs(z)
|
||||
if (abs >= 3) return 'zscore-danger'
|
||||
if (abs >= 2) return 'zscore-warning'
|
||||
return ''
|
||||
}
|
||||
|
||||
function formatZscore(current: number | null, z: number | null): string {
|
||||
if (current === null || z === null) return '-'
|
||||
return `${current.toFixed(0)}% (${z >= 0 ? '+' : ''}${z.toFixed(1)})`
|
||||
}
|
||||
|
||||
function formatLogTime(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
|
||||
onMounted(() => { refresh() })
|
||||
onUnmounted(() => { if (chart) chart.destroy() })
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.app-layout { display: flex; height: 100vh; background: var(--bg-primary); }
|
||||
.main-content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.main-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; border-bottom: 1px solid var(--border-color); background: var(--bg-secondary); }
|
||||
.page-title { margin: 0; font-size: 20px; font-weight: 600; color: var(--text-primary); }
|
||||
.main-body { flex: 1; padding: 20px 24px; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.btn { padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; }
|
||||
.btn-primary { background: #3b82f6; color: white; }
|
||||
.btn-primary:hover { background: #2563eb; }
|
||||
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
|
||||
|
||||
.info-card { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; overflow: hidden; }
|
||||
.info-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 20px; cursor: pointer; }
|
||||
.info-header:hover { background: var(--bg-tertiary); }
|
||||
.info-header h3 { margin: 0; font-size: 15px; color: var(--text-primary); }
|
||||
.collapse-icon { color: var(--text-muted); font-size: 12px; }
|
||||
.info-content { padding: 0 20px 16px; }
|
||||
.info-content .desc { color: var(--text-secondary); line-height: 1.6; margin-bottom: 12px; font-size: 13px; }
|
||||
.info-content code { background: var(--bg-tertiary); padding: 2px 6px; border-radius: 4px; font-size: 12px; }
|
||||
|
||||
.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 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); }
|
||||
|
||||
.content-section { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; padding: 16px; }
|
||||
.period-row { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--border-color); }
|
||||
.period-label { font-size: 13px; font-weight: 500; color: var(--text-muted); }
|
||||
.period-buttons { display: flex; gap: 6px; }
|
||||
.period-btn { padding: 6px 12px; border: 1px solid var(--border-color); border-radius: 6px; background: var(--bg-primary); color: var(--text-secondary); font-size: 12px; cursor: pointer; }
|
||||
.period-btn:hover { background: var(--bg-tertiary); }
|
||||
.period-btn.active { background: #3b82f6; color: white; border-color: #3b82f6; }
|
||||
|
||||
.chart-table-grid { display: grid; grid-template-columns: 1fr 300px; gap: 16px; }
|
||||
.chart-section, .table-section { background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 8px; padding: 12px; }
|
||||
.chart-section h3, .table-section h3 { margin: 0 0 12px 0; font-size: 14px; color: var(--text-primary); }
|
||||
.chart-container { height: 200px; }
|
||||
|
||||
.data-table.compact { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.data-table.compact th, .data-table.compact td { padding: 8px 6px; text-align: left; border-bottom: 1px solid var(--border-color); }
|
||||
.data-table.compact th { font-size: 11px; font-weight: 600; color: var(--text-muted); background: var(--bg-tertiary); }
|
||||
.data-table.compact .server-name { font-weight: 500; max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.zscore-warning { color: #ca8a04 !important; font-weight: 500; }
|
||||
.zscore-danger { color: #dc2626 !important; font-weight: 600; }
|
||||
|
||||
.status-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; }
|
||||
.status-dot.normal { background: #22c55e; }
|
||||
.status-dot.warning { background: #f59e0b; }
|
||||
.status-dot.danger { background: #ef4444; }
|
||||
.status-dot.insufficient { background: #9ca3af; }
|
||||
|
||||
.log-section { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; padding: 16px; flex: 1; min-height: 200px; }
|
||||
.log-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.log-header h3 { margin: 0; font-size: 14px; color: var(--text-primary); }
|
||||
.log-count { font-size: 12px; color: var(--text-muted); background: var(--bg-tertiary); padding: 2px 8px; border-radius: 10px; }
|
||||
|
||||
.no-logs { text-align: center; padding: 40px; color: var(--text-muted); font-size: 13px; }
|
||||
|
||||
.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-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-msg { color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
</style>
|
||||
722
frontend/assets/css/main.css
Normal file
722
frontend/assets/css/main.css
Normal file
@@ -0,0 +1,722 @@
|
||||
/* CSS 변수 - 라이트 테마 (기본) */
|
||||
:root,
|
||||
:root[data-theme="light"] {
|
||||
--bg-primary: #f0f2f5;
|
||||
--bg-secondary: #fff;
|
||||
--bg-tertiary: #fafafa;
|
||||
--bg-hover: #e8e8e8;
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: #333;
|
||||
--text-muted: #666;
|
||||
--text-dim: #999;
|
||||
--border-color: #ddd;
|
||||
--border-light: #eee;
|
||||
|
||||
/* 상태 색상 */
|
||||
--success-bg: linear-gradient(135deg, #e8f5e8 0%, #fff 100%);
|
||||
--success-header: #d4edda;
|
||||
--success-border: #28a745;
|
||||
--success-text: #155724;
|
||||
|
||||
--fail-bg: linear-gradient(135deg, #f5e8e8 0%, #fff 100%);
|
||||
--fail-header: #f8d7da;
|
||||
--fail-border: #dc3545;
|
||||
--fail-text: #721c24;
|
||||
|
||||
/* 버튼 */
|
||||
--btn-active-bg: #555;
|
||||
--btn-active-border: #444;
|
||||
--btn-primary-bg: #555;
|
||||
--btn-primary-border: #444;
|
||||
--btn-primary-hover: #444;
|
||||
--btn-auto-active-bg: #1a1a1a;
|
||||
--btn-auto-active-border: #1a1a1a;
|
||||
--btn-auto-active-text: #fff;
|
||||
|
||||
/* 사이드바 */
|
||||
--sidebar-bg: #fff;
|
||||
--sidebar-active-bg: #e8e8e8;
|
||||
--sidebar-active-border: #4a90d9;
|
||||
|
||||
/* 기타 */
|
||||
--link-color: #4a90d9;
|
||||
--time-color: #4a90d9;
|
||||
--input-bg: #fff;
|
||||
|
||||
/* 테마 토글 버튼 */
|
||||
--theme-toggle-bg: #1a1a1a;
|
||||
--theme-toggle-text: #fff;
|
||||
--theme-toggle-border: #1a1a1a;
|
||||
}
|
||||
|
||||
/* 다크 테마 */
|
||||
:root[data-theme="dark"] {
|
||||
--bg-primary: #1a1a1a;
|
||||
--bg-secondary: #2d2d2d;
|
||||
--bg-tertiary: #252525;
|
||||
--bg-hover: #3a3a3a;
|
||||
--text-primary: #fff;
|
||||
--text-secondary: #e0e0e0;
|
||||
--text-muted: #b0b0b0;
|
||||
--text-dim: #909090;
|
||||
--border-color: #444;
|
||||
--border-light: #3a3a3a;
|
||||
|
||||
/* 상태 색상 */
|
||||
--success-bg: linear-gradient(135deg, #1a3d1a 0%, #2d2d2d 100%);
|
||||
--success-header: #2a4d2a;
|
||||
--success-border: #4ade80;
|
||||
--success-text: #86efac;
|
||||
|
||||
--fail-bg: linear-gradient(135deg, #3d1a1a 0%, #2d2d2d 100%);
|
||||
--fail-header: #4d2a2a;
|
||||
--fail-border: #f87171;
|
||||
--fail-text: #fca5a5;
|
||||
|
||||
/* 버튼 */
|
||||
--btn-active-bg: #555;
|
||||
--btn-active-border: #666;
|
||||
--btn-primary-bg: #555;
|
||||
--btn-primary-border: #666;
|
||||
--btn-primary-hover: #666;
|
||||
--btn-auto-active-bg: #fff;
|
||||
--btn-auto-active-border: #fff;
|
||||
--btn-auto-active-text: #1a1a1a;
|
||||
|
||||
/* 사이드바 */
|
||||
--sidebar-bg: #2d2d2d;
|
||||
--sidebar-active-bg: #3a3a3a;
|
||||
--sidebar-active-border: #6b9dc4;
|
||||
|
||||
/* 기타 */
|
||||
--link-color: #6b9dc4;
|
||||
--time-color: #6b9dc4;
|
||||
--input-bg: #1a1a1a;
|
||||
|
||||
/* 테마 토글 버튼 */
|
||||
--theme-toggle-bg: #fff;
|
||||
--theme-toggle-text: #1a1a1a;
|
||||
--theme-toggle-border: #fff;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-primary);
|
||||
transition: background 0.3s, color 0.3s;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 레이아웃 */
|
||||
.app-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 사이드바 */
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
background: var(--sidebar-bg);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 20px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sidebar-logo .logo-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: block;
|
||||
padding: 10px 20px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--sidebar-active-bg);
|
||||
color: var(--text-primary);
|
||||
border-left: 3px solid var(--sidebar-active-border);
|
||||
}
|
||||
|
||||
.nav-item .icon {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.nav-group-title {
|
||||
padding: 16px 20px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.nav-sub-item {
|
||||
padding-left: 40px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 메인 컨텐츠 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
background: var(--bg-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.main-header {
|
||||
background: var(--bg-secondary);
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.current-time {
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--theme-toggle-border);
|
||||
background: var(--theme-toggle-bg);
|
||||
color: var(--theme-toggle-text);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.main-body {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 컨트롤 영역 */
|
||||
.control-panel {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 24px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.control-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-row + .control-row {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.control-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.interval-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.interval-btn {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.interval-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.interval-btn.active {
|
||||
background: var(--btn-active-bg);
|
||||
color: #fff;
|
||||
border-color: var(--btn-active-border);
|
||||
}
|
||||
|
||||
.control-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
padding: 6px 16px;
|
||||
border: 1px solid var(--btn-primary-border);
|
||||
background: var(--btn-primary-bg);
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
background: var(--btn-primary-hover);
|
||||
}
|
||||
|
||||
.auto-refresh-btn {
|
||||
padding: 6px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.auto-refresh-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.auto-refresh-btn.active {
|
||||
background: var(--btn-auto-active-bg);
|
||||
color: var(--btn-auto-active-text);
|
||||
border-color: var(--btn-auto-active-border);
|
||||
}
|
||||
|
||||
.datetime-input {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--input-bg);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
transition: background 0.3s, border 0.3s;
|
||||
}
|
||||
|
||||
.datetime-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--link-color);
|
||||
}
|
||||
|
||||
.fetch-btn {
|
||||
padding: 6px 16px;
|
||||
border: 1px solid var(--btn-primary-border);
|
||||
background: var(--btn-primary-bg);
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.fetch-btn:hover {
|
||||
background: var(--btn-primary-hover);
|
||||
}
|
||||
|
||||
.last-fetch-info {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.last-fetch-time {
|
||||
color: var(--text-muted);
|
||||
font-family: 'SF Mono', Monaco, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* 포틀릿 그리드 */
|
||||
.portlet-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* 포틀릿 */
|
||||
.portlet {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.portlet.status-success {
|
||||
border-color: var(--success-border);
|
||||
background: var(--success-bg);
|
||||
}
|
||||
|
||||
.portlet.status-success .portlet-header {
|
||||
background: var(--success-header);
|
||||
border-bottom-color: var(--success-border);
|
||||
}
|
||||
|
||||
.portlet.status-fail {
|
||||
border-color: var(--fail-border);
|
||||
background: var(--fail-bg);
|
||||
}
|
||||
|
||||
.portlet.status-fail .portlet-header {
|
||||
background: var(--fail-header);
|
||||
border-bottom-color: var(--fail-border);
|
||||
}
|
||||
|
||||
.portlet-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.portlet-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.portlet-title .icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.portlet-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.portlet-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.main-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.main-status .datetime {
|
||||
color: var(--text-muted);
|
||||
font-family: 'SF Mono', Monaco, 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.main-status .status-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.main-status .target-name {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.log-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.log-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.log-item .datetime {
|
||||
color: var(--text-muted);
|
||||
font-family: 'SF Mono', Monaco, 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.log-item .status-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.log-item .target-name {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 연결 상태 */
|
||||
.connection-status {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
color: var(--fail-text);
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.connection-status.connected {
|
||||
color: var(--success-text);
|
||||
border-color: var(--success-border);
|
||||
}
|
||||
|
||||
/* 목록 페이지 */
|
||||
.list-page {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background: var(--bg-secondary);
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.page-main {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.status-card {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-item .label {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-item .value {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-item .value.active { color: var(--success-text); }
|
||||
.status-item .value.inactive { color: var(--fail-text); }
|
||||
.status-item .value.healthy { color: var(--success-text); }
|
||||
.status-item .value.unhealthy { color: var(--fail-text); }
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--bg-tertiary);
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.url-cell {
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.error-cell {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--fail-text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.sidebar-logo span,
|
||||
.nav-item span:not(.icon),
|
||||
.nav-group-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nav-item .icon {
|
||||
margin-right: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.portlet-grid {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
213
frontend/components/DashboardControl.vue
Normal file
213
frontend/components/DashboardControl.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<div class="control-panel">
|
||||
<div class="control-row">
|
||||
<span class="control-label">조회 간격:</span>
|
||||
<div class="interval-buttons">
|
||||
<button
|
||||
v-for="min in [1, 2, 3, 4, 5]"
|
||||
:key="min"
|
||||
:class="['interval-btn', { active: autoRefresh && interval === min }]"
|
||||
@click="selectInterval(min)"
|
||||
>
|
||||
{{ min }}분
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="control-divider"></div>
|
||||
|
||||
<button
|
||||
:class="['auto-refresh-btn', { active: autoRefresh }]"
|
||||
@click="toggleAutoRefresh"
|
||||
>
|
||||
{{ autoRefresh ? '⏸ 자동갱신 ON' : '▶ 자동갱신 OFF' }}
|
||||
</button>
|
||||
|
||||
<div class="last-fetch-info">
|
||||
마지막 조회: <span class="last-fetch-time">{{ relativeTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-row">
|
||||
<span class="control-label">특정시간:</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
v-model="selectedDatetime"
|
||||
class="datetime-input"
|
||||
:disabled="autoRefresh || fetchState !== 'idle'"
|
||||
:class="{ disabled: autoRefresh || fetchState !== 'idle' }"
|
||||
/>
|
||||
<button
|
||||
class="refresh-btn"
|
||||
:disabled="autoRefresh || fetchState !== 'idle'"
|
||||
:class="{
|
||||
disabled: autoRefresh,
|
||||
loading: fetchState === 'loading',
|
||||
success: fetchState === 'success'
|
||||
}"
|
||||
@click="doRefresh"
|
||||
>
|
||||
<span v-if="fetchState === 'loading'" class="loading-spinner"></span>
|
||||
{{ buttonText }}
|
||||
</button>
|
||||
<span v-if="autoRefresh" class="hint-text">자동갱신 OFF 시 특정시간 조회 가능</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
interval: number
|
||||
autoRefresh: boolean
|
||||
lastFetchTime: Date | null
|
||||
fetchState: 'idle' | 'loading' | 'success'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:interval', value: number): void
|
||||
(e: 'update:autoRefresh', value: boolean): void
|
||||
(e: 'fetchAt', datetime: string): void
|
||||
(e: 'refresh'): void
|
||||
}>()
|
||||
|
||||
// props destructure for template
|
||||
const { interval, autoRefresh, fetchState } = toRefs(props)
|
||||
|
||||
const selectedDatetime = ref('')
|
||||
const relativeTime = ref('-')
|
||||
|
||||
const buttonText = computed(() => {
|
||||
switch (props.fetchState) {
|
||||
case 'loading': return '조회 중...'
|
||||
case 'success': return '✓ 완료'
|
||||
default: return '조회'
|
||||
}
|
||||
})
|
||||
|
||||
function getCurrentDatetimeLocal(): string {
|
||||
const now = new Date()
|
||||
const y = now.getFullYear()
|
||||
const m = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(now.getDate()).padStart(2, '0')
|
||||
const h = String(now.getHours()).padStart(2, '0')
|
||||
const min = String(now.getMinutes()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}T${h}:${min}`
|
||||
}
|
||||
|
||||
function getRelativeTime(date: Date | null): string {
|
||||
if (!date) return '-'
|
||||
|
||||
const now = new Date()
|
||||
const diff = Math.floor((now.getTime() - date.getTime()) / 1000)
|
||||
|
||||
if (diff < 5) return '방금 전'
|
||||
if (diff < 60) return `${diff}초 전`
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}분 전`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}시간 전`
|
||||
return `${Math.floor(diff / 86400)}일 전`
|
||||
}
|
||||
|
||||
function updateRelativeTime() {
|
||||
relativeTime.value = getRelativeTime(props.lastFetchTime)
|
||||
}
|
||||
|
||||
let timeTimer: ReturnType<typeof window.setInterval> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
updateRelativeTime()
|
||||
timeTimer = window.setInterval(updateRelativeTime, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timeTimer) {
|
||||
window.clearInterval(timeTimer)
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.lastFetchTime, () => {
|
||||
updateRelativeTime()
|
||||
})
|
||||
|
||||
// 조회간격 선택 → 자동갱신 ON + 특정시간 초기화
|
||||
function selectInterval(min: number) {
|
||||
selectedDatetime.value = ''
|
||||
emit('update:interval', min)
|
||||
if (!props.autoRefresh) {
|
||||
emit('update:autoRefresh', true)
|
||||
}
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
const newValue = !props.autoRefresh
|
||||
emit('update:autoRefresh', newValue)
|
||||
|
||||
if (newValue) {
|
||||
// 자동갱신 ON 시 특정시간 초기화 + 즉시 조회
|
||||
selectedDatetime.value = ''
|
||||
emit('refresh')
|
||||
} else {
|
||||
// 자동갱신 OFF 시 현재시간을 기본값으로 설정
|
||||
selectedDatetime.value = getCurrentDatetimeLocal()
|
||||
}
|
||||
}
|
||||
|
||||
function doRefresh() {
|
||||
// 자동갱신 OFF일 때만 동작
|
||||
if (props.autoRefresh || props.fetchState !== 'idle') return
|
||||
|
||||
// 특정시간 입력된 경우 → 해당 시간 조회
|
||||
if (selectedDatetime.value) {
|
||||
emit('fetchAt', selectedDatetime.value.replace('T', ' '))
|
||||
} else {
|
||||
// 특정시간 미입력 → 현재 시점 조회
|
||||
emit('refresh')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.datetime-input.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.refresh-btn.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.refresh-btn.loading {
|
||||
cursor: wait;
|
||||
background: var(--btn-primary-bg);
|
||||
}
|
||||
|
||||
.refresh-btn.success {
|
||||
background: var(--success-border);
|
||||
border-color: var(--success-border);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid #fff;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
84
frontend/components/NetworkPortlet.vue
Normal file
84
frontend/components/NetworkPortlet.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<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>
|
||||
<div class="card-time" v-if="status && status.last_checked_at">
|
||||
{{ formatTimeAgo(status.last_checked_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
type: 'pubnet' | 'privnet'
|
||||
title: string
|
||||
icon: string
|
||||
status: {
|
||||
is_healthy: number
|
||||
last_checked_at: string | null
|
||||
last_target_name: string | null
|
||||
} | null
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const statusClass = computed(() => {
|
||||
if (!props.status || !props.status.last_checked_at) return 'pending'
|
||||
return props.status.is_healthy ? 'healthy' : 'unhealthy'
|
||||
})
|
||||
|
||||
function goToList() {
|
||||
router.push(`/network/${props.type}`)
|
||||
}
|
||||
|
||||
function formatTimeAgo(datetime: string | null): string {
|
||||
if (!datetime) return '-'
|
||||
const now = new Date()
|
||||
const then = new Date(datetime.replace(' ', 'T') + '+09:00')
|
||||
const diff = Math.floor((now.getTime() - then.getTime()) / 1000)
|
||||
if (diff < 60) return `${diff}초 전`
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}분 전`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}시간 전`
|
||||
return `${Math.floor(diff / 86400)}일 전`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.network-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 16px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
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);
|
||||
}
|
||||
.network-card.healthy { border-top: 4px solid #22c55e; }
|
||||
.network-card.unhealthy { border-top: 4px solid #ef4444; }
|
||||
.network-card.pending { border-top: 4px 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-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-time { font-size: 12px; color: var(--text-muted); }
|
||||
</style>
|
||||
319
frontend/components/ServerPortlet.vue
Normal file
319
frontend/components/ServerPortlet.vue
Normal file
@@ -0,0 +1,319 @@
|
||||
<template>
|
||||
<div class="server-portlet">
|
||||
<div class="portlet-header">
|
||||
<h2 class="portlet-title">🖥️ 서버 현황</h2>
|
||||
<div class="summary-badges">
|
||||
<span class="badge">
|
||||
서버 {{ summary.servers.total }}대
|
||||
<span class="level-counts">
|
||||
<span class="lv">🟢{{ summary.servers.normal }}</span>
|
||||
<span class="lv">🟡{{ summary.servers.warning }}</span>
|
||||
<span class="lv">🟠{{ summary.servers.critical }}</span>
|
||||
<span class="lv">🔴{{ summary.servers.danger }}</span>
|
||||
<span class="lv">⚫{{ summary.servers.offline }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="divider">|</span>
|
||||
<span class="badge">
|
||||
컨테이너 {{ summary.containers.total }}개
|
||||
<span class="level-counts">
|
||||
<span class="lv">🟢{{ summary.containers.normal }}</span>
|
||||
<span class="lv">🟡{{ summary.containers.warning }}</span>
|
||||
<span class="lv">🟠{{ summary.containers.critical }}</span>
|
||||
<span class="lv">🔴{{ (summary.containers.danger || 0) + summary.containers.stopped }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</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>
|
||||
<span class="container-count" v-if="server.level !== 'offline'">📦{{ server.container_summary.total }}</span>
|
||||
</div>
|
||||
|
||||
<template v-if="server.level !== 'offline'">
|
||||
<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>
|
||||
</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>
|
||||
<span :class="['metric-value', server.memory_level]">{{ server.memory_percent?.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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="offline-info">
|
||||
<div class="offline-text">오프라인</div>
|
||||
<div class="offline-time">{{ formatTimeAgo(server.last_collected) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 컨테이너 영역 (오른쪽) -->
|
||||
<div class="container-area" v-if="server.level !== 'offline'">
|
||||
<div
|
||||
v-for="container in sortContainers(server.containers)"
|
||||
:key="server.target_id + '-' + container.name"
|
||||
:class="['container-card', container.level]"
|
||||
@click="goToServerStatus(server.target_id, container.name)"
|
||||
>
|
||||
<div class="card-header">
|
||||
<div class="card-name">
|
||||
<span class="card-level">{{ levelIcon(container.level) }}</span>
|
||||
<span class="name">{{ container.name }}</span>
|
||||
</div>
|
||||
<span class="card-uptime" v-if="container.status === 'running'">{{ container.uptime || '-' }}</span>
|
||||
</div>
|
||||
<template v-if="container.status === 'running'">
|
||||
<div class="card-metrics">
|
||||
<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>
|
||||
<span class="value">{{ container.cpu_percent?.toFixed(0) || '-' }}%</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>
|
||||
</div>
|
||||
<div class="card-metric">
|
||||
<span class="label">↓RX</span>
|
||||
<span class="value net">{{ formatNetworkShort(container.network_rx) }}</span>
|
||||
</div>
|
||||
<div class="card-metric">
|
||||
<span class="label">↑TX</span>
|
||||
<span class="value net">{{ formatNetworkShort(container.network_tx) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="card-stopped">{{ container.status }}</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 컨테이너 없음 -->
|
||||
<div class="no-container" v-if="server.containers.length === 0">
|
||||
<span>컨테이너 없음</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface ContainerStatus {
|
||||
name: string
|
||||
status: string
|
||||
level: string
|
||||
cpu_percent: number | null
|
||||
memory_usage: number | null
|
||||
memory_limit: number | null
|
||||
uptime: string | null
|
||||
network_rx: number | null
|
||||
network_tx: number | null
|
||||
}
|
||||
|
||||
interface ServerStatus {
|
||||
target_id: number
|
||||
server_name: string
|
||||
level: string
|
||||
cpu_percent: number | null
|
||||
cpu_level: string
|
||||
memory_percent: number | null
|
||||
memory_level: string
|
||||
disk_percent: number | null
|
||||
disk_level: string
|
||||
last_collected: string | null
|
||||
containers: ContainerStatus[]
|
||||
container_summary: { total: number; normal: number; warning: number; critical: number; stopped: number }
|
||||
}
|
||||
|
||||
interface Summary {
|
||||
servers: { total: number; normal: number; warning: number; critical: number; danger: number; offline: number }
|
||||
containers: { total: number; normal: number; warning: number; critical: number; danger: number; stopped: number }
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
servers: ServerStatus[]
|
||||
summary: Summary
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'navigate', path: string): void
|
||||
}>()
|
||||
|
||||
const levelPriority: Record<string, number> = { stopped: 3, critical: 2, danger: 2, warning: 1, normal: 0 }
|
||||
|
||||
function sortContainers(containers: ContainerStatus[]) {
|
||||
return [...containers].sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
function goToServerStatus(targetId: number, containerName?: string) {
|
||||
let path = `/server/history?target=${targetId}`
|
||||
if (containerName) path += `&container=${containerName}`
|
||||
emit('navigate', path)
|
||||
}
|
||||
|
||||
function levelIcon(level: string): string {
|
||||
const icons: Record<string, string> = { normal: '🟢', warning: '🟡', critical: '🟠', danger: '🔴', offline: '⚫', stopped: '🔴' }
|
||||
return icons[level] || '⚪'
|
||||
}
|
||||
|
||||
function getContainerCpuLevel(c: ContainerStatus): string {
|
||||
if (c.cpu_percent === null) return 'normal'
|
||||
if (c.cpu_percent >= 95) return 'danger'
|
||||
if (c.cpu_percent >= 90) return 'critical'
|
||||
if (c.cpu_percent >= 80) return 'warning'
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
function getContainerMemLevel(c: ContainerStatus): string {
|
||||
const pct = getMemPercent(c)
|
||||
if (pct >= 95) return 'danger'
|
||||
if (pct >= 90) return 'critical'
|
||||
if (pct >= 80) return 'warning'
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
function getMemPercent(c: ContainerStatus): number {
|
||||
if (!c.memory_limit || !c.memory_usage) return 0
|
||||
return (c.memory_usage / c.memory_limit) * 100
|
||||
}
|
||||
|
||||
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`
|
||||
}
|
||||
|
||||
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`
|
||||
}
|
||||
|
||||
function formatTimeAgo(datetime: string | null): string {
|
||||
if (!datetime) return '-'
|
||||
const now = new Date()
|
||||
const then = new Date(datetime.replace(' ', 'T') + '+09:00')
|
||||
const diff = Math.floor((now.getTime() - then.getTime()) / 1000)
|
||||
if (diff < 60) return `${diff}초 전`
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}분 전`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}시간 전`
|
||||
return `${Math.floor(diff / 86400)}일 전`
|
||||
}
|
||||
</script>
|
||||
|
||||
<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); }
|
||||
.divider { color: var(--border-color); }
|
||||
.level-counts { margin-left: 8px; }
|
||||
.level-counts .lv { margin-right: 6px; }
|
||||
|
||||
/* 서버 그리드 - flex-wrap */
|
||||
.server-grid { display: flex; flex-wrap: wrap; gap: 12px; padding: 16px; 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-info { width: 150px; min-width: 150px; padding: 12px; 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; }
|
||||
|
||||
/* 서버 메트릭 */
|
||||
.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; }
|
||||
.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.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; }
|
||||
|
||||
/* 컨테이너 영역 */
|
||||
.container-area { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px; align-content: flex-start; min-width: 100px; }
|
||||
|
||||
/* 컨테이너 카드 */
|
||||
.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; }
|
||||
|
||||
.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-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; }
|
||||
.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-stopped { font-size: 12px; color: var(--text-muted); font-style: italic; text-align: center; padding: 6px 0; }
|
||||
|
||||
.no-container { font-size: 13px; color: var(--text-muted); padding: 12px; display: flex; align-items: center; justify-content: center; }
|
||||
</style>
|
||||
74
frontend/components/SidebarNav.vue
Normal file
74
frontend/components/SidebarNav.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-logo">
|
||||
<span class="logo-icon">📡</span>
|
||||
<span>OSOLIT Monitor</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<NuxtLink to="/" class="nav-item" :class="{ active: route.path === '/' }">
|
||||
<span class="icon">📊</span>
|
||||
<span>대시보드</span>
|
||||
</NuxtLink>
|
||||
|
||||
<div class="nav-group-title">네트워크</div>
|
||||
|
||||
<NuxtLink to="/network/pubnet" class="nav-item nav-sub-item" :class="{ active: route.path === '/network/pubnet' }">
|
||||
<span class="icon">🌐</span>
|
||||
<span>Public Network</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink to="/network/privnet" class="nav-item nav-sub-item" :class="{ active: route.path === '/network/privnet' }">
|
||||
<span class="icon">🔒</span>
|
||||
<span>Private Network</span>
|
||||
</NuxtLink>
|
||||
|
||||
<div class="nav-group-title">서버</div>
|
||||
|
||||
<NuxtLink to="/server/list" class="nav-item nav-sub-item" :class="{ active: route.path === '/server/list' }">
|
||||
<span class="icon">🖥️</span>
|
||||
<span>Server Targets</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink to="/server/history" class="nav-item nav-sub-item" :class="{ active: route.path === '/server/history' }">
|
||||
<span class="icon">📈</span>
|
||||
<span>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' }">
|
||||
<span class="icon">⚙️</span>
|
||||
<span>임계값 설정</span>
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
</script>
|
||||
112
frontend/components/ThemeToggle.vue
Normal file
112
frontend/components/ThemeToggle.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div
|
||||
class="theme-switch"
|
||||
:class="{ dark: theme === 'dark' }"
|
||||
@click="toggleTheme"
|
||||
:title="theme === 'dark' ? '라이트 모드로 전환' : '다크 모드로 전환'"
|
||||
>
|
||||
<div class="switch-track">
|
||||
<span class="switch-icon sun">☀️</span>
|
||||
<span class="switch-icon moon">🌙</span>
|
||||
</div>
|
||||
<div class="switch-thumb"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { theme, toggleTheme, initTheme } = useTheme()
|
||||
|
||||
onMounted(() => {
|
||||
initTheme()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.theme-switch {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 28px;
|
||||
background: #e0e0e0;
|
||||
border: 2px solid #ccc;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s ease, border-color 0.3s ease;
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.theme-switch.dark {
|
||||
background: #3a3a3a;
|
||||
border-color: #666;
|
||||
}
|
||||
|
||||
.switch-track {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.switch-icon {
|
||||
font-size: 14px;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.switch-icon.sun {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.switch-icon.moon {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.theme-switch.dark .switch-icon.sun {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.theme-switch.dark .switch-icon.moon {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.switch-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #1a1a1a;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), background 0.3s ease;
|
||||
}
|
||||
|
||||
.theme-switch.dark .switch-thumb {
|
||||
transform: translateX(28px);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 호버 효과 */
|
||||
.theme-switch:hover {
|
||||
border-color: #999;
|
||||
}
|
||||
|
||||
.theme-switch.dark:hover {
|
||||
border-color: #888;
|
||||
}
|
||||
|
||||
.theme-switch:hover .switch-thumb {
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.theme-switch:active .switch-thumb {
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.theme-switch.dark:active .switch-thumb {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
</style>
|
||||
35
frontend/composables/useTheme.ts
Normal file
35
frontend/composables/useTheme.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export type Theme = 'dark' | 'light'
|
||||
|
||||
const THEME_KEY = 'osolit-theme'
|
||||
|
||||
export function useTheme() {
|
||||
const theme = useState<Theme>('theme', () => 'light')
|
||||
|
||||
function setTheme(newTheme: Theme) {
|
||||
theme.value = newTheme
|
||||
if (import.meta.client) {
|
||||
document.documentElement.setAttribute('data-theme', newTheme)
|
||||
localStorage.setItem(THEME_KEY, newTheme)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const newTheme = theme.value === 'dark' ? 'light' : 'dark'
|
||||
setTheme(newTheme)
|
||||
}
|
||||
|
||||
function initTheme() {
|
||||
if (import.meta.client) {
|
||||
const saved = localStorage.getItem(THEME_KEY) as Theme | null
|
||||
const initial = saved || 'light'
|
||||
setTheme(initial)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
theme,
|
||||
setTheme,
|
||||
toggleTheme,
|
||||
initTheme
|
||||
}
|
||||
}
|
||||
246
frontend/index.vue
Normal file
246
frontend/index.vue
Normal file
@@ -0,0 +1,246 @@
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<SidebarNav />
|
||||
|
||||
<div class="main-content">
|
||||
<header class="main-header">
|
||||
<h1 class="page-title">📊 대시보드</h1>
|
||||
<div class="header-info">
|
||||
<span class="current-time">{{ currentTime }}</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main-body">
|
||||
<DashboardControl
|
||||
:interval="interval"
|
||||
:auto-refresh="autoRefresh"
|
||||
:last-fetch-time="lastFetchTime"
|
||||
:fetch-state="fetchState"
|
||||
@update:interval="updateInterval"
|
||||
@update:auto-refresh="updateAutoRefresh"
|
||||
@fetch-at="fetchAt"
|
||||
@refresh="refresh"
|
||||
/>
|
||||
|
||||
<div class="dashboard-layout">
|
||||
<!-- 서버 현황 (좌측 90%) -->
|
||||
<div class="server-section">
|
||||
<ServerPortlet
|
||||
v-if="serverDashboard"
|
||||
:servers="serverDashboard.servers"
|
||||
:summary="serverDashboard.summary"
|
||||
@navigate="navigateTo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 네트워크 상태 (우측 10%) -->
|
||||
<div class="network-section">
|
||||
<NetworkPortlet
|
||||
type="pubnet"
|
||||
title="Public"
|
||||
icon="🌐"
|
||||
:status="pubnetStatus"
|
||||
/>
|
||||
<NetworkPortlet
|
||||
type="privnet"
|
||||
title="Private"
|
||||
icon="🔒"
|
||||
:status="privnetStatus"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="connection-status" :class="{ connected: wsConnected }">
|
||||
{{ wsConnected ? '🟢 연결됨' : '🔴 연결 끊김' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const router = useRouter()
|
||||
|
||||
// 초기값 상수
|
||||
const DEFAULT_INTERVAL = 1
|
||||
const DEFAULT_AUTO_REFRESH = true
|
||||
const MIN_LOADING_TIME = 500
|
||||
const SUCCESS_DISPLAY_TIME = 800
|
||||
|
||||
// 상태
|
||||
const interval = ref(DEFAULT_INTERVAL)
|
||||
const autoRefresh = ref(DEFAULT_AUTO_REFRESH)
|
||||
const wsConnected = ref(false)
|
||||
const currentTime = ref('')
|
||||
const lastFetchTime = ref<Date | null>(null)
|
||||
const fetchState = ref<'idle' | 'loading' | 'success'>('idle')
|
||||
|
||||
// 로딩 타이밍 관리
|
||||
let loadingStartTime = 0
|
||||
|
||||
// 데이터
|
||||
const pubnetStatus = ref<any>(null)
|
||||
const privnetStatus = ref<any>(null)
|
||||
const serverDashboard = ref<any>(null)
|
||||
|
||||
// WebSocket
|
||||
let ws: WebSocket | null = null
|
||||
let timeInterval: ReturnType<typeof window.setInterval> | null = null
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
const h = String(date.getHours()).padStart(2, '0')
|
||||
const min = String(date.getMinutes()).padStart(2, '0')
|
||||
const s = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${y}-${m}-${d} ${h}:${min}:${s}`
|
||||
}
|
||||
|
||||
function updateCurrentTime() {
|
||||
currentTime.value = formatTime(new Date())
|
||||
}
|
||||
|
||||
function sendMessage(msg: object) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
console.log('[WS] Sending:', JSON.stringify(msg))
|
||||
ws.send(JSON.stringify(msg))
|
||||
}
|
||||
}
|
||||
|
||||
function handleDataReceived(data: any) {
|
||||
lastFetchTime.value = new Date()
|
||||
|
||||
if (data.pubnet) {
|
||||
pubnetStatus.value = data.pubnet.status
|
||||
}
|
||||
|
||||
if (data.privnet) {
|
||||
privnetStatus.value = data.privnet.status
|
||||
}
|
||||
}
|
||||
|
||||
function finishLoading() {
|
||||
fetchState.value = 'success'
|
||||
setTimeout(() => {
|
||||
fetchState.value = 'idle'
|
||||
}, SUCCESS_DISPLAY_TIME)
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const wsUrl = `${protocol}//${window.location.host}/_ws`
|
||||
|
||||
ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[WS] Connected')
|
||||
wsConnected.value = true
|
||||
|
||||
ws!.send(JSON.stringify({ type: 'set_interval', interval: DEFAULT_INTERVAL }))
|
||||
ws!.send(JSON.stringify({ type: 'set_auto_refresh', enabled: DEFAULT_AUTO_REFRESH }))
|
||||
ws!.send(JSON.stringify({ type: 'refresh' }))
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
|
||||
if (msg.type === 'status' || msg.type === 'historical') {
|
||||
handleDataReceived(msg.data)
|
||||
|
||||
if (fetchState.value === 'loading') {
|
||||
const elapsed = Date.now() - loadingStartTime
|
||||
const remaining = Math.max(0, MIN_LOADING_TIME - elapsed)
|
||||
|
||||
setTimeout(() => {
|
||||
finishLoading()
|
||||
}, remaining)
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.type === 'server') {
|
||||
serverDashboard.value = msg.data
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WS] Parse error:', err)
|
||||
fetchState.value = 'idle'
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('[WS] Disconnected')
|
||||
wsConnected.value = false
|
||||
fetchState.value = 'idle'
|
||||
|
||||
setTimeout(() => {
|
||||
if (autoRefresh.value) {
|
||||
connectWebSocket()
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('[WS] Error:', error)
|
||||
fetchState.value = 'idle'
|
||||
}
|
||||
}
|
||||
|
||||
function updateInterval(min: number) {
|
||||
interval.value = min
|
||||
sendMessage({ type: 'set_interval', interval: min })
|
||||
}
|
||||
|
||||
function updateAutoRefresh(enabled: boolean) {
|
||||
autoRefresh.value = enabled
|
||||
sendMessage({ type: 'set_auto_refresh', enabled })
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
sendMessage({ type: 'refresh' })
|
||||
}
|
||||
|
||||
function fetchAt(datetime: string) {
|
||||
fetchState.value = 'loading'
|
||||
loadingStartTime = Date.now()
|
||||
sendMessage({ type: 'fetch_at', datetime })
|
||||
}
|
||||
|
||||
function navigateTo(path: string) {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
connectWebSocket()
|
||||
updateCurrentTime()
|
||||
timeInterval = window.setInterval(updateCurrentTime, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (ws) {
|
||||
ws.close()
|
||||
}
|
||||
if (timeInterval) {
|
||||
window.clearInterval(timeInterval)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.app-layout { display: flex; height: 100vh; background: var(--bg-primary); }
|
||||
.main-content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.main-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; border-bottom: 1px solid var(--border-color); background: var(--bg-secondary); }
|
||||
.page-title { margin: 0; font-size: 20px; font-weight: 600; color: var(--text-primary); }
|
||||
.header-info { display: flex; align-items: center; gap: 16px; }
|
||||
.current-time { font-size: 14px; color: var(--text-muted); font-family: monospace; }
|
||||
.main-body { flex: 1; padding: 20px 24px; overflow-y: auto; }
|
||||
|
||||
.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; }
|
||||
|
||||
.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; }
|
||||
</style>
|
||||
1069
frontend/network/privnet.vue
Normal file
1069
frontend/network/privnet.vue
Normal file
File diff suppressed because it is too large
Load Diff
1074
frontend/network/pubnet.vue
Normal file
1074
frontend/network/pubnet.vue
Normal file
File diff suppressed because it is too large
Load Diff
791
frontend/server/history.vue
Normal file
791
frontend/server/history.vue
Normal file
@@ -0,0 +1,791 @@
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<SidebarNav />
|
||||
|
||||
<div class="main-content">
|
||||
<header class="main-header">
|
||||
<h1 class="page-title">📈 Server Status</h1>
|
||||
<div class="header-info">
|
||||
<span class="current-time">{{ currentTime }}</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main-body">
|
||||
<!-- 상단 고정 영역 -->
|
||||
<div class="fixed-top">
|
||||
<!-- 첫 번째 줄: 조회 기간 -->
|
||||
<section class="filter-row">
|
||||
<span class="filter-label">조회 기간</span>
|
||||
<div class="period-buttons">
|
||||
<button
|
||||
v-for="p in periods"
|
||||
:key="p.value"
|
||||
:class="['period-btn', { active: selectedPeriod === p.value }]"
|
||||
@click="changePeriod(p.value)"
|
||||
>
|
||||
{{ p.label }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 두 번째 줄: 서버 목록 -->
|
||||
<section class="server-row">
|
||||
<span class="filter-label">서버</span>
|
||||
<div class="server-buttons">
|
||||
<button
|
||||
v-for="target in targets"
|
||||
:key="target.target_id"
|
||||
:class="['server-btn', { active: selectedTargetId === target.target_id }]"
|
||||
@click="selectServer(target.target_id)"
|
||||
>
|
||||
<span class="server-status" :class="{ online: target.is_active }"></span>
|
||||
{{ target.server_name }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="server-nav">
|
||||
<button class="nav-btn" @click="prevServer">◀</button>
|
||||
<button class="nav-btn" @click="nextServer">▶</button>
|
||||
</div>
|
||||
<div class="auto-rotate">
|
||||
<label class="rotate-checkbox">
|
||||
<input type="checkbox" v-model="autoRotate" @change="onAutoRotateChange">
|
||||
<span>자동순환</span>
|
||||
</label>
|
||||
<div class="rotate-intervals">
|
||||
<button
|
||||
:class="['rotate-btn', { active: rotateInterval === 1/6 }]"
|
||||
:disabled="!autoRotate"
|
||||
@click="changeRotateInterval(1/6)"
|
||||
>
|
||||
10초
|
||||
</button>
|
||||
<button
|
||||
:class="['rotate-btn', { active: rotateInterval === 0.5 }]"
|
||||
:disabled="!autoRotate"
|
||||
@click="changeRotateInterval(0.5)"
|
||||
>
|
||||
30초
|
||||
</button>
|
||||
<button
|
||||
v-for="min in [1, 3, 5]"
|
||||
:key="min"
|
||||
:class="['rotate-btn', { active: rotateInterval === min }]"
|
||||
:disabled="!autoRotate"
|
||||
@click="changeRotateInterval(min)"
|
||||
>
|
||||
{{ min }}분
|
||||
</button>
|
||||
</div>
|
||||
<span class="rotate-remaining" v-if="autoRotate">{{ formatRemaining(rotateRemaining) }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 스냅샷 정보 -->
|
||||
<section class="snapshot-info" v-if="latestSnapshot">
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<label>호스트명</label>
|
||||
<span>{{ latestSnapshot.host_name || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>OS</label>
|
||||
<span>{{ latestSnapshot.os_name }} {{ latestSnapshot.os_version }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>IP</label>
|
||||
<span>{{ latestSnapshot.ip_address || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>CPU</label>
|
||||
<span>{{ latestSnapshot.cpu_name || '-' }} ({{ latestSnapshot.cpu_count || '-' }} cores)</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>Memory</label>
|
||||
<span>{{ formatBytes(latestSnapshot.memory_total) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>Swap</label>
|
||||
<span>{{ formatBytes(latestSnapshot.swap_total) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>Uptime</label>
|
||||
<span>{{ latestSnapshot.uptime_str || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>컨테이너</label>
|
||||
<span>{{ containerData.length }}개</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>수집시간</label>
|
||||
<span>{{ latestSnapshot.collected_at }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="disk-list" v-if="diskList.length > 0">
|
||||
<label>Disks</label>
|
||||
<div class="disk-items">
|
||||
<span v-for="disk in diskList" :key="disk.mount_point" class="disk-item">
|
||||
{{ disk.mount_point }} ({{ formatBytes(disk.disk_total) }}, {{ disk.disk_percent?.toFixed(1) }}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 하단 스크롤 영역 -->
|
||||
<div class="scroll-area">
|
||||
<!-- 시스템 차트 (4개) -->
|
||||
<section class="chart-row">
|
||||
<div class="chart-box">
|
||||
<div class="chart-header">
|
||||
<h4>CPU / 온도 / Load</h4>
|
||||
<span class="chart-avg" v-if="cpuAvg">{{ latestSnapshot?.cpu_name || '-' }} ({{ latestSnapshot?.cpu_count || '-' }} cores) | 평균: CPU <span class="val-cpu">{{ cpuAvg.cpu }}%</span> | 온도 <span class="val-temp">{{ cpuAvg.temp }}°C</span> | Load <span class="val-load">{{ cpuAvg.load }}%</span></span>
|
||||
</div>
|
||||
<div class="chart-container"><canvas ref="cpuChartRef"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<div class="chart-header">
|
||||
<h4>Memory / Swap</h4>
|
||||
<span class="chart-avg" v-if="memAvg">평균: Mem <span class="val-mem">{{ memAvg.mem }}% ({{ memAvg.used }}/{{ memAvg.total }} GB)</span> | Swap <span class="val-swap">{{ memAvg.swap }}% ({{ memAvg.swapUsed }}/{{ memAvg.swapTotal }} GB)</span></span>
|
||||
</div>
|
||||
<div class="chart-container"><canvas ref="memChartRef"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<div class="chart-header">
|
||||
<h4>Disk 사용률</h4>
|
||||
<span class="chart-avg" v-if="diskAvg">평균: <span class="val-disk">{{ diskAvg.percent }}% ({{ diskAvg.used }}/{{ diskAvg.total }} GB)</span></span>
|
||||
</div>
|
||||
<div class="chart-container"><canvas ref="diskChartRef"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<div class="chart-header">
|
||||
<h4>Network I/O</h4>
|
||||
<span class="chart-avg" v-if="networkAvg">평균: RX <span class="val-rx">{{ networkAvg.rx }}</span> | TX <span class="val-tx">{{ networkAvg.tx }}</span></span>
|
||||
</div>
|
||||
<div class="chart-container"><canvas ref="networkMainChartRef"></canvas></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 컨테이너 영역 -->
|
||||
<section class="container-section">
|
||||
<h3 class="section-title">🐳 컨테이너</h3>
|
||||
<div class="container-cards">
|
||||
<div v-for="container in containerData" :key="container.name" class="container-card" :class="container.status">
|
||||
<div class="container-header">
|
||||
<span class="container-name">{{ container.name }}</span>
|
||||
<span class="container-status" :class="container.status">{{ container.status }}</span>
|
||||
<span class="container-uptime">{{ container.uptime || '-' }}</span>
|
||||
</div>
|
||||
<div class="container-charts">
|
||||
<div class="container-chart-box">
|
||||
<div class="chart-header">
|
||||
<span class="chart-title">CPU</span>
|
||||
<span class="chart-avg">평균: <span class="val-cpu">{{ container.cpuAvg }}%</span></span>
|
||||
</div>
|
||||
<div class="container-chart"><canvas :ref="el => setContainerChartRef(container.name, 'cpu', el)"></canvas></div>
|
||||
</div>
|
||||
<div class="container-chart-box">
|
||||
<div class="chart-header">
|
||||
<span class="chart-title">Memory</span>
|
||||
<span class="chart-avg">평균: <span class="val-mem">{{ container.memAvg }}</span> / {{ container.memLimit }}</span>
|
||||
</div>
|
||||
<div class="container-chart"><canvas :ref="el => setContainerChartRef(container.name, 'mem', el)"></canvas></div>
|
||||
</div>
|
||||
<div class="container-chart-box">
|
||||
<div class="chart-header">
|
||||
<span class="chart-title">Network</span>
|
||||
<span class="chart-avg">RX <span class="val-rx">{{ container.rxAvg }}</span> | TX <span class="val-tx">{{ container.txAvg }}</span></span>
|
||||
</div>
|
||||
<div class="container-chart"><canvas :ref="el => setContainerChartRef(container.name, 'net', el)"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
Chart.register(...registerables)
|
||||
|
||||
interface ServerTarget {
|
||||
target_id: number
|
||||
server_name: string
|
||||
server_ip: string
|
||||
is_active: number
|
||||
}
|
||||
|
||||
const targets = ref<ServerTarget[]>([])
|
||||
const selectedTargetId = ref<number | null>(null)
|
||||
const selectedPeriod = ref('1h')
|
||||
const currentTime = ref('')
|
||||
const latestSnapshot = ref<any>(null)
|
||||
const diskList = ref<any[]>([])
|
||||
|
||||
// 자동 순환 (rotateInterval은 분 단위, 10초 = 1/6분)
|
||||
const autoRotate = ref(true)
|
||||
const rotateInterval = ref(1/6)
|
||||
const rotateRemaining = ref(10)
|
||||
let rotateTimer: ReturnType<typeof setInterval> | null = null
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const periods = [
|
||||
{ value: '1h', label: '1시간' },
|
||||
{ value: '2h', label: '2시간' },
|
||||
{ value: '3h', label: '3시간' },
|
||||
{ value: '4h', label: '4시간' },
|
||||
{ value: '5h', label: '5시간' },
|
||||
{ value: '6h', label: '6시간' },
|
||||
{ value: '12h', label: '12시간' },
|
||||
{ value: '18h', label: '18시간' },
|
||||
{ value: '24h', label: '24시간' },
|
||||
{ value: '7d', label: '7일' },
|
||||
{ value: '30d', label: '30일' }
|
||||
]
|
||||
|
||||
const cpuChartRef = ref<HTMLCanvasElement | null>(null)
|
||||
const memChartRef = ref<HTMLCanvasElement | null>(null)
|
||||
const diskChartRef = ref<HTMLCanvasElement | null>(null)
|
||||
const networkMainChartRef = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
let cpuChart: Chart | null = null
|
||||
let memChart: Chart | null = null
|
||||
let diskChart: Chart | null = null
|
||||
let networkMainChart: Chart | null = null
|
||||
|
||||
// 컨테이너 차트 관리
|
||||
interface ContainerInfo {
|
||||
name: string
|
||||
status: string
|
||||
uptime: string
|
||||
cpuAvg: string
|
||||
memAvg: string
|
||||
memLimit: string
|
||||
rxAvg: string
|
||||
txAvg: string
|
||||
}
|
||||
const containerData = ref<ContainerInfo[]>([])
|
||||
const containerChartRefs: Record<string, Record<string, HTMLCanvasElement | null>> = {}
|
||||
const containerCharts: Record<string, Record<string, Chart | null>> = {}
|
||||
|
||||
function setContainerChartRef(name: string, type: string, el: any) {
|
||||
if (!containerChartRefs[name]) containerChartRefs[name] = {}
|
||||
containerChartRefs[name][type] = el as HTMLCanvasElement | null
|
||||
}
|
||||
|
||||
// 평균값
|
||||
const cpuAvg = ref<{ cpu: string; temp: string; load: string } | null>(null)
|
||||
const memAvg = ref<{ mem: string; swap: string; used: string; total: string; swapUsed: string; swapTotal: string } | null>(null)
|
||||
const diskAvg = ref<{ percent: string; used: string; total: string } | null>(null)
|
||||
const networkAvg = ref<{ rx: string; tx: string } | null>(null)
|
||||
|
||||
const chartColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899', '#14b8a6', '#f97316', '#6366f1']
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleString('sv-SE', { timeZone: 'Asia/Seoul' }).replace('T', ' ')
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number | null): string {
|
||||
if (!seconds) return '-'
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const mins = Math.floor((seconds % 3600) / 60)
|
||||
if (days > 0) return `${days}일 ${hours}시간`
|
||||
if (hours > 0) return `${hours}시간 ${mins}분`
|
||||
return `${mins}분`
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number | null): string {
|
||||
if (!bytes) return '-'
|
||||
const gb = bytes / (1024 * 1024 * 1024)
|
||||
if (gb >= 1024) return `${(gb / 1024).toFixed(1)} TB`
|
||||
if (gb >= 1) return `${gb.toFixed(1)} GB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(0)} MB`
|
||||
}
|
||||
|
||||
function formatBytesPerSec(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB/s`
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB/s`
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB/s`
|
||||
return `${bytes.toFixed(0)} B/s`
|
||||
}
|
||||
|
||||
function createLineChart(canvas: HTMLCanvasElement, labels: string[], datasets: { label: string; data: number[]; borderColor: string }[], maxY: number | null = 100): Chart {
|
||||
return new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: { labels, datasets: datasets.map(ds => ({ ...ds, fill: false, tension: 0.3, pointRadius: 1, borderWidth: 2 })) },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'top', labels: { boxWidth: 12, font: { size: 11 } } } },
|
||||
scales: {
|
||||
x: { ticks: { maxTicksLimit: 8, font: { size: 10 } } },
|
||||
y: { beginAtZero: true, max: maxY || undefined }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createCpuTempLoadChart(canvas: HTMLCanvasElement, labels: string[], cpuData: number[], tempData: number[], loadData: number[]): Chart {
|
||||
return new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: 'CPU %', data: cpuData, borderColor: chartColors[0], fill: false, tension: 0.3, pointRadius: 1, borderWidth: 2, yAxisID: 'y' },
|
||||
{ label: 'Load %', data: loadData, borderColor: chartColors[4], fill: false, tension: 0.3, pointRadius: 1, borderWidth: 2, yAxisID: 'y' },
|
||||
{ label: '온도 °C', data: tempData, borderColor: chartColors[3], fill: false, tension: 0.3, pointRadius: 1, borderWidth: 2, yAxisID: 'y1' }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'top', labels: { boxWidth: 12, font: { size: 11 } } } },
|
||||
scales: {
|
||||
x: { ticks: { maxTicksLimit: 8, font: { size: 10 } } },
|
||||
y: { type: 'linear', position: 'left', beginAtZero: true, max: 100, title: { display: true, text: '%', font: { size: 10 } } },
|
||||
y1: { type: 'linear', position: 'right', beginAtZero: true, max: 100, grid: { drawOnChartArea: false }, title: { display: true, text: '°C', font: { size: 10 } } }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchTargets() {
|
||||
try {
|
||||
const res = await $fetch('/api/server/targets')
|
||||
targets.value = (res as ServerTarget[]) || []
|
||||
if (targets.value.length > 0 && !selectedTargetId.value) {
|
||||
selectedTargetId.value = targets.value[0].target_id
|
||||
}
|
||||
} catch (err) { console.error('Failed to fetch targets:', err) }
|
||||
}
|
||||
|
||||
async function fetchLatestSnapshot() {
|
||||
if (!selectedTargetId.value) return
|
||||
try {
|
||||
latestSnapshot.value = await $fetch('/api/server/history/latest', { query: { target_id: selectedTargetId.value } })
|
||||
} catch (err) { console.error('Failed to fetch latest snapshot:', err) }
|
||||
}
|
||||
|
||||
async function fetchDiskList() {
|
||||
if (!selectedTargetId.value) return
|
||||
try {
|
||||
const res = await $fetch('/api/server/history/disk-list', { query: { target_id: selectedTargetId.value } }) as any[]
|
||||
diskList.value = res || []
|
||||
} catch (err) { console.error('Failed to fetch disk list:', err) }
|
||||
}
|
||||
|
||||
async function fetchSnapshots() {
|
||||
if (!selectedTargetId.value || !cpuChartRef.value) return
|
||||
try {
|
||||
const res = await $fetch('/api/server/history/snapshots', { query: { target_id: selectedTargetId.value, period: selectedPeriod.value } }) as any
|
||||
const data = res.data || []
|
||||
const labels = data.map((d: any) => d.collected_at?.substring(11, 16) || '')
|
||||
|
||||
cpuChart?.destroy()
|
||||
memChart?.destroy()
|
||||
|
||||
// CPU + 온도 + Load 차트 (듀얼 Y축: 왼쪽 %, 오른쪽 °C)
|
||||
const cpuData = data.map((d: any) => d.cpu_percent || 0)
|
||||
const tempData = data.map((d: any) => d.cpu_temp || 0)
|
||||
const loadData = data.map((d: any) => d.load_percent || 0)
|
||||
cpuChart = createCpuTempLoadChart(cpuChartRef.value!, labels, cpuData, tempData, loadData)
|
||||
|
||||
// 평균 계산 (CPU, 온도, Load)
|
||||
const validCpu = cpuData.filter((v: number) => v > 0)
|
||||
const validTemp = tempData.filter((v: number) => v > 0)
|
||||
const validLoad = loadData.filter((v: number) => v > 0)
|
||||
cpuAvg.value = {
|
||||
cpu: validCpu.length ? (validCpu.reduce((a: number, b: number) => a + b, 0) / validCpu.length).toFixed(1) : '-',
|
||||
temp: validTemp.length ? (validTemp.reduce((a: number, b: number) => a + b, 0) / validTemp.length).toFixed(1) : '-',
|
||||
load: validLoad.length ? (validLoad.reduce((a: number, b: number) => a + b, 0) / validLoad.length).toFixed(1) : '-'
|
||||
}
|
||||
|
||||
// Memory/Swap 라인 차트
|
||||
const memData = data.map((d: any) => d.memory_percent || 0)
|
||||
const swapData = data.map((d: any) => d.swap_percent || 0)
|
||||
memChart = createLineChart(memChartRef.value!, labels, [
|
||||
{ label: 'Memory %', data: memData, borderColor: chartColors[1] },
|
||||
{ label: 'Swap %', data: swapData, borderColor: chartColors[2] }
|
||||
])
|
||||
|
||||
// 평균 계산 (Memory, Swap) + 사용량/전체용량
|
||||
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 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 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'
|
||||
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) : '-',
|
||||
used: avgMemUsedGB,
|
||||
total: memTotalGB,
|
||||
swapUsed: avgSwapUsedGB,
|
||||
swapTotal: swapTotalGB
|
||||
}
|
||||
|
||||
} catch (err) { console.error('Failed to fetch snapshots:', err) }
|
||||
}
|
||||
|
||||
async function fetchDisks() {
|
||||
if (!selectedTargetId.value || !diskChartRef.value) return
|
||||
try {
|
||||
const res = await $fetch('/api/server/history/disks', { query: { target_id: selectedTargetId.value, period: selectedPeriod.value } }) as any
|
||||
const data = (res.data || []).filter((d: any) => d.device_name && !d.device_name.includes('loop') && !d.mount_point?.includes('/snap') && d.fs_type !== 'tmpfs' && d.fs_type !== 'squashfs')
|
||||
const mountPoints = [...new Set(data.map((d: any) => d.mount_point))]
|
||||
const timeLabels = [...new Set(data.map((d: any) => d.collected_at?.substring(11, 16)))] as string[]
|
||||
const datasets = mountPoints.map((mp, idx) => ({
|
||||
label: mp as string,
|
||||
data: timeLabels.map(time => data.find((d: any) => d.mount_point === mp && d.collected_at?.substring(11, 16) === time)?.disk_percent || 0),
|
||||
borderColor: chartColors[idx % chartColors.length]
|
||||
}))
|
||||
diskChart?.destroy()
|
||||
diskChart = createLineChart(diskChartRef.value!, timeLabels, datasets)
|
||||
|
||||
// 평균 계산 (전체 디스크) + 사용량/전체용량
|
||||
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 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 = {
|
||||
percent: allPercents.length ? (allPercents.reduce((a: number, b: number) => a + b, 0) / allPercents.length).toFixed(1) : '-',
|
||||
used: avgUsedGB,
|
||||
total: avgTotalGB
|
||||
}
|
||||
} catch (err) { console.error('Failed to fetch disks:', err) }
|
||||
}
|
||||
|
||||
async function fetchContainers() {
|
||||
if (!selectedTargetId.value) return
|
||||
try {
|
||||
const res = await $fetch('/api/server/history/containers', { query: { target_id: selectedTargetId.value, period: selectedPeriod.value } }) as any
|
||||
const data = res.data || []
|
||||
const names = [...new Set(data.map((d: any) => d.container_name))] as string[]
|
||||
|
||||
// 기존 차트 정리
|
||||
Object.values(containerCharts).forEach(charts => {
|
||||
Object.values(charts).forEach(chart => chart?.destroy())
|
||||
})
|
||||
|
||||
// 컨테이너별 데이터 구성
|
||||
const containers: ContainerInfo[] = []
|
||||
|
||||
for (const name of names) {
|
||||
const containerRows = data.filter((d: any) => d.container_name === name)
|
||||
if (containerRows.length === 0) continue
|
||||
|
||||
const latest = containerRows[containerRows.length - 1]
|
||||
const timeLabels = containerRows.map((d: any) => d.collected_at?.substring(11, 16))
|
||||
|
||||
// CPU 평균
|
||||
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)
|
||||
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' : '-'
|
||||
|
||||
// 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 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
|
||||
|
||||
containers.push({
|
||||
name,
|
||||
status: latest.container_status || 'unknown',
|
||||
uptime: latest.uptime || '-',
|
||||
cpuAvg: cpuAvgVal,
|
||||
memAvg: formatBytes(memAvgVal * 1024 * 1024),
|
||||
memLimit,
|
||||
rxAvg: formatBytesPerSec(rxAvgVal * 1024),
|
||||
txAvg: formatBytesPerSec(txAvgVal * 1024)
|
||||
})
|
||||
}
|
||||
|
||||
containerData.value = containers
|
||||
|
||||
// 다음 틱에서 차트 생성 (DOM 업데이트 후)
|
||||
await nextTick()
|
||||
|
||||
for (const name of names) {
|
||||
const containerRows = data.filter((d: any) => d.container_name === name)
|
||||
const timeLabels = containerRows.map((d: any) => d.collected_at?.substring(11, 16))
|
||||
|
||||
const refs = containerChartRefs[name]
|
||||
if (!refs) continue
|
||||
|
||||
if (!containerCharts[name]) containerCharts[name] = {}
|
||||
|
||||
// CPU 차트 (0-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)
|
||||
}
|
||||
|
||||
// Memory 차트 (MB 단위) - 자동 스케일
|
||||
if (refs.mem) {
|
||||
const memData = containerRows.map((d: any) => (d.memory_usage || 0) / 1024 / 1024)
|
||||
containerCharts[name].mem = createLineChart(refs.mem, timeLabels, [{ label: 'Memory MB', data: memData, borderColor: '#22c55e' }], null)
|
||||
}
|
||||
|
||||
// Network 차트 (KB/s 단위) - 자동 스케일
|
||||
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)
|
||||
containerCharts[name].net = createLineChart(refs.net, timeLabels, [
|
||||
{ label: 'RX KB/s', data: rxData, borderColor: '#06b6d4' },
|
||||
{ label: 'TX KB/s', data: txData, borderColor: '#f59e0b' }
|
||||
], null)
|
||||
}
|
||||
}
|
||||
} catch (err) { console.error('Failed to fetch containers:', err) }
|
||||
}
|
||||
|
||||
async function fetchNetworkMain() {
|
||||
if (!selectedTargetId.value || !networkMainChartRef.value) return
|
||||
try {
|
||||
const res = await $fetch('/api/server/history/networks', { query: { target_id: selectedTargetId.value, period: selectedPeriod.value } }) as any
|
||||
const data = res.data || []
|
||||
const ifaces = [...new Set(data.map((d: any) => d.interface_name))]
|
||||
const timeLabels = [...new Set(data.map((d: any) => d.collected_at?.substring(11, 16)))] as string[]
|
||||
const datasets: any[] = []
|
||||
ifaces.forEach((iface, idx) => {
|
||||
datasets.push({ label: `${iface} RX`, data: timeLabels.map(t => data.find((d: any) => d.interface_name === iface && d.collected_at?.substring(11, 16) === t)?.speed_recv || 0), borderColor: chartColors[idx * 2 % chartColors.length] })
|
||||
datasets.push({ label: `${iface} TX`, data: timeLabels.map(t => data.find((d: any) => d.interface_name === iface && d.collected_at?.substring(11, 16) === t)?.speed_sent || 0), borderColor: chartColors[(idx * 2 + 1) % chartColors.length] })
|
||||
})
|
||||
networkMainChart?.destroy()
|
||||
networkMainChart = createLineChart(networkMainChartRef.value!, timeLabels, datasets, null)
|
||||
|
||||
// 평균 계산 (전체 RX/TX)
|
||||
const allRx = data.map((d: any) => d.speed_recv || 0).filter((v: number) => v > 0)
|
||||
const allTx = data.map((d: any) => d.speed_sent || 0).filter((v: number) => v > 0)
|
||||
const avgRx = allRx.length ? allRx.reduce((a: number, b: number) => a + b, 0) / allRx.length : 0
|
||||
const avgTx = allTx.length ? allTx.reduce((a: number, b: number) => a + b, 0) / allTx.length : 0
|
||||
networkAvg.value = {
|
||||
rx: formatBytesPerSec(avgRx),
|
||||
tx: formatBytesPerSec(avgTx)
|
||||
}
|
||||
} catch (err) { console.error('Failed to fetch network main:', err) }
|
||||
}
|
||||
|
||||
async function fetchAllData() {
|
||||
await Promise.all([fetchLatestSnapshot(), fetchSnapshots(), fetchDisks(), fetchDiskList(), fetchNetworkMain()])
|
||||
await fetchContainers()
|
||||
}
|
||||
|
||||
async function selectServer(targetId: number) {
|
||||
selectedTargetId.value = targetId
|
||||
// 자동순환 중이면 카운트다운 리셋
|
||||
if (autoRotate.value) {
|
||||
rotateRemaining.value = rotateInterval.value * 60
|
||||
}
|
||||
await fetchAllData()
|
||||
}
|
||||
|
||||
function prevServer() {
|
||||
if (targets.value.length === 0) return
|
||||
const idx = targets.value.findIndex(t => t.target_id === selectedTargetId.value)
|
||||
const newIdx = idx <= 0 ? targets.value.length - 1 : idx - 1
|
||||
selectServer(targets.value[newIdx].target_id)
|
||||
}
|
||||
|
||||
function nextServer() {
|
||||
if (targets.value.length === 0) return
|
||||
const idx = targets.value.findIndex(t => t.target_id === selectedTargetId.value)
|
||||
const newIdx = idx >= targets.value.length - 1 ? 0 : idx + 1
|
||||
selectServer(targets.value[newIdx].target_id)
|
||||
}
|
||||
|
||||
// 자동 순환 기능
|
||||
function startRotateTimer() {
|
||||
stopRotateTimer()
|
||||
if (!autoRotate.value) return
|
||||
|
||||
// 남은 시간 초기화
|
||||
rotateRemaining.value = rotateInterval.value * 60
|
||||
|
||||
// 1초마다 카운트다운
|
||||
countdownTimer = setInterval(() => {
|
||||
rotateRemaining.value--
|
||||
if (rotateRemaining.value <= 0) {
|
||||
nextServer()
|
||||
rotateRemaining.value = rotateInterval.value * 60
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function stopRotateTimer() {
|
||||
if (rotateTimer) {
|
||||
clearInterval(rotateTimer)
|
||||
rotateTimer = null
|
||||
}
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
rotateRemaining.value = 0
|
||||
}
|
||||
|
||||
function onAutoRotateChange() {
|
||||
if (autoRotate.value) {
|
||||
rotateInterval.value = 1/6
|
||||
startRotateTimer()
|
||||
} else {
|
||||
stopRotateTimer()
|
||||
}
|
||||
}
|
||||
|
||||
function changeRotateInterval(min: number) {
|
||||
rotateInterval.value = min
|
||||
startRotateTimer()
|
||||
}
|
||||
|
||||
function formatRemaining(seconds: number): string {
|
||||
const min = Math.floor(seconds / 60)
|
||||
const sec = seconds % 60
|
||||
return `${min}:${sec.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function changePeriod(period: string) {
|
||||
selectedPeriod.value = period
|
||||
fetchAllData()
|
||||
}
|
||||
|
||||
let timeInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
currentTime.value = formatTime(new Date())
|
||||
timeInterval = setInterval(() => { currentTime.value = formatTime(new Date()) }, 1000)
|
||||
await fetchTargets()
|
||||
if (selectedTargetId.value) await fetchAllData()
|
||||
// 자동 순환 기본 활성화
|
||||
if (autoRotate.value) startRotateTimer()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timeInterval) clearInterval(timeInterval)
|
||||
stopRotateTimer()
|
||||
cpuChart?.destroy()
|
||||
memChart?.destroy()
|
||||
diskChart?.destroy()
|
||||
networkMainChart?.destroy()
|
||||
// 컨테이너 차트 정리
|
||||
Object.values(containerCharts).forEach(charts => {
|
||||
Object.values(charts).forEach(chart => chart?.destroy())
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-layout { display: flex; height: 100vh; background: var(--bg-primary); }
|
||||
.main-content { flex: 1; display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
|
||||
.main-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; background: var(--bg-secondary); border-bottom: 1px solid var(--border-color); flex-shrink: 0; }
|
||||
.page-title { font-size: 20px; font-weight: 600; color: var(--text-primary); margin: 0; }
|
||||
.header-info { display: flex; align-items: center; gap: 16px; }
|
||||
.current-time { font-family: monospace; color: var(--text-muted); }
|
||||
.main-body { flex: 1; padding: 16px; display: flex; flex-direction: column; gap: 12px; overflow: hidden; }
|
||||
.fixed-top { flex-shrink: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||
.scroll-area { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; padding-bottom: 16px; }
|
||||
|
||||
/* 필터 행 */
|
||||
.filter-row, .server-row { display: flex; align-items: center; gap: 12px; background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 8px; padding: 10px 16px; }
|
||||
.filter-label { font-size: 13px; font-weight: 600; color: var(--text-muted); min-width: 60px; }
|
||||
.period-buttons { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.server-buttons { display: flex; gap: 6px; flex-wrap: wrap; flex: 1; }
|
||||
.period-btn, .server-btn { padding: 6px 14px; border: 1px solid var(--border-color); border-radius: 6px; background: var(--bg-primary); color: var(--text-muted); font-size: 13px; cursor: pointer; transition: all 0.2s; }
|
||||
.period-btn:hover, .server-btn:hover { border-color: var(--text-muted); }
|
||||
.period-btn.active, .server-btn.active { background: var(--btn-primary-bg); border-color: var(--btn-primary-bg); color: #fff; }
|
||||
.server-btn { display: flex; align-items: center; gap: 6px; }
|
||||
.server-status { width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted); }
|
||||
.server-status.online { background: #22c55e; }
|
||||
.server-nav { display: flex; gap: 6px; margin-left: auto; }
|
||||
.nav-btn { width: 36px; height: 32px; border: 1px solid var(--border-color); border-radius: 6px; background: var(--bg-primary); color: var(--text-primary); font-size: 14px; cursor: pointer; transition: all 0.2s; display: flex; align-items: center; justify-content: center; }
|
||||
.nav-btn:hover { background: var(--btn-primary-bg); border-color: var(--btn-primary-bg); color: #fff; }
|
||||
|
||||
/* 자동 순환 */
|
||||
.auto-rotate { display: flex; align-items: center; gap: 10px; margin-left: 12px; padding-left: 12px; border-left: 1px solid var(--border-color); }
|
||||
.rotate-checkbox { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-primary); cursor: pointer; white-space: nowrap; }
|
||||
.rotate-checkbox input { width: 16px; height: 16px; cursor: pointer; }
|
||||
.rotate-intervals { display: flex; gap: 4px; }
|
||||
.rotate-btn { padding: 4px 10px; border: 1px solid var(--border-color); border-radius: 4px; background: var(--bg-primary); color: var(--text-muted); font-size: 12px; cursor: pointer; transition: all 0.2s; }
|
||||
.rotate-btn:hover:not(:disabled) { border-color: var(--text-muted); }
|
||||
.rotate-btn.active { background: var(--btn-primary-bg); border-color: var(--btn-primary-bg); color: #fff; }
|
||||
.rotate-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.rotate-remaining { font-size: 13px; font-weight: 600; color: var(--btn-primary-bg); font-family: monospace; min-width: 40px; }
|
||||
|
||||
/* 스냅샷 정보 */
|
||||
.snapshot-info { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 8px; padding: 10px 16px; }
|
||||
.info-grid { display: flex; flex-wrap: wrap; gap: 12px 24px; }
|
||||
.info-item { display: flex; flex-direction: column; gap: 2px; }
|
||||
.info-item label { font-size: 10px; color: var(--text-muted); text-transform: uppercase; }
|
||||
.info-item span { font-size: 13px; color: var(--text-primary); font-weight: 500; }
|
||||
.disk-list { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-color); display: flex; align-items: flex-start; gap: 12px; }
|
||||
.disk-list label { font-size: 10px; color: var(--text-muted); text-transform: uppercase; min-width: 40px; }
|
||||
.disk-items { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.disk-item { font-size: 12px; color: var(--text-primary); background: var(--bg-primary); padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border-color); }
|
||||
|
||||
/* 차트 행 */
|
||||
.chart-row { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
||||
.chart-box { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 8px; padding: 12px; }
|
||||
.chart-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.chart-header h4 { margin: 0; font-size: 13px; font-weight: 600; color: var(--text-primary); }
|
||||
.chart-avg { font-size: 13px; color: #1e293b; font-weight: 500; background: #e2e8f0; padding: 4px 10px; border-radius: 4px; }
|
||||
.val-cpu { color: #2563eb; font-weight: 600; }
|
||||
.val-temp { color: #dc2626; font-weight: 600; }
|
||||
.val-mem { color: #16a34a; font-weight: 600; }
|
||||
.val-swap { color: #d97706; font-weight: 600; }
|
||||
.val-disk { color: #3b82f6; font-weight: 600; }
|
||||
.val-load { color: #8b5cf6; font-weight: 600; }
|
||||
.val-rx { color: #0891b2; font-weight: 600; }
|
||||
.val-tx { color: #ea580c; font-weight: 600; }
|
||||
.chart-container { height: 360px; position: relative; }
|
||||
|
||||
/* 컨테이너 섹션 */
|
||||
.container-section { background: var(--bg-tertiary, #f1f5f9); border-radius: 12px; padding: 16px; margin-top: 16px; }
|
||||
.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-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-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-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-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; }
|
||||
.container-chart { height: 180px; position: relative; }
|
||||
|
||||
@media (max-width: 1200px) { .container-charts { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media (max-width: 900px) { .chart-row { grid-template-columns: 1fr; } .container-charts { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
515
frontend/server/list.vue
Normal file
515
frontend/server/list.vue
Normal file
@@ -0,0 +1,515 @@
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<SidebarNav />
|
||||
|
||||
<div class="main-content">
|
||||
<header class="main-header">
|
||||
<h1 class="page-title">🖥️ Server Targets</h1>
|
||||
<div class="header-info">
|
||||
<span class="current-time">{{ currentTime }}</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main-body">
|
||||
<!-- 스케줄러 상태 -->
|
||||
<section class="section">
|
||||
<h2>스케줄러 상태</h2>
|
||||
<div class="scheduler-card">
|
||||
<div class="status-item">
|
||||
<span class="label">상태</span>
|
||||
<span :class="['value', schedulerStatus.is_running ? 'active' : 'inactive']">
|
||||
{{ schedulerStatus.is_running ? '실행 중' : '중지됨' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="label">활성 타이머</span>
|
||||
<span class="value">{{ schedulerStatus.active_timers }} / {{ schedulerStatus.total_targets }}</span>
|
||||
</div>
|
||||
<div class="scheduler-controls">
|
||||
<button class="btn btn-start" @click="startScheduler" :disabled="schedulerStatus.is_running">
|
||||
▶ 시작
|
||||
</button>
|
||||
<button class="btn btn-stop" @click="stopScheduler" :disabled="!schedulerStatus.is_running">
|
||||
⏹ 중지
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 서버 목록 관리 -->
|
||||
<section class="section">
|
||||
<h2>서버 목록 관리</h2>
|
||||
|
||||
<!-- 추가 폼 -->
|
||||
<div class="add-form">
|
||||
<input
|
||||
v-model="newTarget.server_name"
|
||||
type="text"
|
||||
placeholder="서버명"
|
||||
class="input-field"
|
||||
style="width: 120px;"
|
||||
/>
|
||||
<input
|
||||
v-model="newTarget.server_ip"
|
||||
type="text"
|
||||
placeholder="IP"
|
||||
class="input-field"
|
||||
style="width: 130px;"
|
||||
/>
|
||||
<input
|
||||
v-model="newTarget.glances_url"
|
||||
type="text"
|
||||
placeholder="Glances URL"
|
||||
class="input-field url-input"
|
||||
/>
|
||||
<input
|
||||
v-model.number="newTarget.collect_interval"
|
||||
type="number"
|
||||
placeholder="주기(초)"
|
||||
class="input-field"
|
||||
style="width: 80px;"
|
||||
min="10"
|
||||
/>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="newTarget.is_active" />
|
||||
활성
|
||||
</label>
|
||||
<button class="btn btn-add" @click="addTarget" :disabled="!canAdd">
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 목록 -->
|
||||
<div v-if="targets.length === 0" class="no-data">
|
||||
등록된 서버가 없습니다.
|
||||
</div>
|
||||
<div v-else>
|
||||
<p class="total-count">총 {{ targets.length }}개</p>
|
||||
<table class="target-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 50px;">ID</th>
|
||||
<th style="width: 100px;">서버명</th>
|
||||
<th style="width: 120px;">IP</th>
|
||||
<th>Glances URL</th>
|
||||
<th style="width: 70px;">주기(초)</th>
|
||||
<th style="width: 50px;">상태</th>
|
||||
<th style="width: 120px;">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="target in targets" :key="target.target_id">
|
||||
<td>{{ target.target_id }}</td>
|
||||
<td>
|
||||
<input v-if="editingId === target.target_id" v-model="editTarget.server_name" type="text" class="edit-input" />
|
||||
<span v-else>{{ target.server_name }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<input v-if="editingId === target.target_id" v-model="editTarget.server_ip" type="text" class="edit-input" />
|
||||
<span v-else>{{ target.server_ip }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<input v-if="editingId === target.target_id" v-model="editTarget.glances_url" type="text" class="edit-input" />
|
||||
<span v-else class="url-cell">{{ target.glances_url }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<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>
|
||||
<label v-if="editingId === target.target_id" class="checkbox-label">
|
||||
<input type="checkbox" v-model="editTarget.is_active" />
|
||||
</label>
|
||||
<span v-else>{{ target.is_active ? '✅' : '❌' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div v-if="editingId === target.target_id" class="action-buttons">
|
||||
<button class="btn btn-save" @click="saveEdit">저장</button>
|
||||
<button class="btn btn-cancel" @click="cancelEdit">취소</button>
|
||||
</div>
|
||||
<div v-else class="action-buttons">
|
||||
<button class="btn btn-edit" @click="startEdit(target)">수정</button>
|
||||
<button class="btn btn-delete" @click="deleteTarget(target.target_id)">삭제</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface ServerTarget {
|
||||
target_id: number
|
||||
server_name: string
|
||||
server_ip: string
|
||||
glances_url: string
|
||||
is_active: number
|
||||
collect_interval: number
|
||||
}
|
||||
|
||||
interface SchedulerStatus {
|
||||
is_running: boolean
|
||||
active_timers: number
|
||||
total_targets: number
|
||||
}
|
||||
|
||||
const targets = ref<ServerTarget[]>([])
|
||||
const currentTime = ref('')
|
||||
const editingId = ref<number | null>(null)
|
||||
const schedulerStatus = ref<SchedulerStatus>({
|
||||
is_running: false,
|
||||
active_timers: 0,
|
||||
total_targets: 0
|
||||
})
|
||||
|
||||
const newTarget = ref({
|
||||
server_name: '',
|
||||
server_ip: '',
|
||||
glances_url: '',
|
||||
is_active: true,
|
||||
collect_interval: 60
|
||||
})
|
||||
|
||||
const editTarget = ref({
|
||||
server_name: '',
|
||||
server_ip: '',
|
||||
glances_url: '',
|
||||
is_active: true,
|
||||
collect_interval: 60
|
||||
})
|
||||
|
||||
const canAdd = computed(() =>
|
||||
newTarget.value.server_name.trim() &&
|
||||
newTarget.value.server_ip.trim() &&
|
||||
newTarget.value.glances_url.trim()
|
||||
)
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
const M = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
const h = String(date.getHours()).padStart(2, '0')
|
||||
const m = String(date.getMinutes()).padStart(2, '0')
|
||||
const s = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${y}-${M}-${d} ${h}:${m}:${s}`
|
||||
}
|
||||
|
||||
async function fetchSchedulerStatus() {
|
||||
try {
|
||||
const res = await $fetch('/api/server/status')
|
||||
schedulerStatus.value = res as SchedulerStatus
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch scheduler status:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function startScheduler() {
|
||||
try {
|
||||
await $fetch('/api/server/scheduler/start', { method: 'POST' })
|
||||
await fetchSchedulerStatus()
|
||||
} catch (err) {
|
||||
console.error('Failed to start scheduler:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function stopScheduler() {
|
||||
try {
|
||||
await $fetch('/api/server/scheduler/stop', { method: 'POST' })
|
||||
await fetchSchedulerStatus()
|
||||
} catch (err) {
|
||||
console.error('Failed to stop scheduler:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTargets() {
|
||||
try {
|
||||
const res = await $fetch('/api/server/targets')
|
||||
targets.value = (res as ServerTarget[]) || []
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch targets:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function addTarget() {
|
||||
if (!canAdd.value) return
|
||||
try {
|
||||
await $fetch('/api/server/targets', {
|
||||
method: 'POST',
|
||||
body: newTarget.value
|
||||
})
|
||||
newTarget.value = { server_name: '', server_ip: '', glances_url: '', is_active: true, collect_interval: 60 }
|
||||
await fetchTargets()
|
||||
await fetchSchedulerStatus()
|
||||
} catch (err) {
|
||||
console.error('Failed to add target:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(target: ServerTarget) {
|
||||
editingId.value = target.target_id
|
||||
editTarget.value = {
|
||||
server_name: target.server_name,
|
||||
server_ip: target.server_ip,
|
||||
glances_url: target.glances_url,
|
||||
is_active: !!target.is_active,
|
||||
collect_interval: target.collect_interval || 60
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editingId.value) return
|
||||
try {
|
||||
await $fetch(`/api/server/targets/${editingId.value}`, {
|
||||
method: 'PUT',
|
||||
body: editTarget.value
|
||||
})
|
||||
editingId.value = null
|
||||
await fetchTargets()
|
||||
await fetchSchedulerStatus()
|
||||
} catch (err) {
|
||||
console.error('Failed to save target:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTarget(id: number) {
|
||||
if (!confirm('정말 삭제하시겠습니까?')) return
|
||||
try {
|
||||
await $fetch(`/api/server/targets/${id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
await fetchTargets()
|
||||
await fetchSchedulerStatus()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete target:', err)
|
||||
}
|
||||
}
|
||||
|
||||
let timeInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
currentTime.value = formatTime(new Date())
|
||||
timeInterval = setInterval(() => {
|
||||
currentTime.value = formatTime(new Date())
|
||||
}, 1000)
|
||||
|
||||
fetchTargets()
|
||||
fetchSchedulerStatus()
|
||||
|
||||
// 스케줄러 상태 주기적 갱신
|
||||
setInterval(fetchSchedulerStatus, 5000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timeInterval) clearInterval(timeInterval)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.current-time {
|
||||
font-family: monospace;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.main-body {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.scheduler-card {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-item .label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-item .value {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.status-item .value.active { color: #22c55e; }
|
||||
.status-item .value.inactive { color: #ef4444; }
|
||||
|
||||
.scheduler-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.add-form {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.input-field.url-input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.total-count {
|
||||
margin-bottom: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
td {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.url-cell {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.edit-input {
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-add { background: var(--btn-primary-bg); color: #fff; }
|
||||
.btn-add:hover:not(:disabled) { background: var(--btn-primary-hover); }
|
||||
.btn-start { background: #22c55e; color: #fff; }
|
||||
.btn-stop { background: #ef4444; color: #fff; }
|
||||
.btn-edit { background: #3b82f6; color: #fff; }
|
||||
.btn-save { background: #22c55e; color: #fff; }
|
||||
.btn-cancel { background: #6b7280; color: #fff; }
|
||||
.btn-delete { background: #ef4444; color: #fff; }
|
||||
</style>
|
||||
240
frontend/settings/thresholds.vue
Normal file
240
frontend/settings/thresholds.vue
Normal file
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<SidebarNav />
|
||||
|
||||
<div class="main-content">
|
||||
<header class="main-header">
|
||||
<h1 class="page-title">⚙️ 임계값 설정</h1>
|
||||
<div class="header-info">
|
||||
<span class="current-time">{{ currentTime }}</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main-body">
|
||||
<div class="settings-container">
|
||||
<!-- 서버 임계값 -->
|
||||
<section class="threshold-section">
|
||||
<h2 class="section-title">🖥️ 서버 임계값</h2>
|
||||
<p class="section-desc">서버의 CPU, Memory, Disk 사용률에 대한 경고 기준을 설정합니다.</p>
|
||||
|
||||
<div class="threshold-table">
|
||||
<div class="table-header">
|
||||
<div class="col-metric">지표</div>
|
||||
<div class="col-value">🟡 주의 (Warning)</div>
|
||||
<div class="col-value">🟠 경고 (Critical)</div>
|
||||
<div class="col-value">🔴 위험 (Danger)</div>
|
||||
</div>
|
||||
<div class="table-row" v-for="metric in serverMetrics" :key="metric.key">
|
||||
<div class="col-metric">
|
||||
<span class="metric-icon">{{ metric.icon }}</span>
|
||||
<span>{{ metric.label }}</span>
|
||||
</div>
|
||||
<div class="col-value">
|
||||
<input type="number" v-model.number="thresholds.server[metric.key].warning" min="0" max="100" />
|
||||
<span class="unit">%</span>
|
||||
</div>
|
||||
<div class="col-value">
|
||||
<input type="number" v-model.number="thresholds.server[metric.key].critical" min="0" max="100" />
|
||||
<span class="unit">%</span>
|
||||
</div>
|
||||
<div class="col-value">
|
||||
<input type="number" v-model.number="thresholds.server[metric.key].danger" min="0" max="100" />
|
||||
<span class="unit">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 컨테이너 임계값 -->
|
||||
<section class="threshold-section">
|
||||
<h2 class="section-title">🐳 컨테이너 임계값</h2>
|
||||
<p class="section-desc">컨테이너의 CPU, Memory 사용률에 대한 경고 기준을 설정합니다.</p>
|
||||
|
||||
<div class="threshold-table">
|
||||
<div class="table-header">
|
||||
<div class="col-metric">지표</div>
|
||||
<div class="col-value">🟡 주의 (Warning)</div>
|
||||
<div class="col-value">🟠 경고 (Critical)</div>
|
||||
<div class="col-value">🔴 위험 (Danger)</div>
|
||||
</div>
|
||||
<div class="table-row" v-for="metric in containerMetrics" :key="metric.key">
|
||||
<div class="col-metric">
|
||||
<span class="metric-icon">{{ metric.icon }}</span>
|
||||
<span>{{ metric.label }}</span>
|
||||
</div>
|
||||
<div class="col-value">
|
||||
<input type="number" v-model.number="thresholds.container[metric.key].warning" min="0" max="100" />
|
||||
<span class="unit">%</span>
|
||||
</div>
|
||||
<div class="col-value">
|
||||
<input type="number" v-model.number="thresholds.container[metric.key].critical" min="0" max="100" />
|
||||
<span class="unit">%</span>
|
||||
</div>
|
||||
<div class="col-value">
|
||||
<input type="number" v-model.number="thresholds.container[metric.key].danger" min="0" max="100" />
|
||||
<span class="unit">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 레벨 설명 -->
|
||||
<section class="level-guide">
|
||||
<h3>📋 레벨 설명</h3>
|
||||
<div class="level-items">
|
||||
<div class="level-item normal">🟢 정상: 모든 지표가 주의 기준 미만</div>
|
||||
<div class="level-item warning">🟡 주의: 하나 이상의 지표가 주의 기준 이상</div>
|
||||
<div class="level-item critical">🟠 경고: 하나 이상의 지표가 경고 기준 이상</div>
|
||||
<div class="level-item danger">🔴 위험: 하나 이상의 지표가 위험 기준 이상</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 저장 버튼 -->
|
||||
<div class="actions">
|
||||
<button class="btn-reset" @click="resetDefaults" :disabled="saving">기본값 복원</button>
|
||||
<button class="btn-save" @click="saveThresholds" :disabled="saving">
|
||||
{{ saving ? '저장 중...' : '저장' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="message" :class="['message', messageType]">{{ message }}</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const currentTime = ref('')
|
||||
const saving = ref(false)
|
||||
const message = ref('')
|
||||
const messageType = ref<'success' | 'error'>('success')
|
||||
|
||||
const serverMetrics = [
|
||||
{ key: 'cpu', label: 'CPU 사용률', icon: '💻' },
|
||||
{ key: 'memory', label: 'Memory 사용률', icon: '🧠' },
|
||||
{ key: 'disk', label: 'Disk 사용률', icon: '💾' }
|
||||
]
|
||||
|
||||
const containerMetrics = [
|
||||
{ key: 'cpu', label: 'CPU 사용률', icon: '💻' },
|
||||
{ key: 'memory', label: 'Memory 사용률', icon: '🧠' }
|
||||
]
|
||||
|
||||
const defaultThresholds = {
|
||||
server: {
|
||||
cpu: { warning: 70, critical: 85, danger: 95 },
|
||||
memory: { warning: 80, critical: 90, danger: 95 },
|
||||
disk: { warning: 80, critical: 90, danger: 95 }
|
||||
},
|
||||
container: {
|
||||
cpu: { warning: 80, critical: 90, danger: 95 },
|
||||
memory: { warning: 80, critical: 90, danger: 95 }
|
||||
}
|
||||
}
|
||||
|
||||
const thresholds = ref(JSON.parse(JSON.stringify(defaultThresholds)))
|
||||
|
||||
async function fetchThresholds() {
|
||||
try {
|
||||
const data = await $fetch('/api/settings/thresholds')
|
||||
if (data) {
|
||||
thresholds.value = data as typeof defaultThresholds
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch thresholds:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveThresholds() {
|
||||
saving.value = true
|
||||
message.value = ''
|
||||
|
||||
try {
|
||||
await $fetch('/api/settings/thresholds', {
|
||||
method: 'PUT',
|
||||
body: thresholds.value
|
||||
})
|
||||
message.value = '✅ 저장되었습니다.'
|
||||
messageType.value = 'success'
|
||||
} catch (err: any) {
|
||||
message.value = `❌ 저장 실패: ${err.data?.message || err.message}`
|
||||
messageType.value = 'error'
|
||||
} finally {
|
||||
saving.value = false
|
||||
setTimeout(() => { message.value = '' }, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
function resetDefaults() {
|
||||
thresholds.value = JSON.parse(JSON.stringify(defaultThresholds))
|
||||
message.value = '기본값으로 복원되었습니다. 저장 버튼을 눌러 적용하세요.'
|
||||
messageType.value = 'success'
|
||||
setTimeout(() => { message.value = '' }, 3000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const updateTime = () => {
|
||||
currentTime.value = new Date().toLocaleString('ko-KR', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||||
})
|
||||
}
|
||||
updateTime()
|
||||
setInterval(updateTime, 1000)
|
||||
fetchThresholds()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-layout { display: flex; height: 100vh; background: var(--bg-primary); }
|
||||
.main-content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.main-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; border-bottom: 1px solid var(--border-color); background: var(--bg-secondary); }
|
||||
.page-title { margin: 0; font-size: 20px; font-weight: 600; color: var(--text-primary); }
|
||||
.header-info { display: flex; align-items: center; gap: 16px; }
|
||||
.current-time { font-size: 14px; color: var(--text-muted); font-family: monospace; }
|
||||
.main-body { flex: 1; padding: 24px; overflow-y: auto; }
|
||||
|
||||
.settings-container { }
|
||||
|
||||
.threshold-section { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; padding: 20px; margin-bottom: 20px; }
|
||||
.section-title { margin: 0 0 8px 0; font-size: 18px; font-weight: 600; color: var(--text-primary); }
|
||||
.section-desc { margin: 0 0 16px 0; font-size: 13px; color: var(--text-muted); }
|
||||
|
||||
.threshold-table { border: 1px solid var(--border-color); border-radius: 8px; overflow: hidden; }
|
||||
.table-header { display: grid; grid-template-columns: 180px repeat(3, 1fr); background: var(--bg-tertiary, #f1f5f9); border-bottom: 1px solid var(--border-color); }
|
||||
.table-header > div { padding: 12px 16px; font-size: 13px; font-weight: 600; color: var(--text-primary); }
|
||||
.table-row { display: grid; grid-template-columns: 180px repeat(3, 1fr); border-bottom: 1px solid var(--border-color); }
|
||||
.table-row:last-child { border-bottom: none; }
|
||||
.table-row:hover { background: var(--bg-tertiary, #f8fafc); }
|
||||
|
||||
.col-metric { display: flex; align-items: center; gap: 8px; padding: 12px 16px; font-size: 14px; color: var(--text-primary); }
|
||||
.metric-icon { font-size: 16px; }
|
||||
.col-value { display: flex; align-items: center; gap: 6px; padding: 10px 16px; }
|
||||
.col-value input { width: 70px; padding: 8px 10px; border: 1px solid var(--border-color); border-radius: 6px; font-size: 14px; text-align: center; background: var(--bg-primary); color: var(--text-primary); }
|
||||
.col-value input:focus { outline: none; border-color: var(--btn-primary-bg); }
|
||||
.col-value .unit { font-size: 13px; color: var(--text-muted); }
|
||||
|
||||
.level-guide { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; padding: 20px; margin-bottom: 20px; }
|
||||
.level-guide h3 { margin: 0 0 12px 0; font-size: 15px; font-weight: 600; color: var(--text-primary); }
|
||||
.level-items { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.level-item { padding: 8px 14px; border-radius: 6px; font-size: 13px; }
|
||||
.level-item.normal { background: #f0fdf4; color: #166534; border: 1px solid #86efac; }
|
||||
.level-item.warning { background: #fefce8; color: #854d0e; border: 1px solid #fde047; }
|
||||
.level-item.critical { background: #fff7ed; color: #c2410c; border: 1px solid #fdba74; }
|
||||
.level-item.danger { background: #fef2f2; color: #991b1b; border: 1px solid #fca5a5; }
|
||||
|
||||
.actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 20px; }
|
||||
.btn-reset { padding: 10px 20px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-primary); color: var(--text-primary); font-size: 14px; cursor: pointer; transition: all 0.2s; }
|
||||
.btn-reset:hover:not(:disabled) { background: var(--bg-secondary); }
|
||||
.btn-reset:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-save { padding: 10px 24px; border: none; border-radius: 8px; background: var(--btn-primary-bg); color: #fff; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s; }
|
||||
.btn-save:hover:not(:disabled) { opacity: 0.9; }
|
||||
.btn-save:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.message { margin-top: 16px; padding: 12px 16px; border-radius: 8px; font-size: 14px; text-align: center; }
|
||||
.message.success { background: #f0fdf4; color: #166534; border: 1px solid #86efac; }
|
||||
.message.error { background: #fef2f2; color: #991b1b; border: 1px solid #fca5a5; }
|
||||
</style>
|
||||
71
nuxt.config.ts
Normal file
71
nuxt.config.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2024-12-25',
|
||||
devtools: { enabled: true },
|
||||
|
||||
// SSR 비활성화 (SPA 모드)
|
||||
ssr: false,
|
||||
|
||||
// 디렉토리 커스터마이징
|
||||
dir: {
|
||||
pages: 'frontend'
|
||||
},
|
||||
serverDir: 'backend',
|
||||
|
||||
// 컴포넌트 경로
|
||||
components: [
|
||||
{ path: '~/frontend/components', pathPrefix: false }
|
||||
],
|
||||
|
||||
// composables 경로
|
||||
imports: {
|
||||
dirs: ['frontend/composables']
|
||||
},
|
||||
|
||||
app: {
|
||||
head: {
|
||||
title: 'OSOLIT Monitor - Network Status',
|
||||
meta: [
|
||||
{ charset: 'utf-8' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||
{ name: 'description', content: 'Global Network Status Monitoring Dashboard' }
|
||||
],
|
||||
link: [
|
||||
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
||||
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
|
||||
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Rajdhani:wght@500;600&display=swap' }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
css: ['~/frontend/assets/css/main.css'],
|
||||
|
||||
// 서버 설정
|
||||
nitro: {
|
||||
preset: 'node-server',
|
||||
// WebSocket 실험적 기능 활성화
|
||||
experimental: {
|
||||
websocket: true
|
||||
},
|
||||
// 네이티브 모듈은 번들링하지 않고 외부 모듈로 처리
|
||||
externals: {
|
||||
inline: []
|
||||
},
|
||||
// rollup에서 external로 처리
|
||||
rollupConfig: {
|
||||
external: ['better-sqlite3']
|
||||
},
|
||||
// 플러그인 등록
|
||||
plugins: [
|
||||
'~/backend/plugins/pubnet-init.ts',
|
||||
'~/backend/plugins/privnet-init.ts'
|
||||
]
|
||||
},
|
||||
|
||||
// Vite 설정 (네이티브 모듈)
|
||||
vite: {
|
||||
optimizeDeps: {
|
||||
exclude: ['better-sqlite3']
|
||||
}
|
||||
}
|
||||
})
|
||||
10653
package-lock.json
generated
Normal file
10653
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "osolit-monitor",
|
||||
"version": "1.0.0",
|
||||
"description": "System Health Check & Monitoring Dashboard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "nuxt dev",
|
||||
"build": "nuxt build",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"nuxt": "^3.13.0",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/devtools": "latest",
|
||||
"@types/better-sqlite3": "^7.6.11",
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
}
|
||||
3
tsconfig.json
Normal file
3
tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
Reference in New Issue
Block a user