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