시스템 모니터

This commit is contained in:
2025-12-28 12:03:48 +09:00
parent dbae6649bc
commit a871ec8008
73 changed files with 21354 additions and 1 deletions

29
.gitignore vendored Normal file
View 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
View File

@@ -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** - 별도 서버 불필요

3
app.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<NuxtPage />
</template>

View 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()
}
})

View 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)
}
}

View 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()
}
})

View 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()
}
})

View 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()
}
})

View 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()
}
})

View 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
}
})

View 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 }
})

View 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()
}
})

View 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()
}
})

View 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()
}
})

View 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 }
})

View 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
}
})

View 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
})

View 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
}
})

View 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
}
})

View 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 }
})

View 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()
}
})

View 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()
}
})

View 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()
}
})

View 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 }
})

View 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
}
})

View 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
})

View 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
}
})

View 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)
})

View 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
}
})

View 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
})

View 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
}
})

View 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
})

View 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
}
})

View 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
}
})

View File

@@ -0,0 +1,6 @@
import { startServerScheduler } from '../../../utils/server-scheduler'
export default defineEventHandler(() => {
startServerScheduler()
return { success: true, message: 'Server scheduler started' }
})

View File

@@ -0,0 +1,6 @@
import { stopServerScheduler } from '../../../utils/server-scheduler'
export default defineEventHandler(() => {
stopServerScheduler()
return { success: true, message: 'Server scheduler stopped' }
})

View File

@@ -0,0 +1,5 @@
import { getServerSchedulerStatus } from '../../utils/server-scheduler'
export default defineEventHandler(() => {
return getServerSchedulerStatus()
})

View 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
}
})

View 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
}
})

View 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
})

View 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
}
})

View 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
})

View 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 }
})

View 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')
})

View 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')
})

View 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
View 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
View 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
}
}

View 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()

View 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()

View 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
View File

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

View 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>

View 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
View 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
View 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>

View 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;
}
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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

File diff suppressed because it is too large Load Diff

1074
frontend/network/pubnet.vue Normal file

File diff suppressed because it is too large Load Diff

791
frontend/server/history.vue Normal file
View 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
View 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>

View 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
View 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

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View 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
View File

@@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}