SESSION_TIMEOUT) { session_destroy(); header('Location: login.php?expired'); exit; } // 활동 시 로그인 시간 갱신 $_SESSION['login_time'] = time(); // ── Heartbeat: 오프라인 센서 체크 (HEARTBEAT_CHECK_INTERVAL_SEC 쓰로틀) ────── check_offline_sensors_throttled(); $db = get_db(); // ── 온습도 경보 상태 라벨/표시 헬퍼 ───────────────────────── // 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; } // ── 센서별 현재 상태 + 최신 측정값 ────────────────────────── $statuses = $db->query("SELECT * FROM sensor_status ORDER BY sensor_id")->fetchAll(); // 센서별 최신 sensor_metric (최대 id 기준) $latest_metrics = []; $metric_rows = $db->query(" SELECT sm.* FROM sensor_metric sm INNER JOIN ( SELECT sensor_id, MAX(id) AS latest_id FROM 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 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, ]; } } // ── 최근 측정 이력 (sensor_metric 30건) ───────────────────── $recent_metrics = $db->query(" SELECT sensor_id, sensor_name, device_location, temperature_c, humidity_percent, metric_status, measured_at FROM sensor_metric ORDER BY id DESC LIMIT 30 ")->fetchAll(); // ── 최근 센서 이벤트 로그 (sensor_log: startup/periodic) ──── $logs = $db->query(" SELECT id, sensor_name, device_location, event_type, sensor_time FROM sensor_log ORDER BY id DESC LIMIT 30 ")->fetchAll(); // ── 최근 SMS 발송 이력 ───────────────────────────────────── $sms_logs = $db->query(" SELECT * FROM sms_log ORDER BY id DESC LIMIT 20 ")->fetchAll(); // ── 30일 운영 요약 ───────────────────────────────────────── // 종류별 임계 경보 SMS 건수 (sms_log message prefix 기준) $sms_summary_rows = $db->query(" SELECT SUM(message LIKE '[고온경보]%') AS high_temp, SUM(message LIKE '[저온경보]%') AS low_temp, SUM(message LIKE '[고습경보]%') AS high_humidity, SUM(message LIKE '[저습경보]%') AS low_humidity, SUM(message LIKE '[정상복귀]%') AS recovery, SUM(message LIKE '[장비 오프라인]%') AS offline, SUM(message LIKE '[장비 복구]%') AS device_online, SUM(status = 'fail') AS sms_fail FROM 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['offline'] ?? 0), 'device_online' => (int)($sms_summary_rows['device_online'] ?? 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 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; 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(APP_VERSION) ?>

온습도 모니터링 대시보드

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


30초 자동 새로고침
등록 센서
경보 센서
오프라인
최근 온도
최근 습도
현재 시각

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

임계 경보 > 정상 $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' || $status_code === 'low_humidity') { $hum_chip_class = 'humid'; } ?>

/
온도 습도
측정:
측정값 없음
최종확인: (분 전)

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

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

= 2): ?>
온도(°C) 습도(%)
$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); $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 ?>
추이를 표시할 측정 데이터가 충분하지 않습니다 (2개 이상 필요).

30일 운영 요약

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

최근 온습도 측정 (30건)

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

센서 이벤트 로그 (30건)

센서 이벤트 로그 30건 기준
'장비시작', 'periodic' => '정기보고', ]; $evt = $row['event_type']; ?>
#시각센서 위치이벤트
아직 센서 이벤트가 없습니다.

SMS 발송 이력 (최근 20건)

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