'정상', '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; } } // 추이 차트 기간(오늘/3일/7일) — 링크 토글(폐쇄망: 외부 라이브러리 없음, 서버 렌더) $RANGE_DAYS = ['today' => 1, '3d' => 3, '7d' => 7]; $chart_range = (isset($_GET['range']) && isset($RANGE_DAYS[$_GET['range']])) ? $_GET['range'] : '3d'; $range_days = $RANGE_DAYS[$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(); $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' )); // 컴팩트 표시: 실패(확인 필요) 항목만 카드로 노출하고 정상 항목은 접는다(패널 길이 억제). $ops_issues = array_values(array_filter($ops_checks, fn($c) => !$c['ok'])); $ops_ok = array_values(array_filter($ops_checks, fn($c) => $c['ok'])); usort($ops_issues, fn($a, $b) => ($a['severity'] === 'critical' ? 0 : 1) <=> ($b['severity'] === 'critical' ? 0 : 1)); $ops_ok_count = count($ops_ok); $ops_pill = $ops_critical_count > 0 ? 'crit' : ($ops_warn_count > 0 ? 'warn' : 'ok'); $ops_pill_text = $ops_failed > 0 ? '확인 필요' : '정상'; // ── 상단 시스템 상태 집계 ────────────────────────────────── $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; } } // 온습도(SHT30) 센서 카드 목록: 기대 온습도 센서(METRIC_SENSOR_IDS)는 미수신도 // placeholder(null)로 항상 표시한다(누수 카드와 동일 원칙 — 최초 기동 전 SHT30 이 // 화면에서 조용히 빠지지 않게). 실제 온습도 데이터가 있는 센서도 포함하되, 순수 // 누수 센서(측정값 없음)는 아래 "누수 감지" 섹션에서만 표시한다(중복 방지). $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); // 최근 누수 사고(에피소드) 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 = '현재 온습도 경보와 중요 운영 경고가 없습니다.'; } // ── 예보·대응 신호 (forecast.php — 순수계산) ────────────────────────────── // 기존 측정 데이터만으로 임계 여유·추세 ETA·이슬점·변화율·신선도를 산출한다. // $statuses 는 모든 센서(누수 노드 포함)를 담으므로 한 번만 순회한다(중복 없음). require_once __DIR__ . '/forecast.php'; $forecasts = []; // sensor_id => forecast_metric() 결과 (카드 예보줄에 사용) $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, ]; $now_ts = time(); $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); $forecasts[$sid] = $fc; // 신선도 — offline 은 대시보드 기존 판정($s['is_offline'])을 단일 진실원으로 사용 $fr = forecast_freshness(strtotime($s['last_seen'] ?? '') ?: null, $now_ts, HEARTBEAT_TIMEOUT_SEC); $fr['offline'] = (bool)$s['is_offline']; if ($fr['offline']) { $fr['imminent'] = false; } $risk_items_all = array_merge($risk_items_all, risk_items( ['sensor_id' => $sid, 'name' => $s['sensor_name'], 'location' => $s['device_location'], 'is_leak' => (int)($s['is_leak'] ?? 0)], $fc, $fr )); } // critical 먼저, 그다음 warn 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 )) . ')'; }; // (1) 가동률 게이지 — 온라인 센서 비율 + 7일 일별 경보건수 스파크라인 + 상태 배지 $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) ")->fetchAll(PDO::FETCH_KEY_PAIR); $spark_alerts = []; for ($i = 6; $i >= 0; $i--) { $spark_alerts[] = (int)($spark_map[date('Y-m-d', strtotime("-$i day"))] ?? 0); } $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(" SELECT sensor_name, COUNT(*) c FROM leak_sms_log WHERE sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) AND " . $ov_like($OV_ALERT_TAGS) . " 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; } $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); } $trend_max = max(1, max(array_merge($trend_alerts, $trend_recovers))); // (4) 경보 종류 도넛 — $summary(30일) 재사용(추가 쿼리 없음) $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'], ]; usort($donut_segs, fn($a, $b) => $b['val'] <=> $a['val']); $donut_total = array_sum(array_column($donut_segs, 'val')); // (5) 추이 차트 피크(기간 내 최고 온도) — $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; } } ?> 온습도·누수 통합 모니터링 대시보드 <?= htmlspecialchars(APP_VERSION) ?>

온습도·누수 통합 모니터링 대시보드

설치 점검 월간 보고 보안 증적 로그아웃


30초 자동 새로고침
0): ?>

경보 SMS 발송 실패

최근 1시간 내 경보/오프라인 SMS 건 발송 실패 — 미수신 가능. Cafe24 SMS 잔액·계정·발신번호를 확인하세요.

0): ?>

수집 거부 건 (최근 24시간)

'서명불일치(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); ?>

대응 필요 건

— 권장 조치
담당자
관제 개요 Overview

가동률 온라인 센서

% / 온라인
7일 경보 $spv) { $spx = $sp_n > 1 ? $spi * (84 / ($sp_n - 1)) : 42; $spy = 4 + (1 - $spv / $sp_max) * 22; $sp_pts[] = sprintf('%.1f,%.1f', $spx, $spy); } $sp_last = explode(',', end($sp_pts)); ?>
경보 센서 개
오프라인 개

경보 종류 최근 30일

0): ?>
건
최근 30일 경보 없음

경보 최다 센서 30일 · TOP

$br): $b_w = $board_max > 0 ? (int)round((int)$br['c'] / $board_max * 100) : 0; ?>
건
최근 30일 경보 발생 센서 없음

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

경보 발생 복구
1 ? $tpl + $tplot_w * $i / ($tn - 1) : $tpl; }; $t_y = function ($v) use ($tpt, $tplot_h, $trend_max) { return $tpt + $tplot_h * (1 - $v / $trend_max); }; $t_line = function (array $vals) use ($t_x, $t_y) { $pts = []; foreach ($vals as $i => $v) { $pts[] = sprintf('%.1f,%.1f', $t_x($i), $t_y($v)); } return implode(' ', $pts); }; $t_peak_i = 0; foreach ($trend_alerts as $i => $v) { if ($v > $trend_alerts[$t_peak_i]) { $t_peak_i = $i; } } ?> 0): ?> 1 ? [0, intdiv($tn - 1, 2), $tn - 1] : [0]; foreach ($t_lbl_idx as $li): $anchor = $li === 0 ? 'start' : ($li === $tn - 1 ? 'end' : 'middle'); ?>
실시간 상태 Live

센서 현재 상태 30초 자동 새로고침

$s): // 미수신 placeholder: 기대 온습도 센서(METRIC_SENSOR_IDS)가 아직 보고 전이면 // '데이터 없음' 카드로 항상 노출한다(누수 카드와 동일 — 화면에서 사라지지 않게). if ($s === null): ?>
번 센서 (온습도)
데이터 없음
아직 수신된 온습도 데이터가 없습니다.
임계 경보 > 정상 $status_code = $s['metric_status'] ?? 'normal'; if ($s['is_offline']) { $card_class = 'offline'; $badge_class = 'offline'; $badge_text = $s['is_alerting'] ? ('오프라인 (' . metric_status_label($status_code) . ' 중 끊김)') : '오프라인'; } elseif ($s['is_alerting']) { $card_class = 'alert'; $badge_class = 'alert'; $badge_text = metric_status_label($status_code) . ' 경보!'; } else { $card_class = ''; $badge_class = 'ok'; $badge_text = '정상'; } $temp_chip_class = ''; $hum_chip_class = ''; if ($status_code === 'high_temp') { $temp_chip_class = 'hot'; } elseif ($status_code === 'low_temp') { $temp_chip_class = 'cold'; } elseif ($status_code === 'high_humidity') { $hum_chip_class = 'humid'; } elseif ($status_code === 'low_humidity') { $hum_chip_class = 'dry'; } ?>

/
온도 습도
측정:
측정값 없음
최종확인: (분 전)
$h['state'], 'text' => $h['label'] . ' 여유 ' . $h['margin'] . $h['unit']]; } foreach (['temp', 'hum'] as $vk) { $e = $fc['trend'][$vk]['eta'] ?? null; if ($e) { $chips[] = ['state' => 'near', 'text' => '↗ ~' . (int)$e['min'] . '분 뒤 ' . metric_status_label($e['key'])]; } } foreach ($fc['rate'] as $r) { $chips[] = ['state' => 'near', 'text' => ($r['spike'] === 'rise' ? '급상승 ' : '급락 ') . ($r['per10'] > 0 ? '+' : '') . $r['per10'] . '/10분']; } if (!empty($fc['dew']['risk'])) { $chips[] = ['state' => 'near', 'text' => '결로주의 (마진 ' . $fc['dew']['margin'] . '℃)']; } ?>
예보: 임계 여유 충분

아직 수신된 센서 데이터가 없습니다.

누수 감지

$leak_status): // 누수 센서 상태 표시: 오프라인 > 누수 > 정상 우선순위 if ($leak_status === null) { $leak_card_class = ''; $leak_badge_class = 'offline'; $leak_badge_text = '데이터 없음'; } elseif ($leak_status['is_offline']) { $leak_card_class = 'offline'; $leak_badge_class = 'offline'; $leak_badge_text = ((int)($leak_status['is_leak'] ?? 0) === 1) ? '오프라인 (누수 중 끊김)' : '오프라인'; } elseif ((int)($leak_status['is_leak'] ?? 0) === 1) { $leak_card_class = 'leak'; $leak_badge_class = 'leak'; $leak_badge_text = '누수'; } else { $leak_card_class = ''; $leak_badge_class = 'ok'; $leak_badge_text = '정상'; } ?>
번 센서 (누수)
아직 수신된 누수 센서 데이터가 없습니다.

/
최종확인: (분 전)
누수 사고 이력 최근 20건
#센서위치 감지 시각복구 시각사진
미복구 -
아직 누수 사고 이력이 없습니다.
추세 분석 Trend

온습도 추이 0): ?> · 최근 개

온도(°C) 습도(%)
'오늘', '3d' => '3일', '7d' => '7일'] as $rk => $rlabel): ?> >
= 2): ?>
$p['temp'], $chart_points), fn($v) => $v !== null)); $hums = array_values(array_filter(array_map(fn($p) => $p['hum'], $chart_points), fn($v) => $v !== null)); // 온도 축 범위 (여유 패딩) $t_min = !empty($temps) ? min($temps) : 0; $t_max = !empty($temps) ? max($temps) : 1; if ($t_min === $t_max) { $t_min -= 1; $t_max += 1; } $t_pad = ($t_max - $t_min) * 0.1; $t_min -= $t_pad; $t_max += $t_pad; // 습도 축 범위 $h_min = !empty($hums) ? min($hums) : 0; $h_max = !empty($hums) ? max($hums) : 1; if ($h_min === $h_max) { $h_min -= 1; $h_max += 1; } $h_pad = ($h_max - $h_min) * 0.1; $h_min -= $h_pad; $h_max += $h_pad; $x_at = function (int $i) use ($pad_l, $plot_w, $n): float { if ($n <= 1) { return $pad_l; } return $pad_l + ($plot_w * $i / ($n - 1)); }; $y_temp = function (?float $v) use ($pad_t, $plot_h, $t_min, $t_max): ?float { if ($v === null) { return null; } $r = ($t_max - $t_min) ?: 1; return $pad_t + $plot_h * (1 - (($v - $t_min) / $r)); }; $y_hum = function (?float $v) use ($pad_t, $plot_h, $h_min, $h_max): ?float { if ($v === null) { return null; } $r = ($h_max - $h_min) ?: 1; return $pad_t + $plot_h * (1 - (($v - $h_min) / $r)); }; // 결측(null)은 구간을 끊어 polyline 들을 만든다 $build_segments = function (string $key, callable $y_fn) use ($chart_points, $x_at): array { $segments = []; $current = []; foreach ($chart_points as $i => $p) { $val = $p[$key]; if ($val === null) { if (count($current) >= 2) { $segments[] = $current; } $current = []; continue; } $current[] = sprintf('%.1f,%.1f', $x_at($i), $y_fn((float)$val)); } if (count($current) >= 2) { $segments[] = $current; } return $segments; }; $temp_segments = $build_segments('temp', $y_temp); $hum_segments = $build_segments('hum', $y_hum); // 각 계열의 마지막 유효 지점(끝점 마커 + 최신값 라벨용) $last_temp_i = null; $last_temp_v = null; $last_hum_i = null; $last_hum_v = null; foreach ($chart_points as $i => $p) { if ($p['temp'] !== null) { $last_temp_i = $i; $last_temp_v = (float)$p['temp']; } if ($p['hum'] !== null) { $last_hum_i = $i; $last_hum_v = (float)$p['hum']; } } $fmt = fn($v) => rtrim(rtrim(number_format($v, 1, '.', ''), '0'), '.'); ?> 1 ? [0, intdiv($n - 1, 2), $n - 1] : [0]; foreach ($label_idx as $li): $lx = $x_at($li); $anchor = $li === 0 ? 'start' : ($li === $n - 1 ? 'end' : 'middle'); $tlabel = substr($chart_points[$li]['t'], 5, 11); // MM-DD HH:MM ?> $pad_t + $plot_h) { continue; } // 스케일 밖 $thr_line_cls = $axis === 'temp' ? 'line-temp' : 'line-hum'; $thr_label_cls = $axis === 'temp' ? 'axis-temp' : 'axis-hum'; $thr_lx = $axis === 'temp' ? ($pad_l + 4) : ($pad_l + $plot_w - 4); $thr_anchor = $axis === 'temp' ? 'start' : 'end'; ?>
추이를 표시할 측정 데이터가 충분하지 않습니다 (2개 이상 필요).
운영 이력 History

30일 운영 요약

고온 경보 SMS 건
저온 경보 SMS 건
고습 경보 SMS 건
저습 경보 SMS 건
정상 복귀 SMS 건
오프라인 SMS 건
장비 복구 SMS 건
누수감지 SMS 건
누수복구 SMS 건
SMS 실패 건
평균 온도
평균 습도
최근 온도
최근 습도

최근 온습도 측정 (30건)

최근 온습도 측정 30건 기준
측정시각센서위치 온도습도상태
아직 온습도 측정 데이터가 없습니다.

센서 이벤트 로그 (30건)

센서 이벤트 로그 30건 기준
'장비시작', 'periodic' => '정기보고', 'leak_alert' => '누수감지', 'leak_recovery' => '누수복구', ]; $evt = $row['event_type']; $is_leak_evt = ($evt === 'leak_alert' || $evt === 'leak_recovery' || (int)($row['is_leak'] ?? 0) === 1); ?>
#시각센서 위치이벤트
아직 센서 이벤트가 없습니다.

SMS 발송 이력 (최근 20건)

SMS 발송 이력 최근 20건
#발송시각센서수신자상태결과
성공 실패
아직 SMS 발송 이력이 없습니다.