trim((string)$v) !== '')); $n = count($list); if ($n === 0) { return '없음'; } $first = mask_phone((string)$list[0]); return $n === 1 ? $first : "{$first} 외 " . ($n - 1) . '명'; } // ── 탭 정의 (단일 출처) ───────────────────────────────────── const DASH_TABS = [ 'live' => '실시간 현황', 'trend' => '추이·통계', 'history' => '이력 조회', 'system' => '시스템 점검', ]; const DASH_RANGES = ['today' => 1, '3d' => 3, '7d' => 7]; // 현재 탭 ($_GET 화이트리스트 — 목록 밖 값은 기본 'live' 폴백. 'all'은 인쇄용 전체 렌더) function dash_current_tab(): string { $t = isset($_GET['tab']) && is_string($_GET['tab']) ? $_GET['tab'] : 'live'; return (isset(DASH_TABS[$t]) || $t === 'all') ? $t : 'live'; } // 현재 추이 기간 ($_GET 화이트리스트) function dash_current_range(): string { $r = isset($_GET['range']) && is_string($_GET['range']) ? $_GET['range'] : '3d'; return isset(DASH_RANGES[$r]) ? $r : '3d'; } // 내부 링크 빌더 — tab·range 를 병행 보존한다(기간 토글이 탭을 잃거나 그 반대 방지). // 기본값(live/3d)은 생략해 URL 을 짧게 유지. function dash_url(array $over = [], string $frag = ''): string { $tab = $over['tab'] ?? dash_current_tab(); $range = $over['range'] ?? dash_current_range(); if (!isset(DASH_TABS[$tab]) && $tab !== 'all') { $tab = 'live'; } if (!isset(DASH_RANGES[$range])) { $range = '3d'; } $params = []; if ($tab !== 'live') { $params['tab'] = $tab; } if ($range !== '3d') { $params['range'] = $range; } $qs = http_build_query($params); return 'dashboard.php' . ($qs !== '' ? '?' . $qs : '') . ($frag !== '' ? '#' . $frag : ''); } // ── 표시 헬퍼 ─────────────────────────────────────────────── // metric_status: normal|high_temp|low_temp|high_humidity|low_humidity function metric_status_label(string $status): string { $labels = [ 'normal' => '정상', 'high_temp' => '고온', 'low_temp' => '저온', 'high_humidity' => '고습', 'low_humidity' => '저습', ]; return $labels[$status] ?? $status; } // 측정값을 사람이 읽는 짧은 문자열로 (소수점 1자리) function metric_display(?string $value, string $unit): string { if ($value === null || $value === '') { return '-'; } return number_format((float)$value, 1) . $unit; } // 테이블 존재 확인 — 요청 내 static 캐시(같은 로드에서 information_schema 재조회 방지). // 누수 미통합 서버에서 leak_incident/leak_photo 부재 시 graceful degrade. function table_exists(PDO $db, string $table): bool { static $cache = []; if (array_key_exists($table, $cache)) { return $cache[$table]; } try { $stmt = $db->prepare(" SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table "); $stmt->execute([':table' => $table]); return $cache[$table] = (int)$stmt->fetchColumn() > 0; } catch (Throwable $e) { return $cache[$table] = false; } } // ── 운영 자가진단 파일 캐시 ───────────────────────────────── // collect_ops_checks() 는 information_schema 다회 조회를 포함해 30초 리로드마다 // 돌리기엔 무겁다. php/var 에 TTL 캐시(기본 120초)로 래핑한다. 진단이 주인공인 // 시스템 점검 탭(tab=system/all)에서는 $force=true 로 캐시를 우회해 항상 신선하게 // 계산한다(다른 탭 히어로 판정은 최대 2분 지연 — 설정·보관기간류 항목 성격상 수용). function ops_checks_cached(int $ttl = 120, bool $force = false): array { $file = __DIR__ . '/var/ops_checks_cache.json'; if (!$force) { $raw = @file_get_contents($file); if ($raw !== false) { $c = json_decode($raw, true); if (is_array($c) && is_array($c['checks'] ?? null) && ((int)($c['t'] ?? 0) + $ttl) > time()) { return $c['checks']; } } } $checks = collect_ops_checks(); @file_put_contents( $file, json_encode(['t' => time(), 'checks' => $checks], JSON_UNESCAPED_UNICODE), LOCK_EX ); return $checks; } // ── 전역 데이터 (모든 탭 공통: 히어로·알림 스트립·KPI 스트립·대응 패널·탭 배지) ── // $fresh_ops=true(시스템 점검/전체 탭)면 자가진단 캐시를 우회해 항상 신선하게. function load_global_data(PDO $db, bool $fresh_ops = false): array { $now = time(); $g = []; // (1) 센서별 현재 상태 + 최신 측정 + 실시간 오프라인/경보 판정 $statuses = $db->query("SELECT * FROM leak_sensor_status ORDER BY sensor_id")->fetchAll(); $latest_metrics = []; $metric_rows = $db->query(" SELECT sm.* FROM leak_sensor_metric sm INNER JOIN ( SELECT sensor_id, MAX(id) AS latest_id FROM leak_sensor_metric GROUP BY sensor_id ) latest ON latest.latest_id = sm.id ORDER BY sm.sensor_id ")->fetchAll(); foreach ($metric_rows as $metric_row) { $latest_metrics[(int)$metric_row['sensor_id']] = $metric_row; } foreach ($statuses as &$s) { $sid = (int)$s['sensor_id']; $s['latest_metric'] = $latest_metrics[$sid] ?? null; $last_seen_ts = strtotime($s['last_seen'] ?? '2000-01-01'); $elapsed = $now - $last_seen_ts; // 오프라인 판정도 센서별 타임아웃(온습도 1시간 주기 오탐 방지) — cron 과 동일 기준. $s['is_offline'] = ($s['offline_alerted'] == 1) || ($elapsed >= heartbeat_timeout_for($sid)); $s['offline_elapsed_min'] = (int)round($elapsed / 60); $status_code = $s['latest_metric']['metric_status'] ?? 'normal'; $s['metric_status'] = $status_code; $s['is_alerting'] = ($status_code !== 'normal'); // 누수 확정 상태(디바운스): 컬럼이 있으면 leak_confirmed 를, 없으면 raw is_leak 를 // 헤드라인/집계의 단일 기준으로 삼는다(SMS 는 확정 엣지에서만 나가므로 표시와 일치). // leak_pending: raw 가 확정과 어긋나 확인창 대기 중(카드에 '확인중' 배지). $raw_leak = (int)($s['is_leak'] ?? 0); if (array_key_exists('leak_confirmed', $s)) { $s['leak_confirmed'] = (int)$s['leak_confirmed']; $s['leak_pending'] = (($s['leak_pending_since'] ?? null) !== null) && ($raw_leak !== $s['leak_confirmed']); } else { $s['leak_confirmed'] = $raw_leak; // 구배포(컬럼 없음) 폴백 $s['leak_pending'] = false; } // 서버측 라벨 오버라이드(위치/관리주체) — SMS 와 동일 표시로 통일 [$s['device_location'], $s['sensor_name']] = sensor_label($sid, $s['device_location'] ?? null, $s['sensor_name'] ?? null); } unset($s); $g['statuses'] = $statuses; $g['latest_metrics'] = $latest_metrics; // (2) 집계: 온라인/오프라인/온습도 경보/누수 활성 $g['sensor_total'] = count($statuses); $g['offline_count'] = count(array_filter($statuses, fn($s) => $s['is_offline'])); $g['alert_count'] = count(array_filter($statuses, fn($s) => $s['is_alerting'] && !$s['is_offline'])); $g['online_count'] = $g['sensor_total'] - $g['offline_count']; $g['uptime_pct'] = $g['sensor_total'] > 0 ? (int)round($g['online_count'] / $g['sensor_total'] * 100) : 100; $g['leak_active_count'] = count(array_filter( $statuses, fn($s) => (int)($s['leak_confirmed'] ?? $s['is_leak'] ?? 0) === 1 && !$s['is_offline'] )); // (3) 카드 맵 — 온습도(METRIC_SENSOR_IDS 는 미수신도 placeholder) / 누수(LEAK_SENSOR_IDS) $metric_statuses = []; foreach (METRIC_SENSOR_IDS as $mid) { $metric_statuses[(int)$mid] = null; } foreach ($statuses as $s) { $sid = (int)$s['sensor_id']; if (!empty($s['latest_metric']) || array_key_exists($sid, $metric_statuses)) { $metric_statuses[$sid] = $s; } } ksort($metric_statuses); $g['metric_statuses'] = $metric_statuses; $leak_statuses = []; foreach (LEAK_SENSOR_IDS as $leak_sid) { $leak_statuses[$leak_sid] = null; } foreach ($statuses as $s) { $sid = (int)$s['sensor_id']; if (array_key_exists($sid, $leak_statuses)) { $leak_statuses[$sid] = $s; } } $g['leak_statuses'] = $leak_statuses; // (4) 미복구 누수 사고 (recovered_at IS NULL) — 히어로/알림 스트립/live 탭 // 카운트는 COUNT(*) 정확값(표시 목록은 최근 10건만 — LIMIT 이 카운트를 깎지 않게 분리) $g['unrecovered_incidents'] = []; $g['unrecovered_count'] = 0; if (table_exists($db, 'leak_incident')) { $g['unrecovered_count'] = (int)$db->query(" SELECT COUNT(*) FROM leak_incident WHERE recovered_at IS NULL ")->fetchColumn(); if ($g['unrecovered_count'] > 0) { $g['unrecovered_incidents'] = $db->query(" SELECT id, sensor_id, sensor_name, device_location, started_at FROM leak_incident WHERE recovered_at IS NULL ORDER BY id DESC LIMIT 10 ")->fetchAll(); foreach ($g['unrecovered_incidents'] as &$_r) { [$_r['device_location'], $_r['sensor_name']] = sensor_label((int)$_r['sensor_id'], $_r['device_location'] ?? null, $_r['sensor_name'] ?? null); } unset($_r); } } // (5) 오늘 경보 횟수(비정상 측정 + 누수 감지 이벤트) + 오늘 최근 경보 시각 $t_metric = $db->query(" SELECT COUNT(*) c, MAX(measured_at) m FROM leak_sensor_metric WHERE metric_status <> 'normal' AND measured_at >= CURDATE() ")->fetch(); $t_leak = $db->query(" SELECT COUNT(*) c, MAX(sensor_time) m FROM leak_sensor_log WHERE event_type = 'leak_alert' AND sensor_time >= CURDATE() ")->fetch(); $g['today_alert_count'] = (int)($t_metric['c'] ?? 0) + (int)($t_leak['c'] ?? 0); $g['today_last_alert_at'] = max((string)($t_metric['m'] ?? ''), (string)($t_leak['m'] ?? '')) ?: null; // (6) SMS 채널 현황 — 잔여 건수 / 마지막 성공 / 오늘 발송·실패 $g['sms_balance'] = latest_sms_balance($db); $g['sms_last_success_at'] = $db->query(" SELECT sent_at FROM leak_sms_log WHERE status = 'success' ORDER BY id DESC LIMIT 1 ")->fetchColumn() ?: null; $sms_today = $db->query(" SELECT COUNT(*) t, COALESCE(SUM(status = 'fail'), 0) f FROM leak_sms_log WHERE sent_at >= CURDATE() ")->fetch(); $g['sms_today_total'] = (int)($sms_today['t'] ?? 0); $g['sms_today_fail'] = (int)($sms_today['f'] ?? 0); // (7) 최근 1시간 경보성 SMS 발송 실패(미수신 가능 경고 — 알림 스트립) $g['recent_alert_fail'] = (int)$db->query(" SELECT COUNT(*) FROM leak_sms_log WHERE status = 'fail' AND sent_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR) AND (message LIKE '" . SMS_TAG_HIGH_TEMP . "%' OR message LIKE '" . SMS_TAG_LOW_TEMP . "%' OR message LIKE '" . SMS_TAG_HIGH_HUMIDITY . "%' OR message LIKE '" . SMS_TAG_LOW_HUMIDITY . "%' OR message LIKE '" . SMS_TAG_CONDENSATION . "%' OR message LIKE '" . SMS_TAG_DEVICE_OFFLINE . "%' OR message LIKE '" . SMS_TAG_LEAK_ALERT . "%' OR message LIKE '" . SMS_TAG_LEAK_RECOVERY . "%' OR message LIKE '" . SMS_TAG_ESCALATION . "%') ")->fetchColumn(); // (8) 수집 거부 24시간 요약(알림 스트립 1줄 — 상세는 시스템 점검 탭) $g['ingest_rejections'] = read_ingest_rejections(1); $g['rejection_total'] = array_sum($g['ingest_rejections']); // (9) 운영 자가진단(파일 캐시) — 히어로 판정 + KPI '중요 진단' + 시스템 탭 $ops_checks = ops_checks_cached(120, $fresh_ops); $g['ops_checks'] = $ops_checks; $g['ops_failed'] = ops_failed_count($ops_checks); $g['ops_critical_count'] = count(array_filter( $ops_checks, fn($c) => !$c['ok'] && $c['severity'] === 'critical' )); $g['ops_warn_count'] = count(array_filter( $ops_checks, fn($c) => !$c['ok'] && $c['severity'] !== 'critical' )); // (10) 예보·대응 신호 — 대응 필요 패널이 상시 영역이므로 전역에서 계산 $g['forecasts'] = []; $g['risk_items_all'] = []; if (FORECAST_ENABLED) { $fc_cfg = [ 'temp_high' => METRIC_TEMP_HIGH_C, 'temp_low' => METRIC_TEMP_LOW_C, 'rh_high' => METRIC_RH_HIGH, 'rh_low' => (METRIC_RH_LOW >= 0 ? METRIC_RH_LOW : null), // -1 = 비활성 'dew_margin' => METRIC_DEWPOINT_MARGIN_C, // 0 이하 = 결로 판정 스킵 'warn_temp' => FORECAST_WARN_MARGIN_TEMP_C, 'warn_rh' => FORECAST_WARN_MARGIN_RH, 'horizon_min'=> FORECAST_HORIZON_MIN, 'min_points' => FORECAST_MIN_POINTS, 'min_r2' => FORECAST_MIN_R2, 'rate_temp_10' => FORECAST_RATE_TEMP_C_PER_10MIN, 'rate_rh_10' => FORECAST_RATE_RH_PER_10MIN, ]; $recent_stmt = $db->prepare( "SELECT temperature_c, humidity_percent, UNIX_TIMESTAMP(measured_at) AS t FROM leak_sensor_metric WHERE sensor_id = :sid ORDER BY id DESC LIMIT :lim" ); foreach ($statuses as $s) { $sid = (int)$s['sensor_id']; $metric = $s['latest_metric'] ?? null; $temp = ($metric && $metric['temperature_c'] !== null) ? (float)$metric['temperature_c'] : null; $hum = ($metric && $metric['humidity_percent'] !== null) ? (float)$metric['humidity_percent'] : null; $recent = []; if ($temp !== null || $hum !== null) { $recent_stmt->bindValue(':sid', $sid, PDO::PARAM_INT); $recent_stmt->bindValue(':lim', (int)FORECAST_RECENT_POINTS, PDO::PARAM_INT); $recent_stmt->execute(); foreach (array_reverse($recent_stmt->fetchAll()) as $rr) { // 오래된→최신 $recent[] = [ 't' => (int)$rr['t'], 'temp' => $rr['temperature_c'] !== null ? (float)$rr['temperature_c'] : null, 'hum' => $rr['humidity_percent'] !== null ? (float)$rr['humidity_percent'] : null, ]; } } $fc = forecast_metric($temp, $hum, $recent, $fc_cfg); $g['forecasts'][$sid] = $fc; // 신선도 — offline 은 대시보드 기존 판정($s['is_offline'])을 단일 진실원으로 사용 $fr = forecast_freshness(strtotime($s['last_seen'] ?? '') ?: null, $now, heartbeat_timeout_for($sid)); $fr['offline'] = (bool)$s['is_offline']; if ($fr['offline']) { $fr['imminent'] = false; } $g['risk_items_all'] = array_merge($g['risk_items_all'], risk_items( ['sensor_id' => $sid, 'name' => $s['sensor_name'], 'location' => $s['device_location'], 'is_leak' => (int)($s['leak_confirmed'] ?? $s['is_leak'] ?? 0)], $fc, $fr )); } usort($g['risk_items_all'], fn($a, $b) => ($a['severity'] === 'critical' ? 0 : 1) <=> ($b['severity'] === 'critical' ? 0 : 1)); } // (11) 시스템 상태 히어로 — 누수(is_leak)·미복구 사고를 FORECAST_ENABLED 와 무관하게 반영 [$g['system_state'], $g['system_title'], $g['system_detail']] = dash_hero_state($g); $g['hero_alert_lines'] = dash_hero_alert_lines($db, $g); // (12) 탭 배지 $g['badge_live'] = $g['alert_count'] + $g['offline_count'] + $g['leak_active_count']; $g['badge_system'] = $g['ops_critical_count']; return $g; } // 히어로 상태 판정. 우선순위: 누수 > 온습도 경보 > 오프라인 > 미복구 사고 > 중요 진단 > 정상. function dash_hero_state(array $g): array { $parts = []; if ($g['leak_active_count'] > 0) { $parts[] = "누수 {$g['leak_active_count']}개"; } if ($g['alert_count'] > 0) { $parts[] = "임계 경보 {$g['alert_count']}개"; } if ($g['offline_count'] > 0) { $parts[] = "오프라인 {$g['offline_count']}개"; } if ($g['unrecovered_count'] > 0) { $parts[] = "미복구 사고 {$g['unrecovered_count']}건"; } if ($g['ops_critical_count'] > 0) { $parts[] = "중요 진단 {$g['ops_critical_count']}건"; } $detail = implode(' · ', $parts); if ($g['leak_active_count'] > 0 && $g['alert_count'] > 0) { return ['danger', '누수·온습도 경보 발생', $detail]; } if ($g['leak_active_count'] > 0) { return ['danger', '누수 감지 발생', $detail]; } if ($g['alert_count'] > 0) { return ['danger', '온습도 경보 발생', $detail]; } if ($g['offline_count'] > 0) { return ['warning', '장비 오프라인 확인 필요', $detail]; } if ($g['unrecovered_count'] > 0) { return ['warning', '누수 사고 미복구', $detail . ' — 이력 조회 탭에서 확인하세요.']; } if ($g['ops_critical_count'] > 0) { return ['warning', '운영 확인 필요', "중요 진단 {$g['ops_critical_count']}건을 확인하세요."]; } return ['normal', '정상 감시 중', '현재 누수·온습도 경보와 중요 운영 경고가 없습니다.']; } // 히어로 상세 줄 — 경보/누수 중 센서별 "누가·어디서·얼마나(값)·언제부터·SMS 나갔는지". // 경보 중일 때만 실행되는 소량 쿼리(센서당 2회 이하). function dash_hero_alert_lines(PDO $db, array $g): array { $lines = []; $now = time(); $metric_tag_like = "(message LIKE '" . SMS_TAG_HIGH_TEMP . "%' OR message LIKE '" . SMS_TAG_LOW_TEMP . "%' OR message LIKE '" . SMS_TAG_HIGH_HUMIDITY . "%' OR message LIKE '" . SMS_TAG_LOW_HUMIDITY . "%')"; $sms_metric_stmt = $db->prepare(" SELECT sent_at, status FROM leak_sms_log WHERE sensor_id = :sid AND {$metric_tag_like} ORDER BY id DESC LIMIT 1 "); $sms_leak_stmt = $db->prepare(" SELECT sent_at, status FROM leak_sms_log WHERE sensor_id = :sid AND message LIKE '" . SMS_TAG_LEAK_ALERT . "%' ORDER BY id DESC LIMIT 1 "); $start_stmt = $db->prepare(" SELECT MAX(measured_at) FROM leak_sensor_metric WHERE sensor_id = :sid AND metric_status = 'normal' "); $fmt_since = function (?string $start_at) use ($now): string { $ts = $start_at !== null ? strtotime($start_at) : false; if ($ts === false) { return ''; } $min = max(0, (int)round(($now - $ts) / 60)); return '시작 ' . date('H:i', $ts) . ' · ' . $min . '분 경과'; }; $fmt_sms = function ($row): string { if (!$row) { return 'SMS 기록 없음'; } $t = date('H:i', strtotime($row['sent_at'])); return $row['status'] === 'success' ? "SMS 성공 {$t}" : "SMS 실패 {$t}"; }; foreach ($g['statuses'] as $s) { $sid = (int)$s['sensor_id']; // 누수 중(오프라인 아님) — 디바운스 확정 기준(SMS 와 일치) if ((int)($s['leak_confirmed'] ?? $s['is_leak'] ?? 0) === 1 && !$s['is_offline']) { $start = null; foreach ($g['unrecovered_incidents'] as $inc) { if ((int)$inc['sensor_id'] === $sid) { $start = $inc['started_at']; break; } } $sms_leak_stmt->execute([':sid' => $sid]); $bits = array_filter([$fmt_since($start), $fmt_sms($sms_leak_stmt->fetch())]); $lines[] = [ 'severity' => 'danger', 'text' => "{$s['sensor_name']} · {$s['device_location']} — 누수 감지" . ($bits ? ' (' . implode(' · ', $bits) . ')' : ''), ]; } // 온습도 임계 경보 중(오프라인 아님) if ($s['is_alerting'] && !$s['is_offline']) { $metric = $s['latest_metric']; $val = ($s['metric_status'] === 'high_temp' || $s['metric_status'] === 'low_temp') ? metric_display($metric['temperature_c'] ?? null, '°C') : metric_display($metric['humidity_percent'] ?? null, '%'); $start_stmt->execute([':sid' => $sid]); $last_normal = $start_stmt->fetchColumn() ?: null; $sms_metric_stmt->execute([':sid' => $sid]); $bits = array_filter([$fmt_since($last_normal), $fmt_sms($sms_metric_stmt->fetch())]); $lines[] = [ 'severity' => 'danger', 'text' => "{$s['sensor_name']} · {$s['device_location']} — " . metric_status_label($s['metric_status']) . " {$val}" . ($bits ? ' (' . implode(' · ', $bits) . ')' : ''), ]; } } return $lines; } // ── live 탭: 전역 데이터만으로 충분(추가 쿼리 없음) ───────── function load_tab_live(PDO $db, array $g): array { return []; } // ── trend 탭: 추이 차트 + 통계 위젯 ───────────────────────── function load_tab_trend(PDO $db, array $g): array { $t = []; // 추이 차트 대상 센서: METRIC_SENSOR_IDS 중 첫 수신 센서 우선, 없으면 첫 센서 $status_ids = array_map(fn($s) => (int)$s['sensor_id'], $g['statuses']); $chart_sensor_id = 0; foreach (METRIC_SENSOR_IDS as $mid) { if (in_array((int)$mid, $status_ids, true)) { $chart_sensor_id = (int)$mid; break; } } if ($chart_sensor_id === 0) { $chart_sensor_id = $status_ids[0] ?? 0; } $chart_sensor_name = ''; foreach ($g['statuses'] as $s) { if ((int)$s['sensor_id'] === $chart_sensor_id) { $chart_sensor_name = $s['sensor_name']; break; } } $t['chart_sensor_id'] = $chart_sensor_id; $t['chart_sensor_name'] = $chart_sensor_name; $t['chart_range'] = dash_current_range(); $range_days = DASH_RANGES[$t['chart_range']]; $chart_points = []; if ($chart_sensor_id > 0) { $chart_stmt = $db->prepare(" SELECT measured_at, temperature_c, humidity_percent FROM leak_sensor_metric WHERE sensor_id = :sid AND measured_at >= DATE_SUB(NOW(), INTERVAL :days DAY) ORDER BY id DESC LIMIT 600 "); $chart_stmt->bindValue(':sid', $chart_sensor_id, PDO::PARAM_INT); $chart_stmt->bindValue(':days', $range_days, PDO::PARAM_INT); $chart_stmt->execute(); foreach (array_reverse($chart_stmt->fetchAll()) as $cr) { // 오래된→최신 (그래프 좌→우) $chart_points[] = [ 't' => $cr['measured_at'], 'temp' => $cr['temperature_c'] !== null ? (float)$cr['temperature_c'] : null, 'hum' => $cr['humidity_percent'] !== null ? (float)$cr['humidity_percent'] : null, ]; } } $t['chart_points'] = $chart_points; // 기간 내 최고 온도(피크 마커) $peak_i = null; $peak_v = null; foreach ($chart_points as $ci => $cp) { if ($cp['temp'] !== null && ($peak_v === null || $cp['temp'] > $peak_v)) { $peak_v = (float)$cp['temp']; $peak_i = $ci; } } $t['peak_i'] = $peak_i; $t['peak_v'] = $peak_v; // 7일 일별: 온습도 경보 측정 vs 누수 감지 (라인차트 + 게이지 스파크 공용) $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); $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--) { $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; } $t['trend_days'] = $trend_days; $t['trend_metric'] = $trend_metric; $t['trend_leak'] = $trend_leak; $t['trend_max'] = max(1, max(array_merge($trend_metric, $trend_leak))); $t['spark_alerts'] = $spark_alerts; $spark_today = end($spark_alerts); reset($spark_alerts); if ($g['alert_count'] > 0 || $g['leak_active_count'] > 0) { $t['ov_badge'] = 'crit'; $t['ov_badge_txt'] = '경보'; } elseif ($g['offline_count'] > 0 || $spark_today > 0) { $t['ov_badge'] = 'warn'; $t['ov_badge_txt'] = '주의'; } else { $t['ov_badge'] = 'ok'; $t['ov_badge_txt'] = '정상'; } // 경보 종류 도넛 (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)($mstatus_map['high_temp'] ?? 0), 'cat' => 'hot'], ['label' => '고습', 'val' => (int)($mstatus_map['high_humidity'] ?? 0), 'cat' => 'humid'], ['label' => '누수', 'val' => $leak_alert_30d, 'cat' => 'leak'], ['label' => '저온', 'val' => (int)($mstatus_map['low_temp'] ?? 0), 'cat' => 'cold'], ['label' => '저습', 'val' => (int)($mstatus_map['low_humidity'] ?? 0), 'cat' => 'dry'], ]; usort($donut_segs, fn($a, $b) => $b['val'] <=> $a['val']); $t['donut_segs'] = $donut_segs; $t['donut_total'] = array_sum(array_column($donut_segs, 'val')); // 경보 최다 센서 리더보드 (30일) — 경보 발생 센서가 있으면 렌더(구 대시보드 동작 유지) $t['board_rows'] = []; $board_metric = $db->query(" SELECT sensor_id, COUNT(*) c FROM leak_sensor_metric WHERE measured_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) AND metric_status <> 'normal' GROUP BY sensor_id ")->fetchAll(PDO::FETCH_KEY_PAIR); $board_leak = $db->query(" SELECT sensor_id, COUNT(*) c FROM leak_sensor_log WHERE event_type = 'leak_alert' AND sensor_time >= DATE_SUB(NOW(), INTERVAL 30 DAY) GROUP BY sensor_id ")->fetchAll(PDO::FETCH_KEY_PAIR); $board_combined = []; foreach ([$board_metric, $board_leak] as $src) { foreach ($src as $sid => $c) { $board_combined[(int)$sid] = ($board_combined[(int)$sid] ?? 0) + (int)$c; } } arsort($board_combined); foreach (array_slice($board_combined, 0, 6, true) as $sid => $c) { [, $board_name] = sensor_label((int)$sid, null, null); // 관리주체 라벨(미지정 시 "센서{id}") $t['board_rows'][] = ['sensor_name' => $board_name, 'c' => $c]; } $t['board_max'] = $t['board_rows'] ? max(array_column($t['board_rows'], 'c')) : 0; return $t; } // ── history 탭: 이력 테이블 4종 + 30일 SMS 발송 통계 ──────── function load_tab_history(PDO $db, array $g): array { $t = []; // 누수 사고 20건 + 사진(leak_photo) — N+1 대신 IN 절 1회 배치 $t['leak_incidents'] = []; $t['leak_photos_by_log'] = []; if (table_exists($db, 'leak_incident')) { $t['leak_incidents'] = $db->query(" SELECT id, sensor_id, sensor_name, device_location, started_at, recovered_at, log_id_alert FROM leak_incident ORDER BY id DESC LIMIT 20 ")->fetchAll(); foreach ($t['leak_incidents'] as &$_r) { [$_r['device_location'], $_r['sensor_name']] = sensor_label((int)$_r['sensor_id'], $_r['device_location'] ?? null, $_r['sensor_name'] ?? null); } unset($_r); $log_ids = array_values(array_unique(array_filter(array_map( fn($inc) => $inc['log_id_alert'] !== null ? (int)$inc['log_id_alert'] : 0, $t['leak_incidents'] ), fn($v) => $v >= 1))); if (!empty($log_ids) && table_exists($db, 'leak_photo')) { $ph = implode(',', array_fill(0, count($log_ids), '?')); $photo_stmt = $db->prepare(" SELECT id, log_id, captured_at FROM leak_photo WHERE log_id IN ({$ph}) ORDER BY id ASC "); $photo_stmt->execute($log_ids); foreach ($photo_stmt->fetchAll() as $p) { $t['leak_photos_by_log'][(int)$p['log_id']][] = $p; } } } // 최근 온습도 측정 30건 $t['recent_metrics'] = $db->query(" SELECT sensor_id, sensor_name, device_location, temperature_c, humidity_percent, metric_status, measured_at FROM leak_sensor_metric ORDER BY id DESC LIMIT 30 ")->fetchAll(); foreach ($t['recent_metrics'] as &$_r) { [$_r['device_location'], $_r['sensor_name']] = sensor_label((int)$_r['sensor_id'], $_r['device_location'] ?? null, $_r['sensor_name'] ?? null); } unset($_r); // 센서 이벤트 로그 30건 $t['logs'] = $db->query(" SELECT id, sensor_id, sensor_name, device_location, event_type, is_leak, sensor_time FROM leak_sensor_log ORDER BY id DESC LIMIT 30 ")->fetchAll(); foreach ($t['logs'] as &$_r) { [$_r['device_location'], $_r['sensor_name']] = sensor_label((int)$_r['sensor_id'], $_r['device_location'] ?? null, $_r['sensor_name'] ?? null); } unset($_r); // SMS 발송 이력 20건 $t['sms_logs'] = $db->query(" SELECT * FROM leak_sms_log ORDER BY id DESC LIMIT 20 ")->fetchAll(); foreach ($t['sms_logs'] as &$_r) { [, $_r['sensor_name']] = sensor_label((int)($_r['sensor_id'] ?? 0), null, $_r['sensor_name'] ?? null); } unset($_r); // 30일 평균 온습도 — 센서별 분리(전 센서 혼합 평균은 위치별 환경 차이를 뭉갬) $t['metric_avgs'] = $db->query(" SELECT sensor_id, AVG(temperature_c) AS avg_temp, AVG(humidity_percent) AS avg_hum, COUNT(*) AS n FROM leak_sensor_metric WHERE measured_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) AND (temperature_c IS NOT NULL OR humidity_percent IS NOT NULL) GROUP BY sensor_id ORDER BY sensor_id ")->fetchAll(); foreach ($t['metric_avgs'] as &$_r) { [$_r['location'], $_r['name']] = sensor_label((int)$_r['sensor_id'], null, null); } unset($_r); // 30일 SMS 발송 통계(압축 6항목 — 상세 분류는 monthly_report.php) $row = $db->query(" SELECT " . sms_summary_select_sql() . ", COUNT(*) AS total FROM leak_sms_log WHERE sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) ")->fetch(); $alert_total = (int)($row['high_temp'] ?? 0) + (int)($row['low_temp'] ?? 0) + (int)($row['high_humidity'] ?? 0) + (int)($row['low_humidity'] ?? 0); $t['sms_stats'] = [ 'total' => (int)($row['total'] ?? 0), 'alert_total' => $alert_total, 'leak_alert' => (int)($row['leak_alert'] ?? 0), 'recovery' => (int)($row['recovery'] ?? 0) + (int)($row['leak_recovery'] ?? 0) + (int)($row['device_online'] ?? 0), 'offline' => (int)($row['device_offline'] ?? 0), 'fail' => (int)($row['sms_fail'] ?? 0), ]; return $t; } // ── system 탭: 자가진단 상세 + 수집 거부 상세 + SMS 채널 점검 + 설정 요약 ── function load_tab_system(PDO $db, array $g): array { $t = []; // 자가진단 목록 분리(이슈 우선 정렬 / 정상 접기) $t['ops_issues'] = array_values(array_filter($g['ops_checks'], fn($c) => !$c['ok'])); $t['ops_ok'] = array_values(array_filter($g['ops_checks'], fn($c) => $c['ok'])); usort($t['ops_issues'], fn($a, $b) => ($a['severity'] === 'critical' ? 0 : 1) <=> ($b['severity'] === 'critical' ? 0 : 1)); // 최근 거부 출처(IP·사유·모드) — '우리 노드 오설정 vs 외부 스캐너' 판별용 $t['reject_events'] = read_ingest_reject_events(10); // SMS 실패 30일(채널 건강) $t['sms_fail_30d'] = (int)$db->query(" SELECT COUNT(*) FROM leak_sms_log WHERE status = 'fail' AND sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) ")->fetchColumn(); // 임계·설정 요약(읽기 전용 — config.local.php 로 조정) $config_rows = [ ['고온 임계', METRIC_TEMP_HIGH_C . ' ℃'], ['저온 임계', METRIC_TEMP_LOW_C . ' ℃'], ['고습 임계', METRIC_RH_HIGH . ' %'], ['저습 임계', (METRIC_RH_LOW >= 0 ? METRIC_RH_LOW . ' %' : '비활성')], ['결로 마진 임계', (METRIC_DEWPOINT_MARGIN_C > 0 ? METRIC_DEWPOINT_MARGIN_C . ' ℃' : '비활성')], ['경보 재발송 쿨다운', (METRIC_ALERT_COOLDOWN_SEC / 60) . '분'], ['누수 재발송 쿨다운', (LEAK_ALERT_COOLDOWN_SEC / 60) . '분'], ]; // 누수 디바운스 확인 지연(활성 시) if (LEAK_DEBOUNCE_ENABLED) { $config_rows[] = ['누수 확인 지연', '감지 ' . LEAK_ALERT_CONFIRM_SEC . 's / 복구 ' . LEAK_RECOVERY_CONFIRM_SEC . 's']; } // 오프라인 판정 — 센서 유형별 타임아웃(온습도 1시간 주기 대응) $config_rows[] = ['오프라인 판정', '누수 ' . (int)round(HEARTBEAT_TIMEOUT_SEC / 60) . '분 / 온습도 ' . (int)round(METRIC_HEARTBEAT_TIMEOUT_SEC / 60) . '분 무수신']; $config_rows[] = ['SMS 잔액 경고 임계', SMS_BALANCE_WARN_THRESHOLD . '건']; $config_rows[] = ['온습도 센서', implode(', ', array_map(fn($i) => $i . '번', METRIC_SENSOR_IDS))]; $config_rows[] = ['누수 센서', implode(', ', array_map(fn($i) => $i . '번', LEAK_SENSOR_IDS))]; // 수신자: 전역 + 센서별 그룹(지정된 것만, 번호 마스킹) $config_rows[] = ['수신자 · 전역', dash_mask_recipients(SMS_RECIPIENTS)]; foreach (SENSOR_RECIPIENTS as $rsid => $rlist) { $config_rows[] = ["수신자 · {$rsid}번", dash_mask_recipients($rlist)]; } $t['config_rows'] = $config_rows; return $t; }