diff --git a/php/assets/app.css b/php/assets/app.css index d09b1b9..b7a995e 100644 --- a/php/assets/app.css +++ b/php/assets/app.css @@ -378,7 +378,9 @@ tbody tr:hover td { background: var(--surface-2); } .ov-trend .ax { fill: var(--ink-3); } .ov-trend .line-alert { stroke: var(--leak); } .ov-trend .line-recover { stroke: var(--normal); } +.ov-trend .line-metric { stroke: var(--warn); } .ov-trend .dot-alert { fill: var(--leak); } +.ov-trend .dot-metric { fill: var(--warn); } .mini-legend { display: flex; gap: var(--sp-4); flex-wrap: wrap; font-size: .78rem; color: var(--ink-2); margin-bottom: var(--sp-2); } .mini-legend span { display: inline-flex; align-items: center; gap: 6px; } .mini-legend i { width: 15px; height: 3px; border-radius: 2px; } diff --git a/php/dashboard.php b/php/dashboard.php index 98587c2..c60ea16 100644 --- a/php/dashboard.php +++ b/php/dashboard.php @@ -409,81 +409,88 @@ if (FORECAST_ENABLED) { usort($risk_items_all, fn($a, $b) => ($a['severity'] === 'critical' ? 0 : 1) <=> ($b['severity'] === 'critical' ? 0 : 1)); } -// ── 개요 벤토 위젯 데이터 (샘플 대시보드 콘텐츠 반영: 게이지·리더보드·이벤트율·도넛) ── -// 경보성/복구성 SMS 태그 묶음 — 태그 상수에서만 파생(집계 일원화). 상수는 신뢰값이므로 -// $recent_alert_fail 쿼리와 동일하게 LIKE 절에 직접 삽입한다(사용자 입력 아님). -$OV_ALERT_TAGS = [ - SMS_TAG_HIGH_TEMP, SMS_TAG_LOW_TEMP, SMS_TAG_HIGH_HUMIDITY, SMS_TAG_LOW_HUMIDITY, - SMS_TAG_CONDENSATION, SMS_TAG_DEVICE_OFFLINE, SMS_TAG_LEAK_ALERT, SMS_TAG_ESCALATION, -]; -$OV_RECOVER_TAGS = [SMS_TAG_RECOVERY, SMS_TAG_DEVICE_ONLINE, SMS_TAG_LEAK_RECOVERY]; -$ov_like = function (array $tags): string { - return '(' . implode(' OR ', array_map( - fn($t) => "message LIKE '" . $t . "%'", $tags - )) . ')'; -}; +// ── 개요 벤토 위젯 데이터 (측정 기준 — 하단 측정 이력·이벤트 로그와 동일 소스로 정합) ── +// 상단 경보 집계는 SMS 발송(leak_sms_log)이 아니라 실제 센서 데이터 기준으로 센다: +// · 온습도 경보 = leak_sensor_metric.metric_status (비정상 측정) — 하단 "측정 이력"과 동일 소스 +// · 누수 = leak_sensor_log.event_type='leak_alert' (누수 감지 이벤트) — "이벤트 로그"와 동일 +// (SMS 발송 통계는 하단 "30일 운영 요약"에 별도로, 명시적 SMS 라벨로 유지) -// (1) 가동률 게이지 — 온라인 센서 비율 + 7일 일별 경보건수 스파크라인 + 상태 배지 +// (1) 가동률 게이지 — 온라인 센서 비율 (last_seen 기준, 경보 소스와 무관) $sensor_total = count($statuses); $online_count = $sensor_total - $offline_count; $uptime_pct = $sensor_total > 0 ? (int)round($online_count / $sensor_total * 100) : 100; -$spark_map = $db->query(" - SELECT DATE(sent_at) d, COUNT(*) c - FROM leak_sms_log - WHERE sent_at >= DATE_SUB(CURDATE(), INTERVAL 6 DAY) AND " . $ov_like($OV_ALERT_TAGS) . " - GROUP BY DATE(sent_at) +// (2) 7일 일별 온습도 경보 측정 / 누수 감지 — 게이지 스파크라인 + 발생 추이 라인차트 공용 +$mtrend_map = $db->query(" + SELECT DATE(measured_at) d, COUNT(*) c + FROM leak_sensor_metric + WHERE measured_at >= DATE_SUB(CURDATE(), INTERVAL 6 DAY) AND metric_status <> 'normal' + GROUP BY DATE(measured_at) ")->fetchAll(PDO::FETCH_KEY_PAIR); -$spark_alerts = []; +$ltrend_map = $db->query(" + SELECT DATE(sensor_time) d, COUNT(*) c + FROM leak_sensor_log + WHERE event_type = 'leak_alert' AND sensor_time >= DATE_SUB(CURDATE(), INTERVAL 6 DAY) + GROUP BY DATE(sensor_time) +")->fetchAll(PDO::FETCH_KEY_PAIR); +$trend_days = []; $trend_metric = []; $trend_leak = []; $spark_alerts = []; for ($i = 6; $i >= 0; $i--) { - $spark_alerts[] = (int)($spark_map[date('Y-m-d', strtotime("-$i day"))] ?? 0); + $d = date('Y-m-d', strtotime("-$i day")); + $tm = (int)($mtrend_map[$d] ?? 0); + $tl = (int)($ltrend_map[$d] ?? 0); + $trend_days[] = $d; + $trend_metric[] = $tm; + $trend_leak[] = $tl; + $spark_alerts[] = $tm + $tl; } +$trend_max = max(1, max(array_merge($trend_metric, $trend_leak))); $spark_today = end($spark_alerts); reset($spark_alerts); if ($alert_count > 0) { $ov_badge = 'crit'; $ov_badge_txt = '경보'; } elseif ($offline_count > 0 || $spark_today > 0) { $ov_badge = 'warn'; $ov_badge_txt = '주의'; } else { $ov_badge = 'ok'; $ov_badge_txt = '정상'; } -// (2) 경보 최다 센서 리더보드 (30일) -$board_rows = $db->query(" +// (3) 경보 최다 센서 리더보드 (30일) — 온습도 경보 측정 + 누수 감지, 센서별 합산 +$board_metric = $db->query(" SELECT sensor_name, COUNT(*) c - FROM leak_sms_log - WHERE sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) AND " . $ov_like($OV_ALERT_TAGS) . " + FROM leak_sensor_metric + WHERE measured_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) AND metric_status <> 'normal' GROUP BY sensor_name - ORDER BY c DESC, sensor_name ASC - LIMIT 6 -")->fetchAll(); -$board_max = 0; -foreach ($board_rows as $br) { $board_max = max($board_max, (int)$br['c']); } - -// (3) 경보 발생 vs 복구 (7일 일별) — 이벤트율 라인차트 -$trend_map = []; -foreach ($db->query(" - SELECT DATE(sent_at) d, - SUM(" . $ov_like($OV_ALERT_TAGS) . ") alerts, - SUM(" . $ov_like($OV_RECOVER_TAGS) . ") recovers - FROM leak_sms_log - WHERE sent_at >= DATE_SUB(CURDATE(), INTERVAL 6 DAY) - GROUP BY DATE(sent_at) -")->fetchAll() as $tr) { - $trend_map[$tr['d']] = $tr; +")->fetchAll(PDO::FETCH_KEY_PAIR); +$board_leak = $db->query(" + SELECT sensor_name, COUNT(*) c + FROM leak_sensor_log + WHERE event_type = 'leak_alert' AND sensor_time >= DATE_SUB(NOW(), INTERVAL 30 DAY) + GROUP BY sensor_name +")->fetchAll(PDO::FETCH_KEY_PAIR); +$board_combined = []; +foreach ([$board_metric, $board_leak] as $src) { + foreach ($src as $name => $c) { $board_combined[$name] = ($board_combined[$name] ?? 0) + (int)$c; } } -$trend_days = []; $trend_alerts = []; $trend_recovers = []; -for ($i = 6; $i >= 0; $i--) { - $d = date('Y-m-d', strtotime("-$i day")); - $trend_days[] = $d; - $trend_alerts[] = (int)($trend_map[$d]['alerts'] ?? 0); - $trend_recovers[] = (int)($trend_map[$d]['recovers'] ?? 0); +arsort($board_combined); +$board_rows = []; +foreach (array_slice($board_combined, 0, 6, true) as $name => $c) { + $board_rows[] = ['sensor_name' => $name, 'c' => $c]; } -$trend_max = max(1, max(array_merge($trend_alerts, $trend_recovers))); +$board_max = $board_rows ? max(array_column($board_rows, 'c')) : 0; -// (4) 경보 종류 도넛 — $summary(30일) 재사용(추가 쿼리 없음) +// (4) 경보 종류 도넛 (30일) — 온습도 경보 상태별 측정 건수 + 누수 감지 건수 +$mstatus_map = $db->query(" + SELECT metric_status, COUNT(*) c + FROM leak_sensor_metric + WHERE measured_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) AND metric_status <> 'normal' + GROUP BY metric_status +")->fetchAll(PDO::FETCH_KEY_PAIR); +$leak_alert_30d = (int)$db->query(" + SELECT COUNT(*) FROM leak_sensor_log + WHERE event_type = 'leak_alert' AND sensor_time >= DATE_SUB(NOW(), INTERVAL 30 DAY) +")->fetchColumn(); $donut_segs = [ - ['label' => '고온', 'val' => (int)$summary['high_temp'], 'color' => '#ff8a5c'], - ['label' => '고습', 'val' => (int)$summary['high_humidity'], 'color' => '#ffb23e'], - ['label' => '누수', 'val' => (int)$summary['leak_alert'], 'color' => '#ff5470'], - ['label' => '저온', 'val' => (int)$summary['low_temp'], 'color' => '#5fd6ee'], - ['label' => '저습', 'val' => (int)$summary['low_humidity'], 'color' => '#b0b8ff'], + ['label' => '고온', 'val' => (int)($mstatus_map['high_temp'] ?? 0), 'color' => '#ff8a5c'], + ['label' => '고습', 'val' => (int)($mstatus_map['high_humidity'] ?? 0), 'color' => '#ffb23e'], + ['label' => '누수', 'val' => $leak_alert_30d, 'color' => '#ff5470'], + ['label' => '저온', 'val' => (int)($mstatus_map['low_temp'] ?? 0), 'color' => '#5fd6ee'], + ['label' => '저습', 'val' => (int)($mstatus_map['low_humidity'] ?? 0), 'color' => '#b0b8ff'], ]; usort($donut_segs, fn($a, $b) => $b['val'] <=> $a['val']); $donut_total = array_sum(array_column($donut_segs, 'val')); @@ -717,7 +724,7 @@ foreach ($chart_points as $ci => $cp) { - +

경보 종류 최근 30일

0): ?> @@ -771,12 +778,12 @@ foreach ($chart_points as $ci => $cp) {
- +
-

경보 발생 vs 복구 최근 7일 · 일별

+

경보 발생 추이 최근 7일 · 유형별

- 경보 발생 - 복구 + 온습도 경보 + 누수 감지
$cp) { return implode(' ', $pts); }; $t_peak_i = 0; - foreach ($trend_alerts as $i => $v) { if ($v > $trend_alerts[$t_peak_i]) { $t_peak_i = $i; } } + foreach ($trend_metric as $i => $v) { if ($v > $trend_metric[$t_peak_i]) { $t_peak_i = $i; } } ?> - + - - - 0): ?> - + + + 0): ?> + 1 ? [0, intdiv($tn - 1, 2), $tn - 1] : [0];