시스템 모니터
This commit is contained in:
178
backend/api/anomaly/baseline.get.ts
Normal file
178
backend/api/anomaly/baseline.get.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { getDb } from '../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const db = getDb()
|
||||
const DEVIATION_THRESHOLD = 2.0
|
||||
|
||||
const servers = db.prepare(`
|
||||
SELECT target_id, server_name
|
||||
FROM server_targets
|
||||
WHERE is_active = 1
|
||||
ORDER BY server_name
|
||||
`).all() as any[]
|
||||
|
||||
const now = new Date()
|
||||
const currentHour = now.getHours()
|
||||
const currentDayOfWeek = now.getDay()
|
||||
const isWeekend = currentDayOfWeek === 0 || currentDayOfWeek === 6
|
||||
const dayType = isWeekend ? 'weekend' : 'weekday'
|
||||
|
||||
const anomalies: any[] = []
|
||||
const serverResults: any[] = []
|
||||
|
||||
// 로그 저장용
|
||||
const insertLog = db.prepare(`
|
||||
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
|
||||
VALUES (?, ?, 'baseline', ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
const recentLogExists = db.prepare(`
|
||||
SELECT 1 FROM anomaly_logs
|
||||
WHERE target_id = ? AND detect_type = 'baseline' AND metric = ?
|
||||
AND detected_at > datetime('now', '-1 minute', 'localtime')
|
||||
LIMIT 1
|
||||
`)
|
||||
|
||||
for (const server of servers) {
|
||||
const historicalData = db.prepare(`
|
||||
SELECT cpu_percent, memory_percent, collected_at
|
||||
FROM server_snapshots
|
||||
WHERE target_id = ?
|
||||
AND collected_at >= datetime('now', '-14 days', 'localtime')
|
||||
AND strftime('%H', collected_at) = ?
|
||||
AND (
|
||||
(? = 'weekend' AND strftime('%w', collected_at) IN ('0', '6'))
|
||||
OR
|
||||
(? = 'weekday' AND strftime('%w', collected_at) NOT IN ('0', '6'))
|
||||
)
|
||||
ORDER BY collected_at DESC
|
||||
`).all(server.target_id, currentHour.toString().padStart(2, '0'), dayType, dayType) as any[]
|
||||
|
||||
const current = db.prepare(`
|
||||
SELECT cpu_percent, memory_percent
|
||||
FROM server_snapshots
|
||||
WHERE target_id = ?
|
||||
ORDER BY collected_at DESC
|
||||
LIMIT 1
|
||||
`).get(server.target_id) as any
|
||||
|
||||
if (!current || historicalData.length < 5) {
|
||||
serverResults.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
current_hour: currentHour,
|
||||
day_type: dayType,
|
||||
cpu_current: current?.cpu_percent ?? null,
|
||||
mem_current: current?.memory_percent ?? null,
|
||||
cpu_baseline: null,
|
||||
mem_baseline: null,
|
||||
cpu_deviation: null,
|
||||
mem_deviation: null,
|
||||
sample_count: historicalData.length,
|
||||
status: 'insufficient'
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const currCpu = current.cpu_percent ?? 0
|
||||
const currMem = current.memory_percent ?? 0
|
||||
|
||||
const cpuValues = historicalData.map(s => s.cpu_percent ?? 0)
|
||||
const memValues = historicalData.map(s => s.memory_percent ?? 0)
|
||||
|
||||
const cpuAvg = cpuValues.reduce((a, b) => a + b, 0) / cpuValues.length
|
||||
const memAvg = memValues.reduce((a, b) => a + b, 0) / memValues.length
|
||||
|
||||
const cpuVariance = cpuValues.reduce((sum, val) => sum + Math.pow(val - cpuAvg, 2), 0) / cpuValues.length
|
||||
const memVariance = memValues.reduce((sum, val) => sum + Math.pow(val - memAvg, 2), 0) / memValues.length
|
||||
const cpuStd = Math.sqrt(cpuVariance)
|
||||
const memStd = Math.sqrt(memVariance)
|
||||
|
||||
const cpuDeviation = cpuStd > 0.1 ? (currCpu - cpuAvg) / cpuStd : 0
|
||||
const memDeviation = memStd > 0.1 ? (currMem - memAvg) / memStd : 0
|
||||
|
||||
let status = 'normal'
|
||||
const maxDev = Math.max(Math.abs(cpuDeviation), Math.abs(memDeviation))
|
||||
if (maxDev >= 3.0) status = 'danger'
|
||||
else if (maxDev >= DEVIATION_THRESHOLD) status = 'warning'
|
||||
|
||||
serverResults.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
current_hour: currentHour,
|
||||
day_type: dayType,
|
||||
cpu_current: currCpu,
|
||||
mem_current: currMem,
|
||||
cpu_baseline: { avg: cpuAvg, std: cpuStd },
|
||||
mem_baseline: { avg: memAvg, std: memStd },
|
||||
cpu_deviation: cpuDeviation,
|
||||
mem_deviation: memDeviation,
|
||||
sample_count: historicalData.length,
|
||||
status
|
||||
})
|
||||
|
||||
// CPU 이상감지 + 로그 저장
|
||||
if (Math.abs(cpuDeviation) >= DEVIATION_THRESHOLD) {
|
||||
const level = Math.abs(cpuDeviation) >= 3.0 ? 'danger' : 'warning'
|
||||
const direction = cpuDeviation >= 0 ? '높음' : '낮음'
|
||||
const dayLabel = isWeekend ? '주말' : '평일'
|
||||
const message = `CPU ${dayLabel} ${currentHour}시 베이스라인 대비 ${Math.abs(cpuDeviation).toFixed(1)}σ ${direction} (기준: ${cpuAvg.toFixed(1)}%, 현재: ${currCpu.toFixed(1)}%)`
|
||||
|
||||
anomalies.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
metric: 'CPU',
|
||||
current: currCpu,
|
||||
baseline_avg: cpuAvg,
|
||||
deviation: cpuDeviation,
|
||||
direction: cpuDeviation >= 0 ? 'up' : 'down',
|
||||
level,
|
||||
hour: currentHour,
|
||||
day_type: dayType
|
||||
})
|
||||
|
||||
if (!recentLogExists.get(server.target_id, 'CPU')) {
|
||||
insertLog.run(server.target_id, server.server_name, 'CPU', level, currCpu, cpuDeviation, message)
|
||||
}
|
||||
}
|
||||
|
||||
// Memory 이상감지 + 로그 저장
|
||||
if (Math.abs(memDeviation) >= DEVIATION_THRESHOLD) {
|
||||
const level = Math.abs(memDeviation) >= 3.0 ? 'danger' : 'warning'
|
||||
const direction = memDeviation >= 0 ? '높음' : '낮음'
|
||||
const dayLabel = isWeekend ? '주말' : '평일'
|
||||
const message = `Memory ${dayLabel} ${currentHour}시 베이스라인 대비 ${Math.abs(memDeviation).toFixed(1)}σ ${direction} (기준: ${memAvg.toFixed(1)}%, 현재: ${currMem.toFixed(1)}%)`
|
||||
|
||||
anomalies.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
metric: 'Memory',
|
||||
current: currMem,
|
||||
baseline_avg: memAvg,
|
||||
deviation: memDeviation,
|
||||
direction: memDeviation >= 0 ? 'up' : 'down',
|
||||
level,
|
||||
hour: currentHour,
|
||||
day_type: dayType
|
||||
})
|
||||
|
||||
if (!recentLogExists.get(server.target_id, 'Memory')) {
|
||||
insertLog.run(server.target_id, server.server_name, 'Memory', level, currMem, memDeviation, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anomalies.sort((a, b) => Math.abs(b.deviation) - Math.abs(a.deviation))
|
||||
|
||||
return {
|
||||
anomalies,
|
||||
servers: serverResults,
|
||||
context: {
|
||||
current_hour: currentHour,
|
||||
day_type: dayType,
|
||||
day_type_label: isWeekend ? '주말' : '평일',
|
||||
threshold: DEVIATION_THRESHOLD
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
80
backend/api/anomaly/chart.get.ts
Normal file
80
backend/api/anomaly/chart.get.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { getDb } from '../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const db = getDb()
|
||||
const query = getQuery(event)
|
||||
|
||||
const type = query.type as string || 'short-term'
|
||||
const period = query.period as string || '24h'
|
||||
|
||||
// 기간/간격 계산
|
||||
let intervalClause = ''
|
||||
let groupFormat = ''
|
||||
|
||||
if (period === '1h') {
|
||||
intervalClause = `'-1 hours'`
|
||||
groupFormat = '%Y-%m-%d %H:%M' // 분 단위
|
||||
} else if (period === '6h') {
|
||||
intervalClause = `'-6 hours'`
|
||||
groupFormat = '%Y-%m-%d %H:00' // 시간 단위
|
||||
} else if (period === '12h') {
|
||||
intervalClause = `'-12 hours'`
|
||||
groupFormat = '%Y-%m-%d %H:00'
|
||||
} else if (period === '24h') {
|
||||
intervalClause = `'-24 hours'`
|
||||
groupFormat = '%Y-%m-%d %H:00'
|
||||
} else if (period === '7d') {
|
||||
intervalClause = `'-7 days'`
|
||||
groupFormat = '%Y-%m-%d' // 일 단위
|
||||
} else if (period === '30d') {
|
||||
intervalClause = `'-30 days'`
|
||||
groupFormat = '%Y-%m-%d'
|
||||
} else {
|
||||
intervalClause = `'-24 hours'`
|
||||
groupFormat = '%Y-%m-%d %H:00'
|
||||
}
|
||||
|
||||
// 시간대별 집계
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
strftime('${groupFormat}', detected_at) as time_slot,
|
||||
SUM(CASE WHEN level = 'warning' THEN 1 ELSE 0 END) as warning,
|
||||
SUM(CASE WHEN level = 'danger' THEN 1 ELSE 0 END) as danger
|
||||
FROM anomaly_logs
|
||||
WHERE detect_type = ?
|
||||
AND detected_at >= datetime('now', ${intervalClause}, 'localtime')
|
||||
GROUP BY time_slot
|
||||
ORDER BY time_slot ASC
|
||||
`).all(type) as any[]
|
||||
|
||||
// 시간 포맷 변환
|
||||
const data = rows.map(r => ({
|
||||
time: formatTimeLabel(r.time_slot, period),
|
||||
warning: r.warning,
|
||||
danger: r.danger
|
||||
}))
|
||||
|
||||
return {
|
||||
data,
|
||||
type,
|
||||
period,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
|
||||
function formatTimeLabel(timeSlot: string, period: string): string {
|
||||
if (!timeSlot) return ''
|
||||
|
||||
if (period === '7d' || period === '30d') {
|
||||
// 일 단위: MM/DD
|
||||
const parts = timeSlot.split('-')
|
||||
return `${parts[1]}/${parts[2]}`
|
||||
} else {
|
||||
// 시간 단위: HH:MM
|
||||
const parts = timeSlot.split(' ')
|
||||
if (parts.length === 2) {
|
||||
return parts[1].substring(0, 5)
|
||||
}
|
||||
return timeSlot.substring(11, 16)
|
||||
}
|
||||
}
|
||||
38
backend/api/anomaly/logs.get.ts
Normal file
38
backend/api/anomaly/logs.get.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { getDb } from '../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const db = getDb()
|
||||
const query = getQuery(event)
|
||||
|
||||
const type = query.type as string || 'short-term'
|
||||
const period = query.period as string || '24h'
|
||||
|
||||
// 기간 계산
|
||||
let intervalClause = ''
|
||||
if (period.endsWith('h')) {
|
||||
const hours = parseInt(period)
|
||||
intervalClause = `'-${hours} hours'`
|
||||
} else if (period.endsWith('d')) {
|
||||
const days = parseInt(period)
|
||||
intervalClause = `'-${days} days'`
|
||||
} else {
|
||||
intervalClause = `'-24 hours'`
|
||||
}
|
||||
|
||||
const logs = db.prepare(`
|
||||
SELECT id, target_id, server_name, detect_type, metric, level,
|
||||
current_value, threshold_value, message, detected_at
|
||||
FROM anomaly_logs
|
||||
WHERE detect_type = ?
|
||||
AND detected_at >= datetime('now', ${intervalClause}, 'localtime')
|
||||
ORDER BY detected_at DESC
|
||||
LIMIT 100
|
||||
`).all(type) as any[]
|
||||
|
||||
return {
|
||||
logs,
|
||||
type,
|
||||
period,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
141
backend/api/anomaly/short-term.get.ts
Normal file
141
backend/api/anomaly/short-term.get.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { getDb } from '../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const db = getDb()
|
||||
const THRESHOLD = 30 // 30% 이상 변화 시 이상 감지
|
||||
|
||||
// 활성 서버 목록
|
||||
const servers = db.prepare(`
|
||||
SELECT target_id, server_name
|
||||
FROM server_targets
|
||||
WHERE is_active = 1
|
||||
ORDER BY server_name
|
||||
`).all() as any[]
|
||||
|
||||
const anomalies: any[] = []
|
||||
const serverResults: any[] = []
|
||||
|
||||
// 로그 저장용 prepared statement
|
||||
const insertLog = db.prepare(`
|
||||
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
|
||||
VALUES (?, ?, 'short-term', ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
// 최근 1분 내 동일 로그 존재 여부 확인 (중복 방지)
|
||||
const recentLogExists = db.prepare(`
|
||||
SELECT 1 FROM anomaly_logs
|
||||
WHERE target_id = ? AND detect_type = 'short-term' AND metric = ?
|
||||
AND detected_at > datetime('now', '-1 minute', 'localtime')
|
||||
LIMIT 1
|
||||
`)
|
||||
|
||||
for (const server of servers) {
|
||||
const snapshots = db.prepare(`
|
||||
SELECT cpu_percent, memory_percent, collected_at
|
||||
FROM server_snapshots
|
||||
WHERE target_id = ?
|
||||
ORDER BY collected_at DESC
|
||||
LIMIT 20
|
||||
`).all(server.target_id) as any[]
|
||||
|
||||
if (snapshots.length < 4) {
|
||||
serverResults.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
cpu_change: null,
|
||||
mem_change: null,
|
||||
status: 'normal'
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const half = Math.floor(snapshots.length / 2)
|
||||
const currSnapshots = snapshots.slice(0, half)
|
||||
const prevSnapshots = snapshots.slice(half)
|
||||
|
||||
const currCpuAvg = currSnapshots.reduce((sum, s) => sum + (s.cpu_percent || 0), 0) / currSnapshots.length
|
||||
const prevCpuAvg = prevSnapshots.reduce((sum, s) => sum + (s.cpu_percent || 0), 0) / prevSnapshots.length
|
||||
const currMemAvg = currSnapshots.reduce((sum, s) => sum + (s.memory_percent || 0), 0) / currSnapshots.length
|
||||
const prevMemAvg = prevSnapshots.reduce((sum, s) => sum + (s.memory_percent || 0), 0) / prevSnapshots.length
|
||||
|
||||
let cpuChange: number | null = null
|
||||
let memChange: number | null = null
|
||||
|
||||
if (prevCpuAvg > 1) {
|
||||
cpuChange = ((currCpuAvg - prevCpuAvg) / prevCpuAvg) * 100
|
||||
} else {
|
||||
cpuChange = currCpuAvg - prevCpuAvg
|
||||
}
|
||||
|
||||
if (prevMemAvg > 1) {
|
||||
memChange = ((currMemAvg - prevMemAvg) / prevMemAvg) * 100
|
||||
} else {
|
||||
memChange = currMemAvg - prevMemAvg
|
||||
}
|
||||
|
||||
let status = 'normal'
|
||||
const maxChange = Math.max(Math.abs(cpuChange || 0), Math.abs(memChange || 0))
|
||||
if (maxChange >= 100) status = 'danger'
|
||||
else if (maxChange >= THRESHOLD) status = 'warning'
|
||||
|
||||
serverResults.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
cpu_change: cpuChange,
|
||||
mem_change: memChange,
|
||||
status
|
||||
})
|
||||
|
||||
// CPU 이상 감지 + 로그 저장
|
||||
if (cpuChange !== null && Math.abs(cpuChange) >= THRESHOLD) {
|
||||
const level = Math.abs(cpuChange) >= 100 ? 'danger' : 'warning'
|
||||
const direction = cpuChange >= 0 ? '증가' : '감소'
|
||||
const message = `CPU ${direction} 감지 (${prevCpuAvg.toFixed(1)}% → ${currCpuAvg.toFixed(1)}%)`
|
||||
|
||||
anomalies.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
metric: 'CPU',
|
||||
prev_avg: prevCpuAvg,
|
||||
curr_avg: currCpuAvg,
|
||||
change_rate: cpuChange,
|
||||
direction: cpuChange >= 0 ? 'up' : 'down'
|
||||
})
|
||||
|
||||
// 중복 아니면 로그 저장
|
||||
if (!recentLogExists.get(server.target_id, 'CPU')) {
|
||||
insertLog.run(server.target_id, server.server_name, 'CPU', level, currCpuAvg, cpuChange, message)
|
||||
}
|
||||
}
|
||||
|
||||
// Memory 이상 감지 + 로그 저장
|
||||
if (memChange !== null && Math.abs(memChange) >= THRESHOLD) {
|
||||
const level = Math.abs(memChange) >= 100 ? 'danger' : 'warning'
|
||||
const direction = memChange >= 0 ? '증가' : '감소'
|
||||
const message = `Memory ${direction} 감지 (${prevMemAvg.toFixed(1)}% → ${currMemAvg.toFixed(1)}%)`
|
||||
|
||||
anomalies.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
metric: 'Memory',
|
||||
prev_avg: prevMemAvg,
|
||||
curr_avg: currMemAvg,
|
||||
change_rate: memChange,
|
||||
direction: memChange >= 0 ? 'up' : 'down'
|
||||
})
|
||||
|
||||
if (!recentLogExists.get(server.target_id, 'Memory')) {
|
||||
insertLog.run(server.target_id, server.server_name, 'Memory', level, currMemAvg, memChange, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anomalies.sort((a, b) => Math.abs(b.change_rate) - Math.abs(a.change_rate))
|
||||
|
||||
return {
|
||||
anomalies,
|
||||
servers: serverResults,
|
||||
threshold: THRESHOLD,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
192
backend/api/anomaly/trend.get.ts
Normal file
192
backend/api/anomaly/trend.get.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { getDb } from '../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const db = getDb()
|
||||
const SLOPE_THRESHOLD = 0.5 // 분당 0.5% 이상 증가/감소 시 이상
|
||||
const MIN_SAMPLES = 10 // 최소 10개 샘플 필요
|
||||
const WINDOW_MINUTES = 30 // 30분 윈도우
|
||||
|
||||
const servers = db.prepare(`
|
||||
SELECT target_id, server_name
|
||||
FROM server_targets
|
||||
WHERE is_active = 1
|
||||
ORDER BY server_name
|
||||
`).all() as any[]
|
||||
|
||||
const anomalies: any[] = []
|
||||
const serverResults: any[] = []
|
||||
|
||||
// 로그 저장용
|
||||
const insertLog = db.prepare(`
|
||||
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
|
||||
VALUES (?, ?, 'trend', ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
const recentLogExists = db.prepare(`
|
||||
SELECT 1 FROM anomaly_logs
|
||||
WHERE target_id = ? AND detect_type = 'trend' AND metric = ?
|
||||
AND detected_at > datetime('now', '-1 minute', 'localtime')
|
||||
LIMIT 1
|
||||
`)
|
||||
|
||||
for (const server of servers) {
|
||||
// 최근 30분 데이터 조회
|
||||
const snapshots = db.prepare(`
|
||||
SELECT cpu_percent, memory_percent, collected_at,
|
||||
(julianday('now', 'localtime') - julianday(collected_at)) * 24 * 60 as minutes_ago
|
||||
FROM server_snapshots
|
||||
WHERE target_id = ? AND is_online = 1
|
||||
AND collected_at >= datetime('now', '-${WINDOW_MINUTES} minutes', 'localtime')
|
||||
ORDER BY collected_at ASC
|
||||
`).all(server.target_id) as any[]
|
||||
|
||||
if (snapshots.length < MIN_SAMPLES) {
|
||||
serverResults.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
cpu_current: snapshots.length > 0 ? snapshots[snapshots.length - 1].cpu_percent : null,
|
||||
mem_current: snapshots.length > 0 ? snapshots[snapshots.length - 1].memory_percent : null,
|
||||
cpu_slope: null,
|
||||
mem_slope: null,
|
||||
cpu_trend: null,
|
||||
mem_trend: null,
|
||||
sample_count: snapshots.length,
|
||||
status: 'insufficient'
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// 선형 회귀 계산 (최소제곱법)
|
||||
// y = ax + b, a = slope (기울기)
|
||||
const n = snapshots.length
|
||||
const current = snapshots[n - 1]
|
||||
const currCpu = current.cpu_percent ?? 0
|
||||
const currMem = current.memory_percent ?? 0
|
||||
|
||||
// x = 시간 (분), y = 값
|
||||
const cpuPoints = snapshots.map((s, i) => ({ x: i, y: s.cpu_percent ?? 0 }))
|
||||
const memPoints = snapshots.map((s, i) => ({ x: i, y: s.memory_percent ?? 0 }))
|
||||
|
||||
function linearRegression(points: { x: number, y: number }[]): { slope: number, intercept: number, r2: number } {
|
||||
const n = points.length
|
||||
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0, sumY2 = 0
|
||||
|
||||
for (const p of points) {
|
||||
sumX += p.x
|
||||
sumY += p.y
|
||||
sumXY += p.x * p.y
|
||||
sumX2 += p.x * p.x
|
||||
sumY2 += p.y * p.y
|
||||
}
|
||||
|
||||
const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX)
|
||||
const intercept = (sumY - slope * sumX) / n
|
||||
|
||||
// R² (결정계수) 계산
|
||||
const yMean = sumY / n
|
||||
let ssTotal = 0, ssResidual = 0
|
||||
for (const p of points) {
|
||||
const yPred = slope * p.x + intercept
|
||||
ssTotal += Math.pow(p.y - yMean, 2)
|
||||
ssResidual += Math.pow(p.y - yPred, 2)
|
||||
}
|
||||
const r2 = ssTotal > 0 ? 1 - (ssResidual / ssTotal) : 0
|
||||
|
||||
return { slope, intercept, r2 }
|
||||
}
|
||||
|
||||
const cpuReg = linearRegression(cpuPoints)
|
||||
const memReg = linearRegression(memPoints)
|
||||
|
||||
// 분당 기울기로 환산 (수집 간격 고려)
|
||||
const totalMinutes = WINDOW_MINUTES
|
||||
const cpuSlopePerMin = (cpuReg.slope * n) / totalMinutes
|
||||
const memSlopePerMin = (memReg.slope * n) / totalMinutes
|
||||
|
||||
// 추세 판단
|
||||
function getTrend(slope: number, r2: number): string {
|
||||
if (r2 < 0.3) return 'unstable' // 추세가 불안정
|
||||
if (slope >= SLOPE_THRESHOLD) return 'rising'
|
||||
if (slope <= -SLOPE_THRESHOLD) return 'falling'
|
||||
return 'stable'
|
||||
}
|
||||
|
||||
const cpuTrend = getTrend(cpuSlopePerMin, cpuReg.r2)
|
||||
const memTrend = getTrend(memSlopePerMin, memReg.r2)
|
||||
|
||||
// 상태 결정
|
||||
let status = 'normal'
|
||||
if (cpuTrend === 'rising' || memTrend === 'rising') status = 'warning'
|
||||
if (cpuSlopePerMin >= 1.0 || memSlopePerMin >= 1.0) status = 'danger' // 분당 1% 이상
|
||||
|
||||
serverResults.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
cpu_current: currCpu,
|
||||
mem_current: currMem,
|
||||
cpu_slope: cpuSlopePerMin,
|
||||
mem_slope: memSlopePerMin,
|
||||
cpu_trend: cpuTrend,
|
||||
mem_trend: memTrend,
|
||||
cpu_r2: cpuReg.r2,
|
||||
mem_r2: memReg.r2,
|
||||
sample_count: snapshots.length,
|
||||
status
|
||||
})
|
||||
|
||||
// CPU 이상감지 + 로그 저장
|
||||
if (cpuTrend === 'rising' && cpuReg.r2 >= 0.3) {
|
||||
const level = cpuSlopePerMin >= 1.0 ? 'danger' : 'warning'
|
||||
const message = `CPU 지속 상승 중 (분당 +${cpuSlopePerMin.toFixed(2)}%, R²=${cpuReg.r2.toFixed(2)})`
|
||||
|
||||
anomalies.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
metric: 'CPU',
|
||||
current: currCpu,
|
||||
slope: cpuSlopePerMin,
|
||||
r2: cpuReg.r2,
|
||||
trend: cpuTrend,
|
||||
level
|
||||
})
|
||||
|
||||
if (!recentLogExists.get(server.target_id, 'CPU')) {
|
||||
insertLog.run(server.target_id, server.server_name, 'CPU', level, currCpu, cpuSlopePerMin, message)
|
||||
}
|
||||
}
|
||||
|
||||
// Memory 이상감지 + 로그 저장
|
||||
if (memTrend === 'rising' && memReg.r2 >= 0.3) {
|
||||
const level = memSlopePerMin >= 1.0 ? 'danger' : 'warning'
|
||||
const message = `Memory 지속 상승 중 (분당 +${memSlopePerMin.toFixed(2)}%, R²=${memReg.r2.toFixed(2)})`
|
||||
|
||||
anomalies.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
metric: 'Memory',
|
||||
current: currMem,
|
||||
slope: memSlopePerMin,
|
||||
r2: memReg.r2,
|
||||
trend: memTrend,
|
||||
level
|
||||
})
|
||||
|
||||
if (!recentLogExists.get(server.target_id, 'Memory')) {
|
||||
insertLog.run(server.target_id, server.server_name, 'Memory', level, currMem, memSlopePerMin, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anomalies.sort((a, b) => b.slope - a.slope)
|
||||
|
||||
return {
|
||||
anomalies,
|
||||
servers: serverResults,
|
||||
config: {
|
||||
slope_threshold: SLOPE_THRESHOLD,
|
||||
window_minutes: WINDOW_MINUTES,
|
||||
min_samples: MIN_SAMPLES
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
149
backend/api/anomaly/zscore.get.ts
Normal file
149
backend/api/anomaly/zscore.get.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { getDb } from '../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const db = getDb()
|
||||
const WARNING_Z = 2.0
|
||||
const DANGER_Z = 3.0
|
||||
|
||||
const servers = db.prepare(`
|
||||
SELECT target_id, server_name
|
||||
FROM server_targets
|
||||
WHERE is_active = 1
|
||||
ORDER BY server_name
|
||||
`).all() as any[]
|
||||
|
||||
const anomalies: any[] = []
|
||||
const serverResults: any[] = []
|
||||
|
||||
// 로그 저장용
|
||||
const insertLog = db.prepare(`
|
||||
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
|
||||
VALUES (?, ?, 'zscore', ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
const recentLogExists = db.prepare(`
|
||||
SELECT 1 FROM anomaly_logs
|
||||
WHERE target_id = ? AND detect_type = 'zscore' AND metric = ?
|
||||
AND detected_at > datetime('now', '-1 minute', 'localtime')
|
||||
LIMIT 1
|
||||
`)
|
||||
|
||||
for (const server of servers) {
|
||||
const snapshots = db.prepare(`
|
||||
SELECT cpu_percent, memory_percent, collected_at
|
||||
FROM server_snapshots
|
||||
WHERE target_id = ?
|
||||
AND collected_at >= datetime('now', '-1 hour', 'localtime')
|
||||
ORDER BY collected_at DESC
|
||||
`).all(server.target_id) as any[]
|
||||
|
||||
if (snapshots.length < 10) {
|
||||
serverResults.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
cpu_zscore: null,
|
||||
mem_zscore: null,
|
||||
cpu_avg: null,
|
||||
cpu_std: null,
|
||||
mem_avg: null,
|
||||
mem_std: null,
|
||||
sample_count: snapshots.length,
|
||||
status: 'insufficient'
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const current = snapshots[0]
|
||||
const currCpu = current.cpu_percent ?? 0
|
||||
const currMem = current.memory_percent ?? 0
|
||||
|
||||
const cpuValues = snapshots.map(s => s.cpu_percent ?? 0)
|
||||
const memValues = snapshots.map(s => s.memory_percent ?? 0)
|
||||
|
||||
const cpuAvg = cpuValues.reduce((a, b) => a + b, 0) / cpuValues.length
|
||||
const memAvg = memValues.reduce((a, b) => a + b, 0) / memValues.length
|
||||
|
||||
const cpuVariance = cpuValues.reduce((sum, val) => sum + Math.pow(val - cpuAvg, 2), 0) / cpuValues.length
|
||||
const memVariance = memValues.reduce((sum, val) => sum + Math.pow(val - memAvg, 2), 0) / memValues.length
|
||||
const cpuStd = Math.sqrt(cpuVariance)
|
||||
const memStd = Math.sqrt(memVariance)
|
||||
|
||||
const cpuZscore = cpuStd > 0.1 ? (currCpu - cpuAvg) / cpuStd : 0
|
||||
const memZscore = memStd > 0.1 ? (currMem - memAvg) / memStd : 0
|
||||
|
||||
let status = 'normal'
|
||||
const maxZ = Math.max(Math.abs(cpuZscore), Math.abs(memZscore))
|
||||
if (maxZ >= DANGER_Z) status = 'danger'
|
||||
else if (maxZ >= WARNING_Z) status = 'warning'
|
||||
|
||||
serverResults.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
cpu_current: currCpu,
|
||||
mem_current: currMem,
|
||||
cpu_zscore: cpuZscore,
|
||||
mem_zscore: memZscore,
|
||||
cpu_avg: cpuAvg,
|
||||
cpu_std: cpuStd,
|
||||
mem_avg: memAvg,
|
||||
mem_std: memStd,
|
||||
sample_count: snapshots.length,
|
||||
status
|
||||
})
|
||||
|
||||
// CPU 이상 감지 + 로그 저장
|
||||
if (Math.abs(cpuZscore) >= WARNING_Z) {
|
||||
const level = Math.abs(cpuZscore) >= DANGER_Z ? 'danger' : 'warning'
|
||||
const direction = cpuZscore >= 0 ? '높음' : '낮음'
|
||||
const message = `CPU 평균 대비 ${Math.abs(cpuZscore).toFixed(1)}σ ${direction} (평균: ${cpuAvg.toFixed(1)}%, 현재: ${currCpu.toFixed(1)}%)`
|
||||
|
||||
anomalies.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
metric: 'CPU',
|
||||
current: currCpu,
|
||||
avg: cpuAvg,
|
||||
std: cpuStd,
|
||||
zscore: cpuZscore,
|
||||
direction: cpuZscore >= 0 ? 'up' : 'down',
|
||||
level
|
||||
})
|
||||
|
||||
if (!recentLogExists.get(server.target_id, 'CPU')) {
|
||||
insertLog.run(server.target_id, server.server_name, 'CPU', level, currCpu, cpuZscore, message)
|
||||
}
|
||||
}
|
||||
|
||||
// Memory 이상 감지 + 로그 저장
|
||||
if (Math.abs(memZscore) >= WARNING_Z) {
|
||||
const level = Math.abs(memZscore) >= DANGER_Z ? 'danger' : 'warning'
|
||||
const direction = memZscore >= 0 ? '높음' : '낮음'
|
||||
const message = `Memory 평균 대비 ${Math.abs(memZscore).toFixed(1)}σ ${direction} (평균: ${memAvg.toFixed(1)}%, 현재: ${currMem.toFixed(1)}%)`
|
||||
|
||||
anomalies.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
metric: 'Memory',
|
||||
current: currMem,
|
||||
avg: memAvg,
|
||||
std: memStd,
|
||||
zscore: memZscore,
|
||||
direction: memZscore >= 0 ? 'up' : 'down',
|
||||
level
|
||||
})
|
||||
|
||||
if (!recentLogExists.get(server.target_id, 'Memory')) {
|
||||
insertLog.run(server.target_id, server.server_name, 'Memory', level, currMem, memZscore, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anomalies.sort((a, b) => Math.abs(b.zscore) - Math.abs(a.zscore))
|
||||
|
||||
return {
|
||||
anomalies,
|
||||
servers: serverResults,
|
||||
thresholds: { warning: WARNING_Z, danger: DANGER_Z },
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
78
backend/api/network/privnet/chart.get.ts
Normal file
78
backend/api/network/privnet/chart.get.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event)
|
||||
const { year, month, week } = query as { year?: string, month?: string, week?: string }
|
||||
|
||||
if (!year || !month || !week) {
|
||||
return { error: 'year, month, week are required' }
|
||||
}
|
||||
|
||||
// 해당 월의 첫날과 마지막날
|
||||
const y = parseInt(year)
|
||||
const m = parseInt(month)
|
||||
const w = parseInt(week)
|
||||
|
||||
// 해당 월의 첫날
|
||||
const firstDayOfMonth = new Date(y, m - 1, 1)
|
||||
const firstDayWeekday = firstDayOfMonth.getDay() // 0=일, 1=월, ...
|
||||
|
||||
// 주차의 시작일 계산 (월요일 기준)
|
||||
// 1주차: 1일이 포함된 주
|
||||
const mondayOffset = firstDayWeekday === 0 ? -6 : 1 - firstDayWeekday
|
||||
const firstMondayOfMonth = new Date(y, m - 1, 1 + mondayOffset)
|
||||
|
||||
// 선택한 주차의 월요일
|
||||
const weekStart = new Date(firstMondayOfMonth)
|
||||
weekStart.setDate(weekStart.getDate() + (w - 1) * 7)
|
||||
|
||||
// 주차의 일요일
|
||||
const weekEnd = new Date(weekStart)
|
||||
weekEnd.setDate(weekEnd.getDate() + 6)
|
||||
|
||||
// 해당 주의 날짜 목록 생성
|
||||
const weekDates: string[] = []
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(weekStart)
|
||||
d.setDate(d.getDate() + i)
|
||||
const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
weekDates.push(dateStr)
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
|
||||
// 시작/종료 날짜
|
||||
const startDate = weekDates[0]
|
||||
const endDate = weekDates[6]
|
||||
|
||||
// 1시간 단위 성공률 조회
|
||||
const heatmapData = db.prepare(`
|
||||
SELECT
|
||||
date(checked_at) as date,
|
||||
strftime('%H', checked_at) || ':00' as time_slot,
|
||||
COUNT(*) as total_count,
|
||||
SUM(CASE WHEN is_success = 1 THEN 1 ELSE 0 END) as success_count,
|
||||
ROUND(SUM(CASE WHEN is_success = 1 THEN 1.0 ELSE 0.0 END) / COUNT(*) * 100, 1) as success_rate
|
||||
FROM privnet_logs
|
||||
WHERE date(checked_at) >= ? AND date(checked_at) <= ?
|
||||
GROUP BY date, time_slot
|
||||
ORDER BY date, time_slot
|
||||
`).all(startDate, endDate)
|
||||
|
||||
// 해당 월의 주차 수 계산
|
||||
const lastDayOfMonth = new Date(y, m, 0)
|
||||
const lastDate = lastDayOfMonth.getDate()
|
||||
|
||||
// 마지막 날이 몇 주차인지 계산
|
||||
const lastDayFromFirstMonday = Math.floor((lastDayOfMonth.getTime() - firstMondayOfMonth.getTime()) / (1000 * 60 * 60 * 24))
|
||||
const totalWeeks = Math.ceil((lastDayFromFirstMonday + 1) / 7)
|
||||
|
||||
return {
|
||||
heatmapData,
|
||||
weekDates,
|
||||
totalWeeks: Math.max(totalWeeks, 1),
|
||||
year: y,
|
||||
month: m,
|
||||
week: w
|
||||
}
|
||||
})
|
||||
33
backend/api/network/privnet/logs.get.ts
Normal file
33
backend/api/network/privnet/logs.get.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event)
|
||||
const { year, month, day, hour } = query as {
|
||||
year?: string, month?: string, day?: string, hour?: string
|
||||
}
|
||||
|
||||
if (!year || !month || !day || !hour) {
|
||||
return { error: 'year, month, day, hour are required' }
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
|
||||
// 해당 시간대 로그 조회
|
||||
const startTime = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')} ${hour.padStart(2, '0')}:00:00`
|
||||
const endTime = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')} ${hour.padStart(2, '0')}:59:59`
|
||||
|
||||
const logs = db.prepare(`
|
||||
SELECT
|
||||
l.id,
|
||||
l.checked_at,
|
||||
l.is_success,
|
||||
t.name as target_name,
|
||||
t.url as target_url
|
||||
FROM privnet_logs l
|
||||
JOIN privnet_targets t ON l.target_id = t.id
|
||||
WHERE l.checked_at >= ? AND l.checked_at <= ?
|
||||
ORDER BY l.checked_at DESC
|
||||
`).all(startTime, endTime)
|
||||
|
||||
return { logs }
|
||||
})
|
||||
11
backend/api/network/privnet/scheduler/start.post.ts
Normal file
11
backend/api/network/privnet/scheduler/start.post.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { privnetScheduler } from '../../../../utils/privnet-scheduler'
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
privnetScheduler.start()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Privnet scheduler started',
|
||||
isRunning: privnetScheduler.getIsRunning()
|
||||
}
|
||||
})
|
||||
11
backend/api/network/privnet/scheduler/stop.post.ts
Normal file
11
backend/api/network/privnet/scheduler/stop.post.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { privnetScheduler } from '../../../../utils/privnet-scheduler'
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
privnetScheduler.stop()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Privnet scheduler stopped',
|
||||
isRunning: privnetScheduler.getIsRunning()
|
||||
}
|
||||
})
|
||||
41
backend/api/network/privnet/status.get.ts
Normal file
41
backend/api/network/privnet/status.get.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
import { privnetScheduler } from '../../../utils/privnet-scheduler'
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
const db = getDb()
|
||||
|
||||
// 현재 상태 조회
|
||||
const status = db.prepare(`
|
||||
SELECT
|
||||
ps.*,
|
||||
pt.name as last_target_name,
|
||||
pt.url as last_target_url
|
||||
FROM privnet_status ps
|
||||
LEFT JOIN privnet_targets pt ON ps.last_target_id = pt.id
|
||||
WHERE ps.id = 1
|
||||
`).get()
|
||||
|
||||
// 최근 10개 로그
|
||||
const recentLogs = db.prepare(`
|
||||
SELECT
|
||||
pl.*,
|
||||
pt.name as target_name,
|
||||
pt.url as target_url
|
||||
FROM privnet_logs pl
|
||||
JOIN privnet_targets pt ON pl.target_id = pt.id
|
||||
ORDER BY pl.checked_at DESC
|
||||
LIMIT 10
|
||||
`).all()
|
||||
|
||||
// 활성 타겟 수
|
||||
const targetCount = db.prepare(`
|
||||
SELECT COUNT(*) as cnt FROM privnet_targets WHERE is_active = 1
|
||||
`).get() as { cnt: number }
|
||||
|
||||
return {
|
||||
status,
|
||||
recentLogs,
|
||||
targetCount: targetCount.cnt,
|
||||
schedulerRunning: privnetScheduler.getIsRunning()
|
||||
}
|
||||
})
|
||||
10
backend/api/network/privnet/targets/[id].delete.ts
Normal file
10
backend/api/network/privnet/targets/[id].delete.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { getDb } from '../../../../utils/db'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const db = getDb()
|
||||
const id = getRouterParam(event, 'id')
|
||||
|
||||
db.prepare(`DELETE FROM privnet_targets WHERE id = ?`).run(id)
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
29
backend/api/network/privnet/targets/[id].put.ts
Normal file
29
backend/api/network/privnet/targets/[id].put.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { getDb } from '../../../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const db = getDb()
|
||||
const id = getRouterParam(event, 'id')
|
||||
const body = await readBody(event)
|
||||
|
||||
const { name, url, is_active } = body
|
||||
|
||||
if (!name || !url) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'name과 url은 필수입니다'
|
||||
})
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE privnet_targets
|
||||
SET name = ?, url = ?, is_active = ?, updated_at = datetime('now', 'localtime')
|
||||
WHERE id = ?
|
||||
`).run(name, url, is_active ? 1 : 0, id)
|
||||
|
||||
return {
|
||||
id: Number(id),
|
||||
name,
|
||||
url,
|
||||
is_active: is_active ? 1 : 0
|
||||
}
|
||||
})
|
||||
12
backend/api/network/privnet/targets/index.get.ts
Normal file
12
backend/api/network/privnet/targets/index.get.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { getDb } from '../../../../utils/db'
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
const db = getDb()
|
||||
|
||||
const targets = db.prepare(`
|
||||
SELECT * FROM privnet_targets
|
||||
ORDER BY id ASC
|
||||
`).all()
|
||||
|
||||
return targets
|
||||
})
|
||||
27
backend/api/network/privnet/targets/index.post.ts
Normal file
27
backend/api/network/privnet/targets/index.post.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { getDb } from '../../../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const db = getDb()
|
||||
const body = await readBody(event)
|
||||
|
||||
const { name, url, is_active } = body
|
||||
|
||||
if (!name || !url) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'name과 url은 필수입니다'
|
||||
})
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO privnet_targets (name, url, is_active)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(name, url, is_active ? 1 : 0)
|
||||
|
||||
return {
|
||||
id: result.lastInsertRowid,
|
||||
name,
|
||||
url,
|
||||
is_active: is_active ? 1 : 0
|
||||
}
|
||||
})
|
||||
78
backend/api/network/pubnet/chart.get.ts
Normal file
78
backend/api/network/pubnet/chart.get.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event)
|
||||
const { year, month, week } = query as { year?: string, month?: string, week?: string }
|
||||
|
||||
if (!year || !month || !week) {
|
||||
return { error: 'year, month, week are required' }
|
||||
}
|
||||
|
||||
// 해당 월의 첫날과 마지막날
|
||||
const y = parseInt(year)
|
||||
const m = parseInt(month)
|
||||
const w = parseInt(week)
|
||||
|
||||
// 해당 월의 첫날
|
||||
const firstDayOfMonth = new Date(y, m - 1, 1)
|
||||
const firstDayWeekday = firstDayOfMonth.getDay() // 0=일, 1=월, ...
|
||||
|
||||
// 주차의 시작일 계산 (월요일 기준)
|
||||
// 1주차: 1일이 포함된 주
|
||||
const mondayOffset = firstDayWeekday === 0 ? -6 : 1 - firstDayWeekday
|
||||
const firstMondayOfMonth = new Date(y, m - 1, 1 + mondayOffset)
|
||||
|
||||
// 선택한 주차의 월요일
|
||||
const weekStart = new Date(firstMondayOfMonth)
|
||||
weekStart.setDate(weekStart.getDate() + (w - 1) * 7)
|
||||
|
||||
// 주차의 일요일
|
||||
const weekEnd = new Date(weekStart)
|
||||
weekEnd.setDate(weekEnd.getDate() + 6)
|
||||
|
||||
// 해당 주의 날짜 목록 생성
|
||||
const weekDates: string[] = []
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(weekStart)
|
||||
d.setDate(d.getDate() + i)
|
||||
const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
weekDates.push(dateStr)
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
|
||||
// 시작/종료 날짜
|
||||
const startDate = weekDates[0]
|
||||
const endDate = weekDates[6]
|
||||
|
||||
// 1시간 단위 성공률 조회
|
||||
const heatmapData = db.prepare(`
|
||||
SELECT
|
||||
date(checked_at) as date,
|
||||
strftime('%H', checked_at) || ':00' as time_slot,
|
||||
COUNT(*) as total_count,
|
||||
SUM(CASE WHEN is_success = 1 THEN 1 ELSE 0 END) as success_count,
|
||||
ROUND(SUM(CASE WHEN is_success = 1 THEN 1.0 ELSE 0.0 END) / COUNT(*) * 100, 1) as success_rate
|
||||
FROM pubnet_logs
|
||||
WHERE date(checked_at) >= ? AND date(checked_at) <= ?
|
||||
GROUP BY date, time_slot
|
||||
ORDER BY date, time_slot
|
||||
`).all(startDate, endDate)
|
||||
|
||||
// 해당 월의 주차 수 계산
|
||||
const lastDayOfMonth = new Date(y, m, 0)
|
||||
const lastDate = lastDayOfMonth.getDate()
|
||||
|
||||
// 마지막 날이 몇 주차인지 계산
|
||||
const lastDayFromFirstMonday = Math.floor((lastDayOfMonth.getTime() - firstMondayOfMonth.getTime()) / (1000 * 60 * 60 * 24))
|
||||
const totalWeeks = Math.ceil((lastDayFromFirstMonday + 1) / 7)
|
||||
|
||||
return {
|
||||
heatmapData,
|
||||
weekDates,
|
||||
totalWeeks: Math.max(totalWeeks, 1),
|
||||
year: y,
|
||||
month: m,
|
||||
week: w
|
||||
}
|
||||
})
|
||||
33
backend/api/network/pubnet/logs.get.ts
Normal file
33
backend/api/network/pubnet/logs.get.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event)
|
||||
const { year, month, day, hour } = query as {
|
||||
year?: string, month?: string, day?: string, hour?: string
|
||||
}
|
||||
|
||||
if (!year || !month || !day || !hour) {
|
||||
return { error: 'year, month, day, hour are required' }
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
|
||||
// 해당 시간대 로그 조회
|
||||
const startTime = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')} ${hour.padStart(2, '0')}:00:00`
|
||||
const endTime = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')} ${hour.padStart(2, '0')}:59:59`
|
||||
|
||||
const logs = db.prepare(`
|
||||
SELECT
|
||||
l.id,
|
||||
l.checked_at,
|
||||
l.is_success,
|
||||
t.name as target_name,
|
||||
t.url as target_url
|
||||
FROM pubnet_logs l
|
||||
JOIN pubnet_targets t ON l.target_id = t.id
|
||||
WHERE l.checked_at >= ? AND l.checked_at <= ?
|
||||
ORDER BY l.checked_at DESC
|
||||
`).all(startTime, endTime)
|
||||
|
||||
return { logs }
|
||||
})
|
||||
11
backend/api/network/pubnet/scheduler/start.post.ts
Normal file
11
backend/api/network/pubnet/scheduler/start.post.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { pubnetScheduler } from '../../../../utils/pubnet-scheduler'
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
pubnetScheduler.start()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Pubnet scheduler started',
|
||||
isRunning: pubnetScheduler.getIsRunning()
|
||||
}
|
||||
})
|
||||
11
backend/api/network/pubnet/scheduler/stop.post.ts
Normal file
11
backend/api/network/pubnet/scheduler/stop.post.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { pubnetScheduler } from '../../../../utils/pubnet-scheduler'
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
pubnetScheduler.stop()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Pubnet scheduler stopped',
|
||||
isRunning: pubnetScheduler.getIsRunning()
|
||||
}
|
||||
})
|
||||
41
backend/api/network/pubnet/status.get.ts
Normal file
41
backend/api/network/pubnet/status.get.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
import { pubnetScheduler } from '../../../utils/pubnet-scheduler'
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
const db = getDb()
|
||||
|
||||
// 현재 상태 조회
|
||||
const status = db.prepare(`
|
||||
SELECT
|
||||
ps.*,
|
||||
pt.name as last_target_name,
|
||||
pt.url as last_target_url
|
||||
FROM pubnet_status ps
|
||||
LEFT JOIN pubnet_targets pt ON ps.last_target_id = pt.id
|
||||
WHERE ps.id = 1
|
||||
`).get()
|
||||
|
||||
// 최근 10개 로그
|
||||
const recentLogs = db.prepare(`
|
||||
SELECT
|
||||
pl.*,
|
||||
pt.name as target_name,
|
||||
pt.url as target_url
|
||||
FROM pubnet_logs pl
|
||||
JOIN pubnet_targets pt ON pl.target_id = pt.id
|
||||
ORDER BY pl.checked_at DESC
|
||||
LIMIT 10
|
||||
`).all()
|
||||
|
||||
// 활성 타겟 수
|
||||
const targetCount = db.prepare(`
|
||||
SELECT COUNT(*) as cnt FROM pubnet_targets WHERE is_active = 1
|
||||
`).get() as { cnt: number }
|
||||
|
||||
return {
|
||||
status,
|
||||
recentLogs,
|
||||
targetCount: targetCount.cnt,
|
||||
schedulerRunning: pubnetScheduler.getIsRunning()
|
||||
}
|
||||
})
|
||||
10
backend/api/network/pubnet/targets/[id].delete.ts
Normal file
10
backend/api/network/pubnet/targets/[id].delete.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { getDb } from '../../../../utils/db'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const db = getDb()
|
||||
const id = getRouterParam(event, 'id')
|
||||
|
||||
db.prepare(`DELETE FROM pubnet_targets WHERE id = ?`).run(id)
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
29
backend/api/network/pubnet/targets/[id].put.ts
Normal file
29
backend/api/network/pubnet/targets/[id].put.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { getDb } from '../../../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const db = getDb()
|
||||
const id = getRouterParam(event, 'id')
|
||||
const body = await readBody(event)
|
||||
|
||||
const { name, url, is_active } = body
|
||||
|
||||
if (!name || !url) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'name과 url은 필수입니다'
|
||||
})
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE pubnet_targets
|
||||
SET name = ?, url = ?, is_active = ?, updated_at = datetime('now', 'localtime')
|
||||
WHERE id = ?
|
||||
`).run(name, url, is_active ? 1 : 0, id)
|
||||
|
||||
return {
|
||||
id: Number(id),
|
||||
name,
|
||||
url,
|
||||
is_active: is_active ? 1 : 0
|
||||
}
|
||||
})
|
||||
12
backend/api/network/pubnet/targets/index.get.ts
Normal file
12
backend/api/network/pubnet/targets/index.get.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { getDb } from '../../../../utils/db'
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
const db = getDb()
|
||||
|
||||
const targets = db.prepare(`
|
||||
SELECT * FROM pubnet_targets
|
||||
ORDER BY id ASC
|
||||
`).all()
|
||||
|
||||
return targets
|
||||
})
|
||||
27
backend/api/network/pubnet/targets/index.post.ts
Normal file
27
backend/api/network/pubnet/targets/index.post.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { getDb } from '../../../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const db = getDb()
|
||||
const body = await readBody(event)
|
||||
|
||||
const { name, url, is_active } = body
|
||||
|
||||
if (!name || !url) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'name과 url은 필수입니다'
|
||||
})
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO pubnet_targets (name, url, is_active)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(name, url, is_active ? 1 : 0)
|
||||
|
||||
return {
|
||||
id: result.lastInsertRowid,
|
||||
name,
|
||||
url,
|
||||
is_active: is_active ? 1 : 0
|
||||
}
|
||||
})
|
||||
30
backend/api/server/history/container-list.get.ts
Normal file
30
backend/api/server/history/container-list.get.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const query = getQuery(event)
|
||||
const targetId = query.target_id as string
|
||||
|
||||
if (!targetId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'target_id is required'
|
||||
})
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
|
||||
// 최신 수집 시간 기준 컨테이너 목록
|
||||
const containers = db.prepare(`
|
||||
SELECT DISTINCT container_name
|
||||
FROM server_containers
|
||||
WHERE target_id = ?
|
||||
AND collected_at = (
|
||||
SELECT MAX(collected_at)
|
||||
FROM server_containers
|
||||
WHERE target_id = ?
|
||||
)
|
||||
ORDER BY container_name ASC
|
||||
`).all(targetId, targetId)
|
||||
|
||||
return containers.map((c: any) => c.container_name)
|
||||
})
|
||||
57
backend/api/server/history/containers.get.ts
Normal file
57
backend/api/server/history/containers.get.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const query = getQuery(event)
|
||||
const targetId = query.target_id as string
|
||||
const period = (query.period as string) || '1h'
|
||||
|
||||
if (!targetId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'target_id is required'
|
||||
})
|
||||
}
|
||||
|
||||
const periodMap: Record<string, string> = {
|
||||
'1h': '-1 hour',
|
||||
'2h': '-2 hours',
|
||||
'3h': '-3 hours',
|
||||
'4h': '-4 hours',
|
||||
'5h': '-5 hours',
|
||||
'6h': '-6 hours',
|
||||
'12h': '-12 hours',
|
||||
'18h': '-18 hours',
|
||||
'24h': '-24 hours',
|
||||
'7d': '-7 days',
|
||||
'30d': '-30 days'
|
||||
}
|
||||
|
||||
const timeOffset = periodMap[period] || '-1 hour'
|
||||
|
||||
const db = getDb()
|
||||
|
||||
const containers = db.prepare(`
|
||||
SELECT
|
||||
container_id,
|
||||
container_name,
|
||||
container_status,
|
||||
cpu_percent,
|
||||
memory_usage,
|
||||
memory_limit,
|
||||
memory_percent,
|
||||
uptime,
|
||||
network_rx,
|
||||
network_tx,
|
||||
collected_at
|
||||
FROM server_containers
|
||||
WHERE target_id = ?
|
||||
AND collected_at >= datetime('now', 'localtime', ?)
|
||||
ORDER BY collected_at ASC, container_name ASC
|
||||
`).all(targetId, timeOffset)
|
||||
|
||||
return {
|
||||
target_id: targetId,
|
||||
period,
|
||||
data: containers
|
||||
}
|
||||
})
|
||||
29
backend/api/server/history/disk-list.get.ts
Normal file
29
backend/api/server/history/disk-list.get.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const query = getQuery(event)
|
||||
const targetId = query.target_id as string
|
||||
|
||||
if (!targetId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'target_id is required'
|
||||
})
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
|
||||
// 최신 수집 시간 기준 디스크 목록 (물리 디스크만)
|
||||
const disks = db.prepare(`
|
||||
SELECT DISTINCT device_name, mount_point, fs_type, disk_total, disk_used, disk_percent
|
||||
FROM server_disks
|
||||
WHERE target_id = ?
|
||||
AND collected_at = (SELECT MAX(collected_at) FROM server_disks WHERE target_id = ?)
|
||||
AND device_name NOT LIKE '%loop%'
|
||||
AND mount_point NOT LIKE '%/snap%'
|
||||
AND fs_type NOT IN ('tmpfs', 'squashfs', 'overlay')
|
||||
ORDER BY mount_point ASC
|
||||
`).all(targetId, targetId)
|
||||
|
||||
return disks
|
||||
})
|
||||
54
backend/api/server/history/disks.get.ts
Normal file
54
backend/api/server/history/disks.get.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const query = getQuery(event)
|
||||
const targetId = query.target_id as string
|
||||
const period = (query.period as string) || '1h'
|
||||
|
||||
if (!targetId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'target_id is required'
|
||||
})
|
||||
}
|
||||
|
||||
const periodMap: Record<string, string> = {
|
||||
'1h': '-1 hour',
|
||||
'2h': '-2 hours',
|
||||
'3h': '-3 hours',
|
||||
'4h': '-4 hours',
|
||||
'5h': '-5 hours',
|
||||
'6h': '-6 hours',
|
||||
'12h': '-12 hours',
|
||||
'18h': '-18 hours',
|
||||
'24h': '-24 hours',
|
||||
'7d': '-7 days',
|
||||
'30d': '-30 days'
|
||||
}
|
||||
|
||||
const timeOffset = periodMap[period] || '-1 hour'
|
||||
|
||||
const db = getDb()
|
||||
|
||||
const disks = db.prepare(`
|
||||
SELECT
|
||||
disk_id,
|
||||
device_name,
|
||||
mount_point,
|
||||
fs_type,
|
||||
disk_total,
|
||||
disk_used,
|
||||
disk_percent,
|
||||
collected_at
|
||||
FROM server_disks
|
||||
WHERE target_id = ?
|
||||
AND collected_at >= datetime('now', 'localtime', ?)
|
||||
ORDER BY collected_at ASC, mount_point ASC
|
||||
`).all(targetId, timeOffset)
|
||||
|
||||
return {
|
||||
target_id: targetId,
|
||||
period,
|
||||
data: disks
|
||||
}
|
||||
})
|
||||
32
backend/api/server/history/latest.get.ts
Normal file
32
backend/api/server/history/latest.get.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const query = getQuery(event)
|
||||
const targetId = query.target_id as string
|
||||
|
||||
if (!targetId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'target_id is required'
|
||||
})
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
|
||||
// 최신 스냅샷
|
||||
const snapshot = db.prepare(`
|
||||
SELECT
|
||||
s.*,
|
||||
t.server_name,
|
||||
t.server_ip,
|
||||
t.glances_url,
|
||||
t.collect_interval
|
||||
FROM server_snapshots s
|
||||
JOIN server_targets t ON s.target_id = t.target_id
|
||||
WHERE s.target_id = ?
|
||||
ORDER BY s.collected_at DESC
|
||||
LIMIT 1
|
||||
`).get(targetId)
|
||||
|
||||
return snapshot || null
|
||||
})
|
||||
54
backend/api/server/history/networks.get.ts
Normal file
54
backend/api/server/history/networks.get.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const query = getQuery(event)
|
||||
const targetId = query.target_id as string
|
||||
const period = (query.period as string) || '1h'
|
||||
|
||||
if (!targetId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'target_id is required'
|
||||
})
|
||||
}
|
||||
|
||||
const periodMap: Record<string, string> = {
|
||||
'1h': '-1 hour',
|
||||
'2h': '-2 hours',
|
||||
'3h': '-3 hours',
|
||||
'4h': '-4 hours',
|
||||
'5h': '-5 hours',
|
||||
'6h': '-6 hours',
|
||||
'12h': '-12 hours',
|
||||
'18h': '-18 hours',
|
||||
'24h': '-24 hours',
|
||||
'7d': '-7 days',
|
||||
'30d': '-30 days'
|
||||
}
|
||||
|
||||
const timeOffset = periodMap[period] || '-1 hour'
|
||||
|
||||
const db = getDb()
|
||||
|
||||
const networks = db.prepare(`
|
||||
SELECT
|
||||
network_id,
|
||||
interface_name,
|
||||
bytes_recv,
|
||||
bytes_sent,
|
||||
speed_recv,
|
||||
speed_sent,
|
||||
is_up,
|
||||
collected_at
|
||||
FROM server_networks
|
||||
WHERE target_id = ?
|
||||
AND collected_at >= datetime('now', 'localtime', ?)
|
||||
ORDER BY collected_at ASC, interface_name ASC
|
||||
`).all(targetId, timeOffset)
|
||||
|
||||
return {
|
||||
target_id: targetId,
|
||||
period,
|
||||
data: networks
|
||||
}
|
||||
})
|
||||
59
backend/api/server/history/snapshots.get.ts
Normal file
59
backend/api/server/history/snapshots.get.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const query = getQuery(event)
|
||||
const targetId = query.target_id as string
|
||||
const period = (query.period as string) || '1h'
|
||||
|
||||
if (!targetId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'target_id is required'
|
||||
})
|
||||
}
|
||||
|
||||
// 기간별 시간 계산
|
||||
const periodMap: Record<string, string> = {
|
||||
'1h': '-1 hour',
|
||||
'2h': '-2 hours',
|
||||
'3h': '-3 hours',
|
||||
'4h': '-4 hours',
|
||||
'5h': '-5 hours',
|
||||
'6h': '-6 hours',
|
||||
'12h': '-12 hours',
|
||||
'18h': '-18 hours',
|
||||
'24h': '-24 hours',
|
||||
'7d': '-7 days',
|
||||
'30d': '-30 days'
|
||||
}
|
||||
|
||||
const timeOffset = periodMap[period] || '-1 hour'
|
||||
|
||||
const db = getDb()
|
||||
|
||||
const snapshots = db.prepare(`
|
||||
SELECT
|
||||
snapshot_id,
|
||||
cpu_percent,
|
||||
cpu_temp,
|
||||
load_percent,
|
||||
memory_percent,
|
||||
memory_used,
|
||||
memory_total,
|
||||
swap_percent,
|
||||
swap_used,
|
||||
swap_total,
|
||||
is_online,
|
||||
collected_at
|
||||
FROM server_snapshots
|
||||
WHERE target_id = ?
|
||||
AND collected_at >= datetime('now', 'localtime', ?)
|
||||
ORDER BY collected_at ASC
|
||||
`).all(targetId, timeOffset)
|
||||
|
||||
return {
|
||||
target_id: targetId,
|
||||
period,
|
||||
data: snapshots
|
||||
}
|
||||
})
|
||||
6
backend/api/server/scheduler/start.post.ts
Normal file
6
backend/api/server/scheduler/start.post.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { startServerScheduler } from '../../../utils/server-scheduler'
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
startServerScheduler()
|
||||
return { success: true, message: 'Server scheduler started' }
|
||||
})
|
||||
6
backend/api/server/scheduler/stop.post.ts
Normal file
6
backend/api/server/scheduler/stop.post.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { stopServerScheduler } from '../../../utils/server-scheduler'
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
stopServerScheduler()
|
||||
return { success: true, message: 'Server scheduler stopped' }
|
||||
})
|
||||
5
backend/api/server/status.get.ts
Normal file
5
backend/api/server/status.get.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { getServerSchedulerStatus } from '../../utils/server-scheduler'
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
return getServerSchedulerStatus()
|
||||
})
|
||||
27
backend/api/server/targets/[id].delete.ts
Normal file
27
backend/api/server/targets/[id].delete.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
import { refreshServerTimer } from '../../../utils/server-scheduler'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const targetId = getRouterParam(event, 'id')
|
||||
|
||||
if (!targetId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'target_id is required'
|
||||
})
|
||||
}
|
||||
|
||||
// 스케줄러에서 제거
|
||||
refreshServerTimer(Number(targetId))
|
||||
|
||||
const db = getDb()
|
||||
|
||||
const result = db.prepare(`
|
||||
DELETE FROM server_targets WHERE target_id = ?
|
||||
`).run(targetId)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
changes: result.changes
|
||||
}
|
||||
})
|
||||
36
backend/api/server/targets/[id].put.ts
Normal file
36
backend/api/server/targets/[id].put.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
import { refreshServerTimer } from '../../../utils/server-scheduler'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const targetId = getRouterParam(event, 'id')
|
||||
const body = await readBody(event)
|
||||
const { server_name, server_ip, glances_url, is_active, collect_interval } = body
|
||||
|
||||
if (!targetId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'target_id is required'
|
||||
})
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
|
||||
const result = db.prepare(`
|
||||
UPDATE server_targets
|
||||
SET server_name = ?,
|
||||
server_ip = ?,
|
||||
glances_url = ?,
|
||||
is_active = ?,
|
||||
collect_interval = ?,
|
||||
updated_at = datetime('now', 'localtime')
|
||||
WHERE target_id = ?
|
||||
`).run(server_name, server_ip, glances_url, is_active ? 1 : 0, collect_interval || 60, targetId)
|
||||
|
||||
// 스케줄러에 반영
|
||||
refreshServerTimer(Number(targetId))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
changes: result.changes
|
||||
}
|
||||
})
|
||||
12
backend/api/server/targets/index.get.ts
Normal file
12
backend/api/server/targets/index.get.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
const db = getDb()
|
||||
|
||||
const targets = db.prepare(`
|
||||
SELECT * FROM server_targets
|
||||
ORDER BY target_id ASC
|
||||
`).all()
|
||||
|
||||
return targets
|
||||
})
|
||||
33
backend/api/server/targets/index.post.ts
Normal file
33
backend/api/server/targets/index.post.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
import { refreshServerTimer } from '../../../utils/server-scheduler'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
const { server_name, server_ip, glances_url, is_active = 1, collect_interval = 60 } = body
|
||||
|
||||
if (!server_name || !server_ip || !glances_url) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'server_name, server_ip, glances_url are required'
|
||||
})
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO server_targets (server_name, server_ip, glances_url, is_active, collect_interval)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(server_name, server_ip, glances_url, is_active ? 1 : 0, collect_interval)
|
||||
|
||||
const targetId = result.lastInsertRowid as number
|
||||
|
||||
// 스케줄러에 반영
|
||||
if (is_active) {
|
||||
refreshServerTimer(targetId)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
target_id: targetId
|
||||
}
|
||||
})
|
||||
27
backend/api/settings/thresholds.get.ts
Normal file
27
backend/api/settings/thresholds.get.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { getDb } from '../../utils/db'
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
const db = getDb()
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT category, metric, warning, critical, danger, updated_at
|
||||
FROM thresholds
|
||||
ORDER BY category, metric
|
||||
`).all() as any[]
|
||||
|
||||
// 카테고리별로 그룹화
|
||||
const result: Record<string, Record<string, { warning: number; critical: number; danger: number }>> = {}
|
||||
|
||||
for (const row of rows) {
|
||||
if (!result[row.category]) {
|
||||
result[row.category] = {}
|
||||
}
|
||||
result[row.category][row.metric] = {
|
||||
warning: row.warning,
|
||||
critical: row.critical,
|
||||
danger: row.danger
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
54
backend/api/settings/thresholds.put.ts
Normal file
54
backend/api/settings/thresholds.put.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { getDb } from '../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
|
||||
if (!body || typeof body !== 'object') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Invalid request body'
|
||||
})
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const now = new Date().toLocaleString('sv-SE', { timeZone: 'Asia/Seoul' }).replace('T', ' ')
|
||||
|
||||
const stmt = db.prepare(`
|
||||
UPDATE thresholds
|
||||
SET warning = ?, critical = ?, danger = ?, updated_at = ?
|
||||
WHERE category = ? AND metric = ?
|
||||
`)
|
||||
|
||||
let updated = 0
|
||||
|
||||
for (const [category, metrics] of Object.entries(body)) {
|
||||
if (typeof metrics !== 'object') continue
|
||||
|
||||
for (const [metric, values] of Object.entries(metrics as Record<string, any>)) {
|
||||
if (!values || typeof values !== 'object') continue
|
||||
|
||||
const { warning, critical, danger } = values
|
||||
|
||||
// 유효성 검사
|
||||
if (typeof warning !== 'number' || typeof critical !== 'number' || typeof danger !== 'number') {
|
||||
continue
|
||||
}
|
||||
|
||||
if (warning < 0 || warning > 100 || critical < 0 || critical > 100 || danger < 0 || danger > 100) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (warning >= critical || critical >= danger) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: `Invalid thresholds for ${category}.${metric}: warning < critical < danger 순서여야 합니다.`
|
||||
})
|
||||
}
|
||||
|
||||
const result = stmt.run(warning, critical, danger, now, category, metric)
|
||||
if (result.changes > 0) updated++
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, updated }
|
||||
})
|
||||
20
backend/plugins/privnet-init.ts
Normal file
20
backend/plugins/privnet-init.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { initPrivnetTables } from '../utils/db'
|
||||
import { privnetScheduler } from '../utils/privnet-scheduler'
|
||||
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
console.log('[Plugin] privnet-init starting...')
|
||||
|
||||
// DB 테이블 초기화
|
||||
initPrivnetTables()
|
||||
|
||||
// 스케줄러 자동 시작
|
||||
privnetScheduler.start()
|
||||
|
||||
// 서버 종료 시 클린업
|
||||
nitroApp.hooks.hook('close', () => {
|
||||
console.log('[Plugin] Shutting down privnet scheduler...')
|
||||
privnetScheduler.stop()
|
||||
})
|
||||
|
||||
console.log('[Plugin] privnet-init completed')
|
||||
})
|
||||
20
backend/plugins/pubnet-init.ts
Normal file
20
backend/plugins/pubnet-init.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { initPubnetTables } from '../utils/db'
|
||||
import { pubnetScheduler } from '../utils/pubnet-scheduler'
|
||||
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
console.log('[Plugin] pubnet-init starting...')
|
||||
|
||||
// DB 테이블 초기화
|
||||
initPubnetTables()
|
||||
|
||||
// 스케줄러 자동 시작
|
||||
pubnetScheduler.start()
|
||||
|
||||
// 서버 종료 시 클린업
|
||||
nitroApp.hooks.hook('close', () => {
|
||||
console.log('[Plugin] Shutting down pubnet scheduler...')
|
||||
pubnetScheduler.stop()
|
||||
})
|
||||
|
||||
console.log('[Plugin] pubnet-init completed')
|
||||
})
|
||||
8
backend/plugins/server-init.ts
Normal file
8
backend/plugins/server-init.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { startServerScheduler } from '../utils/server-scheduler'
|
||||
|
||||
export default defineNitroPlugin(() => {
|
||||
// 서버 시작 시 스케줄러 자동 시작
|
||||
startServerScheduler()
|
||||
|
||||
console.log('[Server] Plugin initialized - scheduler auto-started')
|
||||
})
|
||||
411
backend/routes/_ws.ts
Normal file
411
backend/routes/_ws.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
import { getDb } from '../utils/db'
|
||||
|
||||
interface Client {
|
||||
ws: any
|
||||
interval: number
|
||||
timer: ReturnType<typeof setInterval> | null
|
||||
autoRefresh: boolean
|
||||
}
|
||||
|
||||
const clients = new Map<any, Client>()
|
||||
|
||||
function getNetworkStatus() {
|
||||
const db = getDb()
|
||||
|
||||
// pubnet 상태
|
||||
const pubnetStatus = db.prepare(`
|
||||
SELECT
|
||||
ps.*,
|
||||
pt.name as last_target_name,
|
||||
pt.url as last_target_url
|
||||
FROM pubnet_status ps
|
||||
LEFT JOIN pubnet_targets pt ON ps.last_target_id = pt.id
|
||||
WHERE ps.id = 1
|
||||
`).get()
|
||||
|
||||
const pubnetLogs = db.prepare(`
|
||||
SELECT
|
||||
pl.*,
|
||||
pt.name as target_name,
|
||||
pt.url as target_url
|
||||
FROM pubnet_logs pl
|
||||
JOIN pubnet_targets pt ON pl.target_id = pt.id
|
||||
ORDER BY pl.checked_at DESC
|
||||
LIMIT 5
|
||||
`).all()
|
||||
|
||||
// privnet 상태
|
||||
const privnetStatus = db.prepare(`
|
||||
SELECT
|
||||
ps.*,
|
||||
pt.name as last_target_name,
|
||||
pt.url as last_target_url
|
||||
FROM privnet_status ps
|
||||
LEFT JOIN privnet_targets pt ON ps.last_target_id = pt.id
|
||||
WHERE ps.id = 1
|
||||
`).get()
|
||||
|
||||
const privnetLogs = db.prepare(`
|
||||
SELECT
|
||||
pl.*,
|
||||
pt.name as target_name,
|
||||
pt.url as target_url
|
||||
FROM privnet_logs pl
|
||||
JOIN privnet_targets pt ON pl.target_id = pt.id
|
||||
ORDER BY pl.checked_at DESC
|
||||
LIMIT 5
|
||||
`).all()
|
||||
|
||||
return {
|
||||
pubnet: {
|
||||
status: pubnetStatus,
|
||||
logs: pubnetLogs
|
||||
},
|
||||
privnet: {
|
||||
status: privnetStatus,
|
||||
logs: privnetLogs
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
function getHistoricalData(datetime: string) {
|
||||
const db = getDb()
|
||||
|
||||
// 특정 시간 이전의 로그 조회
|
||||
const pubnetLogs = db.prepare(`
|
||||
SELECT
|
||||
pl.*,
|
||||
pt.name as target_name,
|
||||
pt.url as target_url
|
||||
FROM pubnet_logs pl
|
||||
JOIN pubnet_targets pt ON pl.target_id = pt.id
|
||||
WHERE pl.checked_at <= @datetime
|
||||
ORDER BY pl.checked_at DESC
|
||||
LIMIT 5
|
||||
`).all({ datetime })
|
||||
|
||||
const privnetLogs = db.prepare(`
|
||||
SELECT
|
||||
pl.*,
|
||||
pt.name as target_name,
|
||||
pt.url as target_url
|
||||
FROM privnet_logs pl
|
||||
JOIN privnet_targets pt ON pl.target_id = pt.id
|
||||
WHERE pl.checked_at <= @datetime
|
||||
ORDER BY pl.checked_at DESC
|
||||
LIMIT 5
|
||||
`).all({ datetime })
|
||||
|
||||
// 해당 시점의 최신 상태 (로그 기준)
|
||||
const pubnetLatest = pubnetLogs[0] || null
|
||||
const privnetLatest = privnetLogs[0] || null
|
||||
|
||||
return {
|
||||
pubnet: {
|
||||
status: pubnetLatest ? {
|
||||
is_healthy: pubnetLatest.is_success,
|
||||
last_checked_at: pubnetLatest.checked_at,
|
||||
last_target_name: pubnetLatest.target_name
|
||||
} : null,
|
||||
logs: pubnetLogs
|
||||
},
|
||||
privnet: {
|
||||
status: privnetLatest ? {
|
||||
is_healthy: privnetLatest.is_success,
|
||||
last_checked_at: privnetLatest.checked_at,
|
||||
last_target_name: privnetLatest.target_name
|
||||
} : null,
|
||||
logs: privnetLogs
|
||||
},
|
||||
timestamp: datetime
|
||||
}
|
||||
}
|
||||
|
||||
// 임계값 조회
|
||||
function getThresholds() {
|
||||
const db = getDb()
|
||||
const rows = db.prepare(`SELECT category, metric, warning, critical, danger FROM thresholds`).all() as any[]
|
||||
|
||||
const result: Record<string, Record<string, { warning: number; critical: number; danger: number }>> = {}
|
||||
for (const row of rows) {
|
||||
if (!result[row.category]) result[row.category] = {}
|
||||
result[row.category][row.metric] = { warning: row.warning, critical: row.critical, danger: row.danger }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// 레벨 계산
|
||||
function getLevel(value: number | null, threshold: { warning: number; critical: number; danger: number }): string {
|
||||
if (value === null || value === undefined) return 'normal'
|
||||
if (value >= threshold.danger) return 'danger'
|
||||
if (value >= threshold.critical) return 'critical'
|
||||
if (value >= threshold.warning) return 'warning'
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
// 레벨 우선순위
|
||||
const levelPriority: Record<string, number> = { normal: 0, warning: 1, critical: 2, danger: 3, offline: 4, stopped: 3 }
|
||||
|
||||
function getHighestLevel(levels: string[]): string {
|
||||
let highest = 'normal'
|
||||
for (const level of levels) {
|
||||
if ((levelPriority[level] || 0) > (levelPriority[highest] || 0)) {
|
||||
highest = level
|
||||
}
|
||||
}
|
||||
return highest
|
||||
}
|
||||
|
||||
// 서버 대시보드 데이터
|
||||
function getServerDashboard() {
|
||||
const db = getDb()
|
||||
const thresholds = getThresholds()
|
||||
const now = new Date()
|
||||
const offlineThreshold = 5 * 60 * 1000 // 5분
|
||||
|
||||
// 서버 목록
|
||||
const servers = db.prepare(`SELECT target_id, server_name, is_active FROM server_targets WHERE is_active = 1 ORDER BY server_name`).all() as any[]
|
||||
|
||||
const serverStatuses: any[] = []
|
||||
const summaryServers = { total: servers.length, normal: 0, warning: 0, critical: 0, danger: 0, offline: 0 }
|
||||
const summaryContainers = { total: 0, normal: 0, warning: 0, critical: 0, danger: 0, stopped: 0 }
|
||||
|
||||
for (const server of servers) {
|
||||
// 최신 스냅샷
|
||||
const snapshot = db.prepare(`
|
||||
SELECT cpu_percent, memory_percent, collected_at
|
||||
FROM server_snapshots
|
||||
WHERE target_id = ?
|
||||
ORDER BY collected_at DESC
|
||||
LIMIT 1
|
||||
`).get(server.target_id) as any
|
||||
|
||||
// 디스크 정보 조회 (루트 마운트 또는 최대 사용률)
|
||||
const disk = db.prepare(`
|
||||
SELECT disk_percent
|
||||
FROM server_disks
|
||||
WHERE target_id = ?
|
||||
ORDER BY
|
||||
CASE WHEN mount_point = '/' THEN 0 ELSE 1 END,
|
||||
disk_percent DESC
|
||||
LIMIT 1
|
||||
`).get(server.target_id) as any
|
||||
|
||||
// 오프라인 체크
|
||||
let isOffline = true
|
||||
let lastCollected = null
|
||||
if (snapshot && snapshot.collected_at) {
|
||||
lastCollected = snapshot.collected_at
|
||||
const collectedTime = new Date(snapshot.collected_at.replace(' ', 'T') + '+09:00').getTime()
|
||||
isOffline = (now.getTime() - collectedTime) > offlineThreshold
|
||||
}
|
||||
|
||||
// 서버 레벨 계산
|
||||
let serverLevel = 'offline'
|
||||
let cpuLevel = 'normal', memLevel = 'normal', diskLevel = 'normal'
|
||||
|
||||
if (!isOffline && snapshot) {
|
||||
cpuLevel = getLevel(snapshot.cpu_percent, thresholds.server?.cpu || { warning: 70, critical: 85, danger: 95 })
|
||||
memLevel = getLevel(snapshot.memory_percent, thresholds.server?.memory || { warning: 80, critical: 90, danger: 95 })
|
||||
diskLevel = getLevel(disk?.disk_percent ?? null, thresholds.server?.disk || { warning: 80, critical: 90, danger: 95 })
|
||||
serverLevel = getHighestLevel([cpuLevel, memLevel, diskLevel])
|
||||
}
|
||||
|
||||
// 컨테이너 조회 (최신 데이터, 중복 제거)
|
||||
const containers = db.prepare(`
|
||||
SELECT container_name, container_status, cpu_percent, memory_usage, memory_limit, uptime, network_rx, network_tx
|
||||
FROM server_containers
|
||||
WHERE target_id = ? AND collected_at = (
|
||||
SELECT MAX(collected_at) FROM server_containers WHERE target_id = ?
|
||||
)
|
||||
GROUP BY container_name
|
||||
ORDER BY container_name
|
||||
`).all(server.target_id, server.target_id) as any[]
|
||||
|
||||
const containerStatuses: any[] = []
|
||||
const containerSummary = { total: containers.length, normal: 0, warning: 0, critical: 0, danger: 0, stopped: 0 }
|
||||
|
||||
for (const c of containers) {
|
||||
let containerLevel = 'normal'
|
||||
|
||||
if (c.container_status !== 'running') {
|
||||
containerLevel = 'stopped'
|
||||
containerSummary.stopped++
|
||||
} else {
|
||||
const cCpuLevel = getLevel(c.cpu_percent, thresholds.container?.cpu || { warning: 80, critical: 90, danger: 95 })
|
||||
const cMemPercent = c.memory_limit ? (c.memory_usage / c.memory_limit * 100) : null
|
||||
const cMemLevel = getLevel(cMemPercent, thresholds.container?.memory || { warning: 80, critical: 90, danger: 95 })
|
||||
containerLevel = getHighestLevel([cCpuLevel, cMemLevel])
|
||||
|
||||
if (containerLevel === 'normal') containerSummary.normal++
|
||||
else if (containerLevel === 'warning') containerSummary.warning++
|
||||
else if (containerLevel === 'critical') containerSummary.critical++
|
||||
else if (containerLevel === 'danger') containerSummary.danger++
|
||||
}
|
||||
|
||||
containerStatuses.push({
|
||||
name: c.container_name,
|
||||
status: c.container_status,
|
||||
level: containerLevel,
|
||||
cpu_percent: c.cpu_percent,
|
||||
memory_usage: c.memory_usage,
|
||||
memory_limit: c.memory_limit,
|
||||
uptime: c.uptime,
|
||||
network_rx: c.network_rx,
|
||||
network_tx: c.network_tx
|
||||
})
|
||||
}
|
||||
|
||||
// 요약 집계
|
||||
summaryContainers.total += containerSummary.total
|
||||
summaryContainers.normal += containerSummary.normal
|
||||
summaryContainers.warning += containerSummary.warning
|
||||
summaryContainers.critical += containerSummary.critical
|
||||
summaryContainers.danger += containerSummary.danger
|
||||
summaryContainers.stopped += containerSummary.stopped
|
||||
|
||||
if (serverLevel === 'offline') summaryServers.offline++
|
||||
else if (serverLevel === 'danger') summaryServers.danger++
|
||||
else if (serverLevel === 'critical') summaryServers.critical++
|
||||
else if (serverLevel === 'warning') summaryServers.warning++
|
||||
else summaryServers.normal++
|
||||
|
||||
serverStatuses.push({
|
||||
target_id: server.target_id,
|
||||
server_name: server.server_name,
|
||||
level: serverLevel,
|
||||
cpu_percent: snapshot?.cpu_percent ?? null,
|
||||
cpu_level: cpuLevel,
|
||||
memory_percent: snapshot?.memory_percent ?? null,
|
||||
memory_level: memLevel,
|
||||
disk_percent: disk?.disk_percent ?? null,
|
||||
disk_level: diskLevel,
|
||||
last_collected: lastCollected,
|
||||
containers: containerStatuses,
|
||||
container_summary: containerSummary
|
||||
})
|
||||
}
|
||||
|
||||
// 서버 정렬: 컨테이너 많은 순 → 이름 순
|
||||
serverStatuses.sort((a, b) => {
|
||||
const containerDiff = (b.container_summary?.total || 0) - (a.container_summary?.total || 0)
|
||||
if (containerDiff !== 0) return containerDiff
|
||||
return a.server_name.localeCompare(b.server_name)
|
||||
})
|
||||
|
||||
return {
|
||||
summary: { servers: summaryServers, containers: summaryContainers },
|
||||
servers: serverStatuses,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
function startAutoRefresh(client: Client) {
|
||||
if (client.timer) {
|
||||
clearInterval(client.timer)
|
||||
}
|
||||
|
||||
if (client.autoRefresh) {
|
||||
client.timer = setInterval(() => {
|
||||
const data = getNetworkStatus()
|
||||
client.ws.send(JSON.stringify({ type: 'status', data }))
|
||||
|
||||
// 서버 대시보드 데이터도 전송
|
||||
const serverData = getServerDashboard()
|
||||
client.ws.send(JSON.stringify({ type: 'server', data: serverData }))
|
||||
}, client.interval)
|
||||
}
|
||||
}
|
||||
|
||||
export default defineWebSocketHandler({
|
||||
open(peer) {
|
||||
console.log('[WebSocket] Client connected')
|
||||
|
||||
const client: Client = {
|
||||
ws: peer,
|
||||
interval: 60 * 1000, // 기본 1분
|
||||
timer: null,
|
||||
autoRefresh: true
|
||||
}
|
||||
|
||||
clients.set(peer, client)
|
||||
|
||||
// 초기 데이터 전송
|
||||
const data = getNetworkStatus()
|
||||
peer.send(JSON.stringify({ type: 'status', data }))
|
||||
|
||||
// 서버 대시보드 데이터 전송
|
||||
const serverData = getServerDashboard()
|
||||
peer.send(JSON.stringify({ type: 'server', data: serverData }))
|
||||
|
||||
// 자동 갱신 시작
|
||||
startAutoRefresh(client)
|
||||
},
|
||||
|
||||
message(peer, message) {
|
||||
const client = clients.get(peer)
|
||||
if (!client) return
|
||||
|
||||
try {
|
||||
const msg = JSON.parse(message.text())
|
||||
|
||||
switch (msg.type) {
|
||||
case 'set_interval':
|
||||
// 간격 변경 (분 단위로 받음)
|
||||
client.interval = msg.interval * 60 * 1000
|
||||
console.log(`[WebSocket] Interval changed to ${msg.interval} min`)
|
||||
startAutoRefresh(client)
|
||||
break
|
||||
|
||||
case 'set_auto_refresh':
|
||||
// 자동 갱신 ON/OFF
|
||||
client.autoRefresh = msg.enabled
|
||||
console.log(`[WebSocket] Auto refresh: ${msg.enabled}`)
|
||||
if (msg.enabled) {
|
||||
startAutoRefresh(client)
|
||||
} else if (client.timer) {
|
||||
clearInterval(client.timer)
|
||||
client.timer = null
|
||||
}
|
||||
break
|
||||
|
||||
case 'fetch_at':
|
||||
// 특정 시간 데이터 조회
|
||||
const historicalData = getHistoricalData(msg.datetime)
|
||||
peer.send(JSON.stringify({ type: 'historical', data: historicalData }))
|
||||
break
|
||||
|
||||
case 'refresh':
|
||||
// 즉시 갱신 요청
|
||||
const currentData = getNetworkStatus()
|
||||
peer.send(JSON.stringify({ type: 'status', data: currentData }))
|
||||
// 서버 데이터도 전송
|
||||
const currentServerData = getServerDashboard()
|
||||
peer.send(JSON.stringify({ type: 'server', data: currentServerData }))
|
||||
break
|
||||
|
||||
case 'refresh_server':
|
||||
// 서버 대시보드만 즉시 갱신
|
||||
const serverDashData = getServerDashboard()
|
||||
peer.send(JSON.stringify({ type: 'server', data: serverDashData }))
|
||||
break
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WebSocket] Message parse error:', err)
|
||||
}
|
||||
},
|
||||
|
||||
close(peer) {
|
||||
console.log('[WebSocket] Client disconnected')
|
||||
const client = clients.get(peer)
|
||||
if (client?.timer) {
|
||||
clearInterval(client.timer)
|
||||
}
|
||||
clients.delete(peer)
|
||||
},
|
||||
|
||||
error(peer, error) {
|
||||
console.error('[WebSocket] Error:', error)
|
||||
}
|
||||
})
|
||||
113
backend/utils/db.ts
Normal file
113
backend/utils/db.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import { resolve } from 'path'
|
||||
|
||||
// 싱글톤 DB 인스턴스
|
||||
let db: Database.Database | null = null
|
||||
|
||||
export function getDb(): Database.Database {
|
||||
if (!db) {
|
||||
const dbPath = resolve(process.cwd(), 'database/osolit-monitor.db')
|
||||
db = new Database(dbPath)
|
||||
db.pragma('journal_mode = WAL')
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
export function initPubnetTables(): void {
|
||||
const db = getDb()
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS pubnet_targets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now', 'localtime')),
|
||||
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||
)
|
||||
`)
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS pubnet_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
target_id INTEGER NOT NULL,
|
||||
is_success INTEGER NOT NULL,
|
||||
checked_at TEXT DEFAULT (datetime('now', 'localtime')),
|
||||
FOREIGN KEY (target_id) REFERENCES pubnet_targets(id)
|
||||
)
|
||||
`)
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS pubnet_status (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
current_index INTEGER DEFAULT 0,
|
||||
check_interval INTEGER DEFAULT 300000,
|
||||
is_healthy INTEGER DEFAULT 1,
|
||||
last_target_id INTEGER,
|
||||
last_checked_at TEXT,
|
||||
scheduler_running INTEGER DEFAULT 0,
|
||||
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||
)
|
||||
`)
|
||||
|
||||
const statusExists = db.prepare('SELECT COUNT(*) as cnt FROM pubnet_status').get() as { cnt: number }
|
||||
if (statusExists.cnt === 0) {
|
||||
db.prepare('INSERT INTO pubnet_status (id) VALUES (1)').run()
|
||||
}
|
||||
|
||||
console.log('[DB] pubnet tables initialized')
|
||||
}
|
||||
|
||||
export function initPrivnetTables(): void {
|
||||
const db = getDb()
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS privnet_targets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now', 'localtime')),
|
||||
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||
)
|
||||
`)
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS privnet_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
target_id INTEGER NOT NULL,
|
||||
is_success INTEGER NOT NULL,
|
||||
checked_at TEXT DEFAULT (datetime('now', 'localtime')),
|
||||
FOREIGN KEY (target_id) REFERENCES privnet_targets(id)
|
||||
)
|
||||
`)
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS privnet_status (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
current_index INTEGER DEFAULT 0,
|
||||
check_interval INTEGER DEFAULT 300000,
|
||||
is_healthy INTEGER DEFAULT 1,
|
||||
last_target_id INTEGER,
|
||||
last_checked_at TEXT,
|
||||
scheduler_running INTEGER DEFAULT 0,
|
||||
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||
)
|
||||
`)
|
||||
|
||||
const statusExists = db.prepare('SELECT COUNT(*) as cnt FROM privnet_status').get() as { cnt: number }
|
||||
if (statusExists.cnt === 0) {
|
||||
db.prepare('INSERT INTO privnet_status (id) VALUES (1)').run()
|
||||
}
|
||||
|
||||
console.log('[DB] privnet tables initialized')
|
||||
}
|
||||
|
||||
export function closeDb(): void {
|
||||
if (db) {
|
||||
db.close()
|
||||
db = null
|
||||
}
|
||||
}
|
||||
221
backend/utils/privnet-scheduler.ts
Normal file
221
backend/utils/privnet-scheduler.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { getDb } from './db'
|
||||
|
||||
// 상수 정의
|
||||
const INTERVAL_SUCCESS = 5 * 60 * 1000 // 5분
|
||||
const INTERVAL_FAILURE = 1 * 60 * 1000 // 1분
|
||||
const REQUEST_TIMEOUT = 10 * 1000 // 10초
|
||||
|
||||
// 타입 정의
|
||||
interface PrivnetTarget {
|
||||
id: number
|
||||
name: string
|
||||
url: string
|
||||
is_active: number
|
||||
}
|
||||
|
||||
interface PrivnetStatus {
|
||||
id: number
|
||||
current_index: number
|
||||
check_interval: number
|
||||
is_healthy: number
|
||||
last_target_id: number | null
|
||||
last_checked_at: string | null
|
||||
scheduler_running: number
|
||||
}
|
||||
|
||||
interface CheckResult {
|
||||
targetId: number
|
||||
targetName: string
|
||||
url: string
|
||||
isSuccess: boolean
|
||||
}
|
||||
|
||||
class PrivnetScheduler {
|
||||
private timer: ReturnType<typeof setTimeout> | null = null
|
||||
private isRunning: boolean = false
|
||||
|
||||
/**
|
||||
* 스케줄러 시작
|
||||
*/
|
||||
start(): void {
|
||||
if (this.isRunning) {
|
||||
console.log('[PrivnetScheduler] Already running')
|
||||
return
|
||||
}
|
||||
|
||||
this.isRunning = true
|
||||
this.updateSchedulerRunning(1)
|
||||
console.log('[PrivnetScheduler] Started')
|
||||
|
||||
// 즉시 첫 체크 실행
|
||||
this.runCheck()
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄러 중지
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
this.isRunning = false
|
||||
this.updateSchedulerRunning(0)
|
||||
console.log('[PrivnetScheduler] Stopped')
|
||||
}
|
||||
|
||||
/**
|
||||
* 실행 상태 확인
|
||||
*/
|
||||
getIsRunning(): boolean {
|
||||
return this.isRunning
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크 실행
|
||||
*/
|
||||
private async runCheck(): Promise<void> {
|
||||
if (!this.isRunning) return
|
||||
|
||||
try {
|
||||
const result = await this.checkCurrentTarget()
|
||||
|
||||
// 결과에 따라 다음 간격 결정
|
||||
const nextInterval = result.isSuccess ? INTERVAL_SUCCESS : INTERVAL_FAILURE
|
||||
|
||||
// 로그 저장
|
||||
this.saveLog(result)
|
||||
|
||||
// 상태 업데이트 (인덱스 증가)
|
||||
this.updateStatus(result, nextInterval)
|
||||
|
||||
console.log(
|
||||
`[PrivnetScheduler] ${result.targetName} (${result.url}) - ` +
|
||||
`${result.isSuccess ? 'SUCCESS' : 'FAILED'} - ` +
|
||||
`Next check in ${nextInterval / 1000}s`
|
||||
)
|
||||
|
||||
// 다음 체크 예약
|
||||
this.timer = setTimeout(() => this.runCheck(), nextInterval)
|
||||
|
||||
} catch (error) {
|
||||
console.error('[PrivnetScheduler] Error:', error)
|
||||
// 에러 발생 시 1분 후 재시도
|
||||
this.timer = setTimeout(() => this.runCheck(), INTERVAL_FAILURE)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 타겟 URL 체크
|
||||
*/
|
||||
private async checkCurrentTarget(): Promise<CheckResult> {
|
||||
const db = getDb()
|
||||
|
||||
// 활성화된 타겟 목록 조회
|
||||
const targets = db.prepare(`
|
||||
SELECT * FROM privnet_targets
|
||||
WHERE is_active = 1
|
||||
ORDER BY id ASC
|
||||
`).all() as PrivnetTarget[]
|
||||
|
||||
if (targets.length === 0) {
|
||||
throw new Error('No active targets found')
|
||||
}
|
||||
|
||||
// 현재 인덱스 조회
|
||||
const status = db.prepare('SELECT * FROM privnet_status WHERE id = 1').get() as PrivnetStatus
|
||||
const currentIndex = status.current_index % targets.length
|
||||
const target = targets[currentIndex]
|
||||
|
||||
// HTTP 요청 실행
|
||||
let isSuccess = false
|
||||
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT)
|
||||
|
||||
const response = await fetch(target.url, {
|
||||
method: 'HEAD',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent': 'OSOLIT-Monitor/1.0'
|
||||
}
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
isSuccess = response.status === 200
|
||||
|
||||
} catch (err: any) {
|
||||
isSuccess = false
|
||||
}
|
||||
|
||||
return {
|
||||
targetId: target.id,
|
||||
targetName: target.name,
|
||||
url: target.url,
|
||||
isSuccess
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크 결과 로그 저장
|
||||
*/
|
||||
private saveLog(result: CheckResult): void {
|
||||
const db = getDb()
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO privnet_logs (target_id, is_success)
|
||||
VALUES (@targetId, @isSuccess)
|
||||
`).run({
|
||||
targetId: result.targetId,
|
||||
isSuccess: result.isSuccess ? 1 : 0
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 업데이트 (인덱스 순환)
|
||||
*/
|
||||
private updateStatus(result: CheckResult, nextInterval: number): void {
|
||||
const db = getDb()
|
||||
|
||||
// 활성 타겟 수 조회
|
||||
const countResult = db.prepare(`
|
||||
SELECT COUNT(*) as cnt FROM privnet_targets WHERE is_active = 1
|
||||
`).get() as { cnt: number }
|
||||
|
||||
const status = db.prepare('SELECT current_index FROM privnet_status WHERE id = 1').get() as { current_index: number }
|
||||
const nextIndex = (status.current_index + 1) % countResult.cnt
|
||||
|
||||
db.prepare(`
|
||||
UPDATE privnet_status SET
|
||||
current_index = @nextIndex,
|
||||
check_interval = @checkInterval,
|
||||
is_healthy = @isHealthy,
|
||||
last_target_id = @lastTargetId,
|
||||
last_checked_at = datetime('now', 'localtime'),
|
||||
updated_at = datetime('now', 'localtime')
|
||||
WHERE id = 1
|
||||
`).run({
|
||||
nextIndex,
|
||||
checkInterval: nextInterval,
|
||||
isHealthy: result.isSuccess ? 1 : 0,
|
||||
lastTargetId: result.targetId
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄러 실행 상태 업데이트
|
||||
*/
|
||||
private updateSchedulerRunning(running: number): void {
|
||||
const db = getDb()
|
||||
db.prepare(`
|
||||
UPDATE privnet_status SET
|
||||
scheduler_running = @running,
|
||||
updated_at = datetime('now', 'localtime')
|
||||
WHERE id = 1
|
||||
`).run({ running })
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스
|
||||
export const privnetScheduler = new PrivnetScheduler()
|
||||
223
backend/utils/pubnet-scheduler.ts
Normal file
223
backend/utils/pubnet-scheduler.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { getDb } from './db'
|
||||
|
||||
// 상수 정의
|
||||
const INTERVAL_SUCCESS = 5 * 60 * 1000 // 5분
|
||||
const INTERVAL_FAILURE = 1 * 60 * 1000 // 1분
|
||||
const REQUEST_TIMEOUT = 10 * 1000 // 10초
|
||||
|
||||
// 타입 정의
|
||||
interface PubnetTarget {
|
||||
id: number
|
||||
name: string
|
||||
url: string
|
||||
is_active: number
|
||||
}
|
||||
|
||||
interface PubnetStatus {
|
||||
id: number
|
||||
current_index: number
|
||||
check_interval: number
|
||||
is_healthy: number
|
||||
last_target_id: number | null
|
||||
last_checked_at: string | null
|
||||
scheduler_running: number
|
||||
}
|
||||
|
||||
interface CheckResult {
|
||||
targetId: number
|
||||
targetName: string
|
||||
url: string
|
||||
isSuccess: boolean
|
||||
}
|
||||
|
||||
class PubnetScheduler {
|
||||
private timer: ReturnType<typeof setTimeout> | null = null
|
||||
private isRunning: boolean = false
|
||||
|
||||
/**
|
||||
* 스케줄러 시작
|
||||
*/
|
||||
start(): void {
|
||||
if (this.isRunning) {
|
||||
console.log('[PubnetScheduler] Already running')
|
||||
return
|
||||
}
|
||||
|
||||
this.isRunning = true
|
||||
this.updateSchedulerRunning(1)
|
||||
console.log('[PubnetScheduler] Started')
|
||||
|
||||
// 즉시 첫 체크 실행
|
||||
this.runCheck()
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄러 중지
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
this.isRunning = false
|
||||
this.updateSchedulerRunning(0)
|
||||
console.log('[PubnetScheduler] Stopped')
|
||||
}
|
||||
|
||||
/**
|
||||
* 실행 상태 확인
|
||||
*/
|
||||
getIsRunning(): boolean {
|
||||
return this.isRunning
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크 실행
|
||||
*/
|
||||
private async runCheck(): Promise<void> {
|
||||
if (!this.isRunning) return
|
||||
|
||||
try {
|
||||
const result = await this.checkCurrentTargets()
|
||||
|
||||
// 결과에 따라 다음 간격 결정
|
||||
const nextInterval = result.isSuccess ? INTERVAL_SUCCESS : INTERVAL_FAILURE
|
||||
|
||||
// 로그 저장 (1개만)
|
||||
this.saveLog(result)
|
||||
|
||||
// 상태 업데이트 (인덱스 +1)
|
||||
this.updateStatus(result, nextInterval)
|
||||
|
||||
console.log(
|
||||
`[PubnetScheduler] ${result.targetName} (${result.url}) - ` +
|
||||
`${result.isSuccess ? 'SUCCESS' : 'FAILED'} - ` +
|
||||
`Next check in ${nextInterval / 1000}s`
|
||||
)
|
||||
|
||||
// 다음 체크 예약
|
||||
this.timer = setTimeout(() => this.runCheck(), nextInterval)
|
||||
|
||||
} catch (error) {
|
||||
console.error('[PubnetScheduler] Error:', error)
|
||||
// 에러 발생 시 1분 후 재시도
|
||||
this.timer = setTimeout(() => this.runCheck(), INTERVAL_FAILURE)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 타겟 1개 체크
|
||||
*/
|
||||
private async checkCurrentTargets(): Promise<CheckResult> {
|
||||
const db = getDb()
|
||||
|
||||
// 활성화된 타겟 목록 조회
|
||||
const targets = db.prepare(`
|
||||
SELECT * FROM pubnet_targets
|
||||
WHERE is_active = 1
|
||||
ORDER BY id ASC
|
||||
`).all() as PubnetTarget[]
|
||||
|
||||
if (targets.length === 0) {
|
||||
throw new Error('No active targets found')
|
||||
}
|
||||
|
||||
// 현재 인덱스 조회
|
||||
const status = db.prepare('SELECT * FROM pubnet_status WHERE id = 1').get() as PubnetStatus
|
||||
|
||||
// 1개 타겟 선택
|
||||
const idx = status.current_index % targets.length
|
||||
const target = targets[idx]
|
||||
|
||||
// 단일 타겟 체크
|
||||
let isSuccess = false
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT)
|
||||
|
||||
const response = await fetch(target.url, {
|
||||
method: 'HEAD',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent': 'OSOLIT-Monitor/1.0'
|
||||
}
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
isSuccess = response.status === 200
|
||||
} catch (err: any) {
|
||||
isSuccess = false
|
||||
}
|
||||
|
||||
return {
|
||||
targetId: target.id,
|
||||
targetName: target.name,
|
||||
url: target.url,
|
||||
isSuccess
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크 결과 로그 저장
|
||||
*/
|
||||
private saveLog(result: CheckResult): void {
|
||||
const db = getDb()
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO pubnet_logs (target_id, is_success)
|
||||
VALUES (@targetId, @isSuccess)
|
||||
`).run({
|
||||
targetId: result.targetId,
|
||||
isSuccess: result.isSuccess ? 1 : 0
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 업데이트 (인덱스 +1 순환)
|
||||
*/
|
||||
private updateStatus(result: CheckResult, nextInterval: number): void {
|
||||
const db = getDb()
|
||||
|
||||
// 활성 타겟 수 조회
|
||||
const countResult = db.prepare(`
|
||||
SELECT COUNT(*) as cnt FROM pubnet_targets WHERE is_active = 1
|
||||
`).get() as { cnt: number }
|
||||
|
||||
const status = db.prepare('SELECT current_index FROM pubnet_status WHERE id = 1').get() as { current_index: number }
|
||||
|
||||
// 인덱스 +1 (순환)
|
||||
const nextIndex = (status.current_index + 1) % countResult.cnt
|
||||
|
||||
db.prepare(`
|
||||
UPDATE pubnet_status SET
|
||||
current_index = @nextIndex,
|
||||
check_interval = @checkInterval,
|
||||
is_healthy = @isHealthy,
|
||||
last_target_id = @lastTargetId,
|
||||
last_checked_at = datetime('now', 'localtime'),
|
||||
updated_at = datetime('now', 'localtime')
|
||||
WHERE id = 1
|
||||
`).run({
|
||||
nextIndex,
|
||||
checkInterval: nextInterval,
|
||||
isHealthy: result.isSuccess ? 1 : 0,
|
||||
lastTargetId: result.targetId
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄러 실행 상태 업데이트
|
||||
*/
|
||||
private updateSchedulerRunning(running: number): void {
|
||||
const db = getDb()
|
||||
db.prepare(`
|
||||
UPDATE pubnet_status SET
|
||||
scheduler_running = @running,
|
||||
updated_at = datetime('now', 'localtime')
|
||||
WHERE id = 1
|
||||
`).run({ running })
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스
|
||||
export const pubnetScheduler = new PubnetScheduler()
|
||||
672
backend/utils/server-scheduler.ts
Normal file
672
backend/utils/server-scheduler.ts
Normal file
@@ -0,0 +1,672 @@
|
||||
import { getDb } from './db'
|
||||
|
||||
interface ServerTarget {
|
||||
target_id: number
|
||||
server_name: string
|
||||
server_ip: string
|
||||
glances_url: string
|
||||
is_active: number
|
||||
collect_interval: number
|
||||
}
|
||||
|
||||
// 서버별 타이머 관리
|
||||
const serverTimers = new Map<number, ReturnType<typeof setInterval>>()
|
||||
// 서버별 API 버전 캐시
|
||||
const apiVersionCache = new Map<number, string>()
|
||||
let isRunning = false
|
||||
|
||||
// 타임스탬프 생성
|
||||
function timestamp(): string {
|
||||
return new Date().toLocaleString('sv-SE', { timeZone: 'Asia/Seoul' }).replace('T', ' ')
|
||||
}
|
||||
|
||||
// Glances API 호출 (버전 지정)
|
||||
async function fetchGlancesApi(baseUrl: string, endpoint: string, version: string): Promise<any> {
|
||||
try {
|
||||
const url = `${baseUrl}/api/${version}/${endpoint}`
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(5000)
|
||||
})
|
||||
if (!response.ok) return null
|
||||
return await response.json()
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// API 버전 자동 감지 (v4 우선, 실패 시 v3)
|
||||
async function detectApiVersion(baseUrl: string, serverName: string): Promise<string | null> {
|
||||
const now = timestamp()
|
||||
|
||||
// v4 먼저 시도
|
||||
console.log(`[${now}] 🔍 [${serverName}] API 버전 감지 중... (v4 시도)`)
|
||||
const v4Result = await fetchGlancesApi(baseUrl, 'system', '4')
|
||||
if (v4Result && v4Result.os_name) {
|
||||
console.log(`[${now}] ✅ [${serverName}] API v4 감지됨`)
|
||||
return '4'
|
||||
}
|
||||
|
||||
// v3 시도
|
||||
console.log(`[${now}] 🔍 [${serverName}] API 버전 감지 중... (v3 시도)`)
|
||||
const v3Result = await fetchGlancesApi(baseUrl, 'system', '3')
|
||||
if (v3Result && v3Result.os_name) {
|
||||
console.log(`[${now}] ✅ [${serverName}] API v3 감지됨`)
|
||||
return '3'
|
||||
}
|
||||
|
||||
console.log(`[${now}] ❌ [${serverName}] API 버전 감지 실패`)
|
||||
return null
|
||||
}
|
||||
|
||||
// 이상감지 실행
|
||||
async function detectAnomalies(targetId: number, serverName: string) {
|
||||
const db = getDb()
|
||||
const now = timestamp()
|
||||
|
||||
try {
|
||||
// === 단기 변화율 감지 ===
|
||||
const SHORT_TERM_THRESHOLD = 30
|
||||
|
||||
const snapshots = db.prepare(`
|
||||
SELECT cpu_percent, memory_percent
|
||||
FROM server_snapshots
|
||||
WHERE target_id = ? AND is_online = 1
|
||||
ORDER BY collected_at DESC
|
||||
LIMIT 20
|
||||
`).all(targetId) as any[]
|
||||
|
||||
if (snapshots.length >= 4) {
|
||||
const half = Math.floor(snapshots.length / 2)
|
||||
const currSnapshots = snapshots.slice(0, half)
|
||||
const prevSnapshots = snapshots.slice(half)
|
||||
|
||||
const currCpuAvg = currSnapshots.reduce((sum, s) => sum + (s.cpu_percent || 0), 0) / currSnapshots.length
|
||||
const prevCpuAvg = prevSnapshots.reduce((sum, s) => sum + (s.cpu_percent || 0), 0) / prevSnapshots.length
|
||||
const currMemAvg = currSnapshots.reduce((sum, s) => sum + (s.memory_percent || 0), 0) / currSnapshots.length
|
||||
const prevMemAvg = prevSnapshots.reduce((sum, s) => sum + (s.memory_percent || 0), 0) / prevSnapshots.length
|
||||
|
||||
const cpuChange = prevCpuAvg > 1 ? ((currCpuAvg - prevCpuAvg) / prevCpuAvg) * 100 : currCpuAvg - prevCpuAvg
|
||||
const memChange = prevMemAvg > 1 ? ((currMemAvg - prevMemAvg) / prevMemAvg) * 100 : currMemAvg - prevMemAvg
|
||||
|
||||
// 중복 체크용
|
||||
const recentLogExists = db.prepare(`
|
||||
SELECT 1 FROM anomaly_logs
|
||||
WHERE target_id = ? AND detect_type = ? AND metric = ?
|
||||
AND detected_at > datetime('now', '-1 minute', 'localtime')
|
||||
LIMIT 1
|
||||
`)
|
||||
|
||||
const insertLog = db.prepare(`
|
||||
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
// CPU 단기 변화율 체크
|
||||
if (Math.abs(cpuChange) >= SHORT_TERM_THRESHOLD) {
|
||||
const level = Math.abs(cpuChange) >= 100 ? 'danger' : 'warning'
|
||||
const direction = cpuChange >= 0 ? '증가' : '감소'
|
||||
const message = `CPU ${direction} 감지 (${prevCpuAvg.toFixed(1)}% → ${currCpuAvg.toFixed(1)}%)`
|
||||
|
||||
if (!recentLogExists.get(targetId, 'short-term', 'CPU')) {
|
||||
insertLog.run(targetId, serverName, 'short-term', 'CPU', level, currCpuAvg, cpuChange, message)
|
||||
console.log(`[${now}] 🚨 [${serverName}] 단기변화율 이상감지: CPU ${cpuChange.toFixed(1)}% (${level})`)
|
||||
}
|
||||
}
|
||||
|
||||
// Memory 단기 변화율 체크
|
||||
if (Math.abs(memChange) >= SHORT_TERM_THRESHOLD) {
|
||||
const level = Math.abs(memChange) >= 100 ? 'danger' : 'warning'
|
||||
const direction = memChange >= 0 ? '증가' : '감소'
|
||||
const message = `Memory ${direction} 감지 (${prevMemAvg.toFixed(1)}% → ${currMemAvg.toFixed(1)}%)`
|
||||
|
||||
if (!recentLogExists.get(targetId, 'short-term', 'Memory')) {
|
||||
insertLog.run(targetId, serverName, 'short-term', 'Memory', level, currMemAvg, memChange, message)
|
||||
console.log(`[${now}] 🚨 [${serverName}] 단기변화율 이상감지: Memory ${memChange.toFixed(1)}% (${level})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Z-Score 감지 ===
|
||||
const WARNING_Z = 2.0
|
||||
const DANGER_Z = 3.0
|
||||
|
||||
const hourSnapshots = db.prepare(`
|
||||
SELECT cpu_percent, memory_percent
|
||||
FROM server_snapshots
|
||||
WHERE target_id = ? AND is_online = 1
|
||||
AND collected_at >= datetime('now', '-1 hour', 'localtime')
|
||||
ORDER BY collected_at DESC
|
||||
`).all(targetId) as any[]
|
||||
|
||||
if (hourSnapshots.length >= 10) {
|
||||
const current = hourSnapshots[0]
|
||||
const currCpu = current.cpu_percent ?? 0
|
||||
const currMem = current.memory_percent ?? 0
|
||||
|
||||
const cpuValues = hourSnapshots.map(s => s.cpu_percent ?? 0)
|
||||
const memValues = hourSnapshots.map(s => s.memory_percent ?? 0)
|
||||
|
||||
const cpuAvg = cpuValues.reduce((a, b) => a + b, 0) / cpuValues.length
|
||||
const memAvg = memValues.reduce((a, b) => a + b, 0) / memValues.length
|
||||
|
||||
const cpuVariance = cpuValues.reduce((sum, val) => sum + Math.pow(val - cpuAvg, 2), 0) / cpuValues.length
|
||||
const memVariance = memValues.reduce((sum, val) => sum + Math.pow(val - memAvg, 2), 0) / memValues.length
|
||||
const cpuStd = Math.sqrt(cpuVariance)
|
||||
const memStd = Math.sqrt(memVariance)
|
||||
|
||||
const cpuZscore = cpuStd > 0.1 ? (currCpu - cpuAvg) / cpuStd : 0
|
||||
const memZscore = memStd > 0.1 ? (currMem - memAvg) / memStd : 0
|
||||
|
||||
const recentLogExists = db.prepare(`
|
||||
SELECT 1 FROM anomaly_logs
|
||||
WHERE target_id = ? AND detect_type = 'zscore' AND metric = ?
|
||||
AND detected_at > datetime('now', '-1 minute', 'localtime')
|
||||
LIMIT 1
|
||||
`)
|
||||
|
||||
const insertLog = db.prepare(`
|
||||
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
|
||||
VALUES (?, ?, 'zscore', ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
// CPU Z-Score 체크
|
||||
if (Math.abs(cpuZscore) >= WARNING_Z) {
|
||||
const level = Math.abs(cpuZscore) >= DANGER_Z ? 'danger' : 'warning'
|
||||
const direction = cpuZscore >= 0 ? '높음' : '낮음'
|
||||
const message = `CPU 평균 대비 ${Math.abs(cpuZscore).toFixed(1)}σ ${direction} (평균: ${cpuAvg.toFixed(1)}%, 현재: ${currCpu.toFixed(1)}%)`
|
||||
|
||||
if (!recentLogExists.get(targetId, 'CPU')) {
|
||||
insertLog.run(targetId, serverName, 'CPU', level, currCpu, cpuZscore, message)
|
||||
console.log(`[${now}] 🚨 [${serverName}] Z-Score 이상감지: CPU Z=${cpuZscore.toFixed(2)} (${level})`)
|
||||
}
|
||||
}
|
||||
|
||||
// Memory Z-Score 체크
|
||||
if (Math.abs(memZscore) >= WARNING_Z) {
|
||||
const level = Math.abs(memZscore) >= DANGER_Z ? 'danger' : 'warning'
|
||||
const direction = memZscore >= 0 ? '높음' : '낮음'
|
||||
const message = `Memory 평균 대비 ${Math.abs(memZscore).toFixed(1)}σ ${direction} (평균: ${memAvg.toFixed(1)}%, 현재: ${currMem.toFixed(1)}%)`
|
||||
|
||||
if (!recentLogExists.get(targetId, 'Memory')) {
|
||||
insertLog.run(targetId, serverName, 'Memory', level, currMem, memZscore, message)
|
||||
console.log(`[${now}] 🚨 [${serverName}] Z-Score 이상감지: Memory Z=${memZscore.toFixed(2)} (${level})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === 시간대별 베이스라인 감지 ===
|
||||
const DEVIATION_THRESHOLD = 2.0
|
||||
const currentHour = new Date().getHours()
|
||||
const currentDayOfWeek = new Date().getDay()
|
||||
const isWeekend = currentDayOfWeek === 0 || currentDayOfWeek === 6
|
||||
const dayType = isWeekend ? 'weekend' : 'weekday'
|
||||
|
||||
const baselineData = db.prepare(`
|
||||
SELECT cpu_percent, memory_percent
|
||||
FROM server_snapshots
|
||||
WHERE target_id = ? AND is_online = 1
|
||||
AND collected_at >= datetime('now', '-14 days', 'localtime')
|
||||
AND strftime('%H', collected_at) = ?
|
||||
AND (
|
||||
(? = 'weekend' AND strftime('%w', collected_at) IN ('0', '6'))
|
||||
OR
|
||||
(? = 'weekday' AND strftime('%w', collected_at) NOT IN ('0', '6'))
|
||||
)
|
||||
`).all(targetId, currentHour.toString().padStart(2, '0'), dayType, dayType) as any[]
|
||||
|
||||
const currentSnapshot = db.prepare(`
|
||||
SELECT cpu_percent, memory_percent
|
||||
FROM server_snapshots
|
||||
WHERE target_id = ? AND is_online = 1
|
||||
ORDER BY collected_at DESC LIMIT 1
|
||||
`).get(targetId) as any
|
||||
|
||||
if (baselineData.length >= 5 && currentSnapshot) {
|
||||
const currCpu = currentSnapshot.cpu_percent ?? 0
|
||||
const currMem = currentSnapshot.memory_percent ?? 0
|
||||
|
||||
const cpuValues = baselineData.map(s => s.cpu_percent ?? 0)
|
||||
const memValues = baselineData.map(s => s.memory_percent ?? 0)
|
||||
|
||||
const cpuAvg = cpuValues.reduce((a, b) => a + b, 0) / cpuValues.length
|
||||
const memAvg = memValues.reduce((a, b) => a + b, 0) / memValues.length
|
||||
|
||||
const cpuVariance = cpuValues.reduce((sum, val) => sum + Math.pow(val - cpuAvg, 2), 0) / cpuValues.length
|
||||
const memVariance = memValues.reduce((sum, val) => sum + Math.pow(val - memAvg, 2), 0) / memValues.length
|
||||
const cpuStd = Math.sqrt(cpuVariance)
|
||||
const memStd = Math.sqrt(memVariance)
|
||||
|
||||
const cpuDeviation = cpuStd > 0.1 ? (currCpu - cpuAvg) / cpuStd : 0
|
||||
const memDeviation = memStd > 0.1 ? (currMem - memAvg) / memStd : 0
|
||||
|
||||
const baselineLogExists = db.prepare(`
|
||||
SELECT 1 FROM anomaly_logs
|
||||
WHERE target_id = ? AND detect_type = 'baseline' AND metric = ?
|
||||
AND detected_at > datetime('now', '-1 minute', 'localtime')
|
||||
LIMIT 1
|
||||
`)
|
||||
|
||||
const baselineInsertLog = db.prepare(`
|
||||
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
|
||||
VALUES (?, ?, 'baseline', ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
if (Math.abs(cpuDeviation) >= DEVIATION_THRESHOLD) {
|
||||
const level = Math.abs(cpuDeviation) >= 3.0 ? 'danger' : 'warning'
|
||||
const direction = cpuDeviation >= 0 ? '높음' : '낮음'
|
||||
const dayLabel = isWeekend ? '주말' : '평일'
|
||||
const message = `CPU ${dayLabel} ${currentHour}시 베이스라인 대비 ${Math.abs(cpuDeviation).toFixed(1)}σ ${direction}`
|
||||
|
||||
if (!baselineLogExists.get(targetId, 'CPU')) {
|
||||
baselineInsertLog.run(targetId, serverName, 'CPU', level, currCpu, cpuDeviation, message)
|
||||
console.log(`[${now}] 🚨 [${serverName}] 베이스라인 이상감지: CPU σ=${cpuDeviation.toFixed(2)} (${level})`)
|
||||
}
|
||||
}
|
||||
|
||||
if (Math.abs(memDeviation) >= DEVIATION_THRESHOLD) {
|
||||
const level = Math.abs(memDeviation) >= 3.0 ? 'danger' : 'warning'
|
||||
const direction = memDeviation >= 0 ? '높음' : '낮음'
|
||||
const dayLabel = isWeekend ? '주말' : '평일'
|
||||
const message = `Memory ${dayLabel} ${currentHour}시 베이스라인 대비 ${Math.abs(memDeviation).toFixed(1)}σ ${direction}`
|
||||
|
||||
if (!baselineLogExists.get(targetId, 'Memory')) {
|
||||
baselineInsertLog.run(targetId, serverName, 'Memory', level, currMem, memDeviation, message)
|
||||
console.log(`[${now}] 🚨 [${serverName}] 베이스라인 이상감지: Memory σ=${memDeviation.toFixed(2)} (${level})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === 추세 분석 감지 ===
|
||||
const SLOPE_THRESHOLD = 0.5
|
||||
const WINDOW_MINUTES = 30
|
||||
|
||||
const trendSnapshots = db.prepare(`
|
||||
SELECT cpu_percent, memory_percent
|
||||
FROM server_snapshots
|
||||
WHERE target_id = ? AND is_online = 1
|
||||
AND collected_at >= datetime('now', '-${WINDOW_MINUTES} minutes', 'localtime')
|
||||
ORDER BY collected_at ASC
|
||||
`).all(targetId) as any[]
|
||||
|
||||
if (trendSnapshots.length >= 10) {
|
||||
const n = trendSnapshots.length
|
||||
const currCpu = trendSnapshots[n - 1].cpu_percent ?? 0
|
||||
const currMem = trendSnapshots[n - 1].memory_percent ?? 0
|
||||
|
||||
// 선형 회귀 계산
|
||||
function calcSlope(values: number[]): { slope: number, r2: number } {
|
||||
const n = values.length
|
||||
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0
|
||||
for (let i = 0; i < n; i++) {
|
||||
sumX += i; sumY += values[i]; sumXY += i * values[i]; sumX2 += i * i
|
||||
}
|
||||
const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX)
|
||||
const yMean = sumY / n
|
||||
let ssTotal = 0, ssResidual = 0
|
||||
for (let i = 0; i < n; i++) {
|
||||
const yPred = slope * i + (sumY - slope * sumX) / n
|
||||
ssTotal += Math.pow(values[i] - yMean, 2)
|
||||
ssResidual += Math.pow(values[i] - yPred, 2)
|
||||
}
|
||||
const r2 = ssTotal > 0 ? 1 - (ssResidual / ssTotal) : 0
|
||||
return { slope: (slope * n) / WINDOW_MINUTES, r2 }
|
||||
}
|
||||
|
||||
const cpuResult = calcSlope(trendSnapshots.map(s => s.cpu_percent ?? 0))
|
||||
const memResult = calcSlope(trendSnapshots.map(s => s.memory_percent ?? 0))
|
||||
|
||||
const trendLogExists = db.prepare(`
|
||||
SELECT 1 FROM anomaly_logs
|
||||
WHERE target_id = ? AND detect_type = 'trend' AND metric = ?
|
||||
AND detected_at > datetime('now', '-1 minute', 'localtime')
|
||||
LIMIT 1
|
||||
`)
|
||||
|
||||
const trendInsertLog = db.prepare(`
|
||||
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
|
||||
VALUES (?, ?, 'trend', ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
if (cpuResult.slope >= SLOPE_THRESHOLD && cpuResult.r2 >= 0.3) {
|
||||
const level = cpuResult.slope >= 1.0 ? 'danger' : 'warning'
|
||||
const message = `CPU 지속 상승 중 (분당 +${cpuResult.slope.toFixed(2)}%, R²=${cpuResult.r2.toFixed(2)})`
|
||||
|
||||
if (!trendLogExists.get(targetId, 'CPU')) {
|
||||
trendInsertLog.run(targetId, serverName, 'CPU', level, currCpu, cpuResult.slope, message)
|
||||
console.log(`[${now}] 🚨 [${serverName}] 추세 이상감지: CPU +${cpuResult.slope.toFixed(2)}/분 (${level})`)
|
||||
}
|
||||
}
|
||||
|
||||
if (memResult.slope >= SLOPE_THRESHOLD && memResult.r2 >= 0.3) {
|
||||
const level = memResult.slope >= 1.0 ? 'danger' : 'warning'
|
||||
const message = `Memory 지속 상승 중 (분당 +${memResult.slope.toFixed(2)}%, R²=${memResult.r2.toFixed(2)})`
|
||||
|
||||
if (!trendLogExists.get(targetId, 'Memory')) {
|
||||
trendInsertLog.run(targetId, serverName, 'Memory', level, currMem, memResult.slope, message)
|
||||
console.log(`[${now}] 🚨 [${serverName}] 추세 이상감지: Memory +${memResult.slope.toFixed(2)}/분 (${level})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error(`[${now}] ❌ [${serverName}] 이상감지 에러:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 서버 데이터 수집
|
||||
async function collectServerData(target: ServerTarget) {
|
||||
const db = getDb()
|
||||
const now = timestamp()
|
||||
|
||||
console.log(`[${now}] 📡 [${target.server_name}] 수집 시작... (${target.glances_url})`)
|
||||
|
||||
try {
|
||||
// API 버전 확인 (캐시 또는 자동 감지)
|
||||
let apiVersion = apiVersionCache.get(target.target_id)
|
||||
|
||||
if (!apiVersion) {
|
||||
apiVersion = await detectApiVersion(target.glances_url, target.server_name)
|
||||
if (apiVersion) {
|
||||
apiVersionCache.set(target.target_id, apiVersion)
|
||||
}
|
||||
}
|
||||
|
||||
if (!apiVersion) {
|
||||
console.log(`[${now}] ❌ [${target.server_name}] 연결 실패 - Offline 기록`)
|
||||
db.prepare(`
|
||||
INSERT INTO server_snapshots (target_id, is_online, collected_at)
|
||||
VALUES (?, 0, ?)
|
||||
`).run(target.target_id, now)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[${now}] 📡 [${target.server_name}] Glances API v${apiVersion} 호출 중...`)
|
||||
|
||||
// 병렬로 API 호출
|
||||
const [system, cpu, mem, memswap, fs, docker, network, quicklook, uptime, sensors, load] = await Promise.all([
|
||||
fetchGlancesApi(target.glances_url, 'system', apiVersion),
|
||||
fetchGlancesApi(target.glances_url, 'cpu', apiVersion),
|
||||
fetchGlancesApi(target.glances_url, 'mem', apiVersion),
|
||||
fetchGlancesApi(target.glances_url, 'memswap', apiVersion),
|
||||
fetchGlancesApi(target.glances_url, 'fs', apiVersion),
|
||||
fetchGlancesApi(target.glances_url, 'containers', apiVersion),
|
||||
fetchGlancesApi(target.glances_url, 'network', apiVersion),
|
||||
fetchGlancesApi(target.glances_url, 'quicklook', apiVersion),
|
||||
fetchGlancesApi(target.glances_url, 'uptime', apiVersion),
|
||||
fetchGlancesApi(target.glances_url, 'sensors', apiVersion),
|
||||
fetchGlancesApi(target.glances_url, 'load', apiVersion)
|
||||
])
|
||||
|
||||
const isOnline = system !== null
|
||||
|
||||
if (!isOnline) {
|
||||
// 캐시 클리어 후 재시도 위해
|
||||
apiVersionCache.delete(target.target_id)
|
||||
console.log(`[${now}] ❌ [${target.server_name}] 연결 실패 - Offline 기록`)
|
||||
db.prepare(`
|
||||
INSERT INTO server_snapshots (target_id, is_online, collected_at)
|
||||
VALUES (?, 0, ?)
|
||||
`).run(target.target_id, now)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[${now}] ✅ [${target.server_name}] 연결 성공 - 데이터 저장 중...`)
|
||||
|
||||
// CPU 온도 추출 (sensors 배열에서)
|
||||
let cpuTemp: number | null = null
|
||||
if (Array.isArray(sensors)) {
|
||||
const tempSensor = sensors.find((s: any) =>
|
||||
s.label?.toLowerCase().includes('cpu') ||
|
||||
s.label?.toLowerCase().includes('core') ||
|
||||
s.type === 'temperature_core'
|
||||
)
|
||||
cpuTemp = tempSensor?.value ?? null
|
||||
}
|
||||
|
||||
// server_snapshots INSERT (api_version 포함)
|
||||
console.log(`[${now}] 💾 [${target.server_name}] snapshot 저장 (API v${apiVersion}, CPU: ${cpu?.total?.toFixed(1) || 0}%, MEM: ${mem?.percent?.toFixed(1) || 0}%, TEMP: ${cpuTemp ?? 'N/A'}°C, LOAD: ${quicklook?.load?.toFixed(1) ?? 'N/A'}%)`)
|
||||
db.prepare(`
|
||||
INSERT INTO server_snapshots (
|
||||
target_id, os_name, os_version, host_name, uptime_seconds, uptime_str, ip_address,
|
||||
cpu_name, cpu_count, cpu_percent, memory_total, memory_used, memory_percent,
|
||||
swap_total, swap_used, swap_percent, is_online, api_version, cpu_temp,
|
||||
load_1, load_5, load_15, load_percent, collected_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
target.target_id,
|
||||
system?.os_name || system?.linux_distro || null,
|
||||
system?.os_version || null,
|
||||
system?.hostname || null,
|
||||
null,
|
||||
typeof uptime === 'string' ? uptime : null,
|
||||
target.glances_url.match(/https?:\/\/([^:\/]+)/)?.[1] || null,
|
||||
quicklook?.cpu_name || null,
|
||||
quicklook?.cpu_number || quicklook?.cpu_log_core || cpu?.cpucore || null,
|
||||
cpu?.total ?? quicklook?.cpu ?? null,
|
||||
mem?.total || null,
|
||||
mem?.used || null,
|
||||
mem?.percent || null,
|
||||
memswap?.total || null,
|
||||
memswap?.used || null,
|
||||
memswap?.percent || null,
|
||||
isOnline ? 1 : 0,
|
||||
apiVersion,
|
||||
cpuTemp,
|
||||
load?.min1 ?? null,
|
||||
load?.min5 ?? null,
|
||||
load?.min15 ?? null,
|
||||
quicklook?.load ?? null,
|
||||
now
|
||||
)
|
||||
|
||||
// server_disks INSERT (배열)
|
||||
if (Array.isArray(fs) && fs.length > 0) {
|
||||
console.log(`[${now}] 💾 [${target.server_name}] disk 저장 (${fs.length}개 파티션)`)
|
||||
const diskStmt = db.prepare(`
|
||||
INSERT INTO server_disks (
|
||||
target_id, device_name, mount_point, fs_type,
|
||||
disk_total, disk_used, disk_percent, collected_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
for (const disk of fs) {
|
||||
diskStmt.run(
|
||||
target.target_id,
|
||||
disk.device_name || null,
|
||||
disk.mnt_point || null,
|
||||
disk.fs_type || null,
|
||||
disk.size || null,
|
||||
disk.used || null,
|
||||
disk.percent || null,
|
||||
now
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// server_containers INSERT (배열)
|
||||
if (Array.isArray(docker) && docker.length > 0) {
|
||||
console.log(`[${now}] 🐳 [${target.server_name}] container 저장 (${docker.length}개 컨테이너)`)
|
||||
const containerStmt = db.prepare(`
|
||||
INSERT INTO server_containers (
|
||||
target_id, docker_id, container_name, container_image,
|
||||
container_status, cpu_percent, memory_usage, memory_limit,
|
||||
memory_percent, uptime, network_rx, network_tx, collected_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
for (const container of docker) {
|
||||
containerStmt.run(
|
||||
target.target_id,
|
||||
container.id || null,
|
||||
container.name || null,
|
||||
Array.isArray(container.image) ? container.image.join(', ') : container.image || null,
|
||||
container.status || null,
|
||||
container.cpu?.total ?? container.cpu_percent ?? null,
|
||||
container.memory?.usage || container.memory_usage || null,
|
||||
container.memory?.limit || container.memory_limit || null,
|
||||
container.memory?.usage && container.memory?.limit
|
||||
? (container.memory.usage / container.memory.limit * 100)
|
||||
: container.memory_percent ?? null,
|
||||
container.uptime || null,
|
||||
container.network?.rx ?? container.network_rx ?? null,
|
||||
container.network?.tx ?? container.network_tx ?? null,
|
||||
now
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// server_networks INSERT (배열)
|
||||
if (Array.isArray(network) && network.length > 0) {
|
||||
console.log(`[${now}] 🌐 [${target.server_name}] network 저장 (${network.length}개 인터페이스)`)
|
||||
const netStmt = db.prepare(`
|
||||
INSERT INTO server_networks (
|
||||
target_id, interface_name, bytes_recv, bytes_sent,
|
||||
packets_recv, packets_sent, speed_recv, speed_sent,
|
||||
is_up, collected_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
for (const iface of network) {
|
||||
netStmt.run(
|
||||
target.target_id,
|
||||
iface.interface_name || null,
|
||||
iface.bytes_recv || iface.cumulative_rx || null,
|
||||
iface.bytes_sent || iface.cumulative_tx || null,
|
||||
iface.packets_recv || null,
|
||||
iface.packets_sent || null,
|
||||
iface.bytes_recv_rate_per_sec || iface.rx || iface.bytes_recv_rate || null,
|
||||
iface.bytes_sent_rate_per_sec || iface.tx || iface.bytes_sent_rate || null,
|
||||
iface.is_up ? 1 : 0,
|
||||
now
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[${now}] ✅ [${target.server_name}] 수집 완료!`)
|
||||
|
||||
// 이상감지 실행
|
||||
await detectAnomalies(target.target_id, target.server_name)
|
||||
|
||||
} catch (err) {
|
||||
console.error(`[${now}] ❌ [${target.server_name}] 수집 에러:`, err)
|
||||
|
||||
// 캐시 클리어
|
||||
apiVersionCache.delete(target.target_id)
|
||||
|
||||
// 오프라인 기록
|
||||
db.prepare(`
|
||||
INSERT INTO server_snapshots (target_id, is_online, collected_at)
|
||||
VALUES (?, 0, ?)
|
||||
`).run(target.target_id, now)
|
||||
}
|
||||
}
|
||||
|
||||
// 서버별 타이머 시작
|
||||
function startServerTimer(target: ServerTarget) {
|
||||
const now = timestamp()
|
||||
|
||||
// 기존 타이머 제거
|
||||
stopServerTimer(target.target_id)
|
||||
|
||||
console.log(`[${now}] ⏰ [${target.server_name}] 타이머 등록 (주기: ${target.collect_interval}초)`)
|
||||
|
||||
// 즉시 한 번 실행
|
||||
collectServerData(target)
|
||||
|
||||
// 주기적 실행
|
||||
const intervalMs = (target.collect_interval || 60) * 1000
|
||||
const timer = setInterval(() => {
|
||||
collectServerData(target)
|
||||
}, intervalMs)
|
||||
|
||||
serverTimers.set(target.target_id, timer)
|
||||
}
|
||||
|
||||
// 서버별 타이머 중지
|
||||
function stopServerTimer(targetId: number) {
|
||||
const timer = serverTimers.get(targetId)
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
serverTimers.delete(targetId)
|
||||
apiVersionCache.delete(targetId)
|
||||
console.log(`[${timestamp()}] ⏹️ 타이머 중지 (target_id: ${targetId})`)
|
||||
}
|
||||
}
|
||||
|
||||
// 스케줄러 시작 (모든 활성 서버)
|
||||
export function startServerScheduler() {
|
||||
const now = timestamp()
|
||||
|
||||
if (isRunning) {
|
||||
console.log(`[${now}] ⚠️ [Server Scheduler] 이미 실행 중`)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[${now}] 🚀 [Server Scheduler] ========== 스케줄러 시작 ==========`)
|
||||
|
||||
const db = getDb()
|
||||
const targets = db.prepare(`
|
||||
SELECT * FROM server_targets WHERE is_active = 1
|
||||
`).all() as ServerTarget[]
|
||||
|
||||
console.log(`[${now}] 📋 [Server Scheduler] 활성 서버: ${targets.length}개`)
|
||||
|
||||
for (const target of targets) {
|
||||
console.log(`[${now}] 📋 [Server Scheduler] - ${target.server_name} (${target.glances_url}) / ${target.collect_interval}초`)
|
||||
startServerTimer(target)
|
||||
}
|
||||
|
||||
isRunning = true
|
||||
console.log(`[${now}] ✅ [Server Scheduler] ========== 스케줄러 시작 완료 ==========`)
|
||||
}
|
||||
|
||||
// 스케줄러 중지 (모든 서버)
|
||||
export function stopServerScheduler() {
|
||||
const now = timestamp()
|
||||
|
||||
console.log(`[${now}] 🛑 [Server Scheduler] ========== 스케줄러 중지 ==========`)
|
||||
|
||||
for (const [targetId] of serverTimers) {
|
||||
stopServerTimer(targetId)
|
||||
}
|
||||
|
||||
isRunning = false
|
||||
console.log(`[${now}] ✅ [Server Scheduler] ========== 스케줄러 중지 완료 ==========`)
|
||||
}
|
||||
|
||||
// 스케줄러 상태
|
||||
export function getServerSchedulerStatus() {
|
||||
const db = getDb()
|
||||
const activeServers = serverTimers.size
|
||||
const targets = db.prepare(`
|
||||
SELECT * FROM server_targets WHERE is_active = 1
|
||||
`).all() as ServerTarget[]
|
||||
|
||||
return {
|
||||
is_running: isRunning,
|
||||
active_timers: activeServers,
|
||||
total_targets: targets.length,
|
||||
targets: targets.map(t => ({
|
||||
target_id: t.target_id,
|
||||
server_name: t.server_name,
|
||||
glances_url: t.glances_url,
|
||||
collect_interval: t.collect_interval,
|
||||
has_timer: serverTimers.has(t.target_id),
|
||||
api_version: apiVersionCache.get(t.target_id) || null
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 서버 타이머 갱신 (설정 변경 시)
|
||||
export function refreshServerTimer(targetId: number) {
|
||||
const now = timestamp()
|
||||
const db = getDb()
|
||||
const target = db.prepare(`
|
||||
SELECT * FROM server_targets WHERE target_id = ? AND is_active = 1
|
||||
`).get(targetId) as ServerTarget | undefined
|
||||
|
||||
if (target && isRunning) {
|
||||
console.log(`[${now}] 🔄 [${target.server_name}] 타이머 갱신`)
|
||||
apiVersionCache.delete(targetId) // 버전 재감지
|
||||
startServerTimer(target)
|
||||
} else {
|
||||
stopServerTimer(targetId)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user