'정상', '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; } // 테이블 존재 확인(누수 미통합 서버에서 leak_incident/leak_photo 부재 시 graceful degrade). function table_exists(PDO $db, string $table): bool { try { $stmt = $db->prepare(" SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table "); $stmt->execute([':table' => $table]); return (int)$stmt->fetchColumn() > 0; } catch (Throwable $e) { return false; } } // ── 센서별 현재 상태 + 최신 측정값 ────────────────────────── $statuses = $db->query("SELECT * FROM leak_sensor_status ORDER BY sensor_id")->fetchAll(); // 센서별 최신 leak_sensor_metric (최대 id 기준) $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; } // 각 센서에 대해 실시간 오프라인/경보 상태 계산 $now = time(); foreach ($statuses as &$s) { $sid = (int)$s['sensor_id']; $metric = $latest_metrics[$sid] ?? null; $s['latest_metric'] = $metric; $last_seen_ts = strtotime($s['last_seen'] ?? '2000-01-01'); $elapsed = $now - $last_seen_ts; // offline_alerted 플래그가 1이거나, last_seen이 HEARTBEAT_TIMEOUT_SEC 이상 경과했으면 오프라인 $s['is_offline'] = ($s['offline_alerted'] == 1) || ($elapsed >= HEARTBEAT_TIMEOUT_SEC); $s['offline_elapsed_min'] = (int)round($elapsed / 60); $status_code = $metric['metric_status'] ?? 'normal'; $s['metric_status'] = $status_code; $s['is_alerting'] = ($status_code !== 'normal'); } unset($s); // 참조 해제 // ── 온습도 추이 차트용: 한 센서의 최근 100개 측정 ─────────── // 기본 sensor_id=2, 없으면 첫 센서 $chart_sensor_ids = array_map(fn($s) => (int)$s['sensor_id'], $statuses); $chart_sensor_id = in_array(2, $chart_sensor_ids, true) ? 2 : ($chart_sensor_ids[0] ?? 0); $chart_sensor_name = ''; foreach ($statuses as $s) { if ((int)$s['sensor_id'] === $chart_sensor_id) { $chart_sensor_name = $s['sensor_name']; break; } } $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 ORDER BY id DESC LIMIT 100 "); $chart_stmt->execute([':sid' => $chart_sensor_id]); $chart_rows = $chart_stmt->fetchAll(); // 오래된 → 최신 순으로 (그래프 좌→우) $chart_rows = array_reverse($chart_rows); foreach ($chart_rows 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, ]; } } // ── 최근 측정 이력 (leak_sensor_metric 30건) ───────────────────── $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(); // ── 최근 센서 이벤트 로그 (leak_sensor_log: startup/periodic/leak_alert/leak_recovery) ── $logs = $db->query(" SELECT id, sensor_name, device_location, event_type, is_leak, sensor_time FROM leak_sensor_log ORDER BY id DESC LIMIT 30 ")->fetchAll(); // ── 최근 SMS 발송 이력 ───────────────────────────────────── $sms_logs = $db->query(" SELECT * FROM leak_sms_log ORDER BY id DESC LIMIT 20 ")->fetchAll(); // ── 30일 운영 요약 ───────────────────────────────────────── // 종류별 임계 경보 SMS 건수 (leak_sms_log message prefix 기준) $sms_summary_rows = $db->query(" SELECT " . sms_summary_select_sql() . " FROM leak_sms_log WHERE sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) ")->fetch(); $summary = [ 'high_temp' => (int)($sms_summary_rows['high_temp'] ?? 0), 'low_temp' => (int)($sms_summary_rows['low_temp'] ?? 0), 'high_humidity' => (int)($sms_summary_rows['high_humidity'] ?? 0), 'low_humidity' => (int)($sms_summary_rows['low_humidity'] ?? 0), 'recovery' => (int)($sms_summary_rows['recovery'] ?? 0), 'offline' => (int)($sms_summary_rows['device_offline'] ?? 0), 'device_online' => (int)($sms_summary_rows['device_online'] ?? 0), 'leak_alert' => (int)($sms_summary_rows['leak_alert'] ?? 0), 'leak_recovery' => (int)($sms_summary_rows['leak_recovery'] ?? 0), 'sms_fail' => (int)($sms_summary_rows['sms_fail'] ?? 0), ]; $summary['alert_total'] = $summary['high_temp'] + $summary['low_temp'] + $summary['high_humidity'] + $summary['low_humidity']; // 30일 평균 온습도 + 최근 온습도(차트 센서 기준 최신값) $metric_avg = $db->query(" SELECT AVG(temperature_c) AS avg_temp, AVG(humidity_percent) AS avg_hum FROM leak_sensor_metric WHERE measured_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) ")->fetch(); $avg_temp = $metric_avg['avg_temp'] !== null ? round((float)$metric_avg['avg_temp'], 1) : null; $avg_hum = $metric_avg['avg_hum'] !== null ? round((float)$metric_avg['avg_hum'], 1) : null; $latest_overall = !empty($chart_points) ? end($chart_points) : null; $latest_temp = $latest_overall['temp'] ?? null; $latest_hum = $latest_overall['hum'] ?? null; // ── 운영 자가진단 (ops_checks.php) ───────────────────────── $ops_checks = collect_ops_checks(); $ops_failed = ops_failed_count($ops_checks); $ops_critical_count = count(array_filter( $ops_checks, fn($c) => !$c['ok'] && $c['severity'] === 'critical' )); $ops_warn_count = count(array_filter( $ops_checks, fn($c) => !$c['ok'] && $c['severity'] !== 'critical' )); // ── 상단 시스템 상태 집계 ────────────────────────────────── $offline_count = count(array_filter($statuses, fn($s) => $s['is_offline'])); $alert_count = count(array_filter($statuses, fn($s) => $s['is_alerting'] && !$s['is_offline'])); $normal_count = count($statuses) - $offline_count - $alert_count; // ── 경보 SMS 발송 실패 / 수집 거부 사유 (신뢰성·관측성) ───── // 최근 1시간 내 경보/오프라인/에스컬레이션 SMS 가 실패했는가(미수신 가능 경고). $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(); // 최근 24시간(오늘) 수집 거부 사유별 집계(왜 데이터가 안 들어오는가). $ingest_rejections = read_ingest_rejections(1); $rejection_total = array_sum($ingest_rejections); // ── 누수 모듈 현재 상태 + 사고/사진 ─────────────────── // 누수 센서(LEAK_SENSOR_IDS, config.php — 기본 [1])의 leak_sensor_status.is_leak 로 // 누수/정상을 표시한다. 오프라인 판정은 위 $statuses 계산과 동일한 로직을 // 따른다(행이 없으면 '데이터 없음'). 신규 누수 지점은 config.local.php 의 // 'LEAK_SENSOR_IDS' => [1, 3] 오버라이드로 카드가 추가된다. $leak_statuses = []; // sensor_id => leak_sensor_status 행 (미수신이면 null) 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; } } // 최근 누수 사고(에피소드) 20건 + 각 사고의 사진(leak_photo) — 테이블 없으면 graceful. $leak_incidents = []; $leak_photos_by_log = []; // log_id => [ ['id'=>..,'log_id'=>..,'captured_at'=>..], ... ] if (table_exists($db, 'leak_incident')) { $leak_incidents = $db->query(" SELECT id, sensor_name, device_location, started_at, recovered_at, log_id_alert FROM leak_incident ORDER BY id DESC LIMIT 20 ")->fetchAll(); if (!empty($leak_incidents) && table_exists($db, 'leak_photo')) { $photo_stmt = $db->prepare(" SELECT id, log_id, captured_at FROM leak_photo WHERE log_id = :lid ORDER BY id ASC "); foreach ($leak_incidents as $inc) { $lid = $inc['log_id_alert'] !== null ? (int)$inc['log_id_alert'] : 0; if ($lid < 1 || isset($leak_photos_by_log[$lid])) { continue; } $photo_stmt->execute([':lid' => $lid]); $leak_photos_by_log[$lid] = $photo_stmt->fetchAll(); } } } if ($alert_count > 0) { $system_state = 'danger'; $system_title = '온습도 경보 발생'; $system_detail = "임계 경보 센서 {$alert_count}개, 오프라인 {$offline_count}개"; } elseif ($offline_count > 0) { $system_state = 'warning'; $system_title = '장비 오프라인 확인 필요'; $system_detail = "오프라인 {$offline_count}개, 중요 진단 {$ops_critical_count}건"; } elseif ($ops_critical_count > 0) { $system_state = 'warning'; $system_title = '운영 확인 필요'; $system_detail = "중요 진단 {$ops_critical_count}건을 확인하세요."; } else { $system_state = 'normal'; $system_title = '정상 감시 중'; $system_detail = '현재 온습도 경보와 중요 운영 경고가 없습니다.'; } ?>
= htmlspecialchars($system_detail) ?>
최근 1시간 내 경보/오프라인 SMS = (int)$recent_alert_fail ?>건 발송 실패 — 미수신 가능. Cafe24 SMS 잔액·계정·발신번호를 확인하세요.
'서명불일치(API키/펌웨어 점검)', 'timestamp' => '시각오차(SNTP/RTC 점검)', 'key_default' => '기본 API키(미설정)', 'body_too_large' => '본문과대', 'invalid_json' => 'JSON오류', 'missing_field' => '필드누락', 'sensor_id_range' => 'sensor_id 범위', ]; $parts = []; foreach ($ingest_rejections as $r => $c) { $parts[] = htmlspecialchars($reason_ko[$r] ?? $r) . ' ' . (int)$c; } echo implode(' · ', $parts); ?>
아직 수신된 센서 데이터가 없습니다.
| 측정시각 | 센서 | 온도 | 습도 | 상태 | |
|---|---|---|---|---|---|
| = htmlspecialchars($row['measured_at']) ?> | = htmlspecialchars($row['sensor_name']) ?> | = htmlspecialchars(metric_display($row['temperature_c'], '°C')) ?> | = htmlspecialchars(metric_display($row['humidity_percent'], '%')) ?> | = htmlspecialchars(metric_status_label($status_code)) ?> | |
| 아직 온습도 측정 데이터가 없습니다. | |||||
| 시각 | 센서 | 이벤트 | ||
|---|---|---|---|---|
| = htmlspecialchars($row['sensor_time']) ?> | = htmlspecialchars($row['sensor_name']) ?> | = htmlspecialchars($event_labels[$evt]) ?> = htmlspecialchars($event_labels[$evt] ?? $evt) ?> | ||
| 아직 센서 이벤트가 없습니다. | ||||
| 발송시각 | 센서 | 수신자 | 상태 | ||
|---|---|---|---|---|---|
| = htmlspecialchars($row['sent_at']) ?> | = htmlspecialchars($row['sensor_name']) ?> | = htmlspecialchars(mask_phone($row['receivers'] ?? '')) ?> | 성공 실패 | ||
| 아직 SMS 발송 이력이 없습니다. | |||||