'정상',
'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);
// 최근 거부 출처(IP·사유·모드) — '우리 노드 오설정 vs 외부 스캐너' 판별용.
$reject_events = read_ingest_reject_events(10);
// ── 누수 모듈 현재 상태 + 사고/사진 ───────────────────
// 누수 센서(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 발송(leak_sms_log)이 아니라 실제 센서 데이터 기준으로 센다:
// · 온습도 경보 = leak_sensor_metric.metric_status (비정상 측정) — 하단 "측정 이력"과 동일 소스
// · 누수 = leak_sensor_log.event_type='leak_alert' (누수 감지 이벤트) — "이벤트 로그"와 동일
// (SMS 발송 통계는 하단 "30일 운영 요약"에 별도로, 명시적 SMS 라벨로 유지)
// (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;
// (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);
$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;
}
$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 = '정상'; }
// (3) 경보 최다 센서 리더보드 (30일) — 온습도 경보 측정 + 누수 감지, 센서별 합산
$board_metric = $db->query("
SELECT sensor_name, COUNT(*) c
FROM leak_sensor_metric
WHERE measured_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) AND metric_status <> 'normal'
GROUP BY sensor_name
")->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; }
}
arsort($board_combined);
$board_rows = [];
foreach (array_slice($board_combined, 0, 6, true) as $name => $c) {
$board_rows[] = ['sensor_name' => $name, 'c' => $c];
}
$board_max = $board_rows ? max(array_column($board_rows, 'c')) : 0;
// (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)($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'));
// (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) ?>
온습도·누수 통합 모니터링 대시보드 = htmlspecialchars(APP_VERSION) ?>
= htmlspecialchars($system_title) ?>
= htmlspecialchars($system_detail) ?>
= date('Y-m-d H:i:s') ?>
30초 자동 새로고침
0): ?>
경보 SMS 발송 실패
최근 1시간 내 경보/오프라인 SMS = (int)$recent_alert_fail ?>건 발송 실패 — 미수신 가능. Cafe24 SMS 잔액·계정·발신번호를 확인하세요.
0): ?>
수집 거부 = (int)$rejection_total ?>건 (최근 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);
?>
최근 거부 출처 = count($reject_events) ?>건 (우리 노드 vs 외부 스캐너 판별)
| 시각 | 출처 IP | 사유 | 모드 | device_id |
| = htmlspecialchars((string)($ev['t'] ?? '')) ?> |
= htmlspecialchars((string)($ev['ip'] ?? '?')) ?> (xff = htmlspecialchars((string)$ev['fwd']) ?>) |
= htmlspecialchars($reason_ko[$ev['reason'] ?? ''] ?? (string)($ev['reason'] ?? '')) ?> |
= htmlspecialchars((string)($ev['mode'] ?? '')) ?> |
= htmlspecialchars((string)($ev['dev'] ?? '')) ?> |
대응 필요 = count($risk_items_all) ?>건
= $sev_text ?>
= htmlspecialchars($risk['name']) ?>= $risk['location'] ? ' · ' . htmlspecialchars($risk['location']) : '' ?>
= htmlspecialchars($risk['detail']) ?>
= htmlspecialchars($pb['title'] ?? '권장 조치') ?> — 권장 조치
- = htmlspecialchars($step) ?>
관제 개요 Overview
가동률 온라인 센서
= $uptime_pct ?>%= $online_count ?> / = $sensor_total ?> 온라인
경보 센서
= $alert_count ?>개
오프라인
= $offline_count ?>개
경보 종류 최근 30일
0): ?>
= htmlspecialchars($seg['label']) ?>= (int)$seg['val'] ?>
최근 30일 경보 없음
경보 최다 센서 30일 · TOP = count($board_rows) ?: 6 ?>
$br):
$b_w = $board_max > 0 ? (int)round((int)$br['c'] / $board_max * 100) : 0; ?>
= $b_rank + 1 ?>
= htmlspecialchars($br['sensor_name']) ?>
= (int)$br['c'] ?>건
최근 30일 경보 발생 센서 없음
경보 발생 추이 최근 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_metric as $i => $v) { if ($v > $trend_metric[$t_peak_i]) { $t_peak_i = $i; } }
?>
실시간 상태 Live
센서 현재 상태 30초 자동 새로고침
$s):
// 미수신 placeholder: 기대 온습도 센서(METRIC_SENSOR_IDS)가 아직 보고 전이면
// '데이터 없음' 카드로 항상 노출한다(누수 카드와 동일 — 화면에서 사라지지 않게).
if ($s === null): ?>
= (int)$metric_sid ?>번 센서 (온습도)
데이터 없음
아직 수신된 온습도 데이터가 없습니다.
임계 경보 > 정상
$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'; }
?>
= htmlspecialchars($s['sensor_name']) ?>
= htmlspecialchars($s['device_location']) ?> / = htmlspecialchars($s['device_id']) ?>
= htmlspecialchars($badge_text) ?>
온도 = htmlspecialchars($metric_temp) ?>
습도 = htmlspecialchars($metric_humidity) ?>
측정: = htmlspecialchars($metric['measured_at']) ?>
측정값 없음
최종확인: = htmlspecialchars($s['last_seen']) ?>
(= $s['offline_elapsed_min'] ?>분 전)
$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'] . '℃)'];
}
?>
= htmlspecialchars($c['text']) ?>
예보: 임계 여유 충분
아직 수신된 센서 데이터가 없습니다.
누수 감지
$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 = '정상';
}
?>
= (int)$leak_sid ?>번 센서 (누수)
= htmlspecialchars($leak_badge_text) ?>
아직 수신된 누수 센서 데이터가 없습니다.
= htmlspecialchars($leak_status['sensor_name']) ?>
= htmlspecialchars($leak_status['device_location']) ?> / = htmlspecialchars($leak_status['device_id']) ?>
= htmlspecialchars($leak_badge_text) ?>
최종확인: = htmlspecialchars($leak_status['last_seen']) ?>
(= $leak_status['offline_elapsed_min'] ?>분 전)
누수 사고 이력 최근 20건
| # | 센서 | 위치 |
감지 시각 | 복구 시각 | 사진 |
| = (int)$inc['id'] ?> |
= htmlspecialchars($inc['sensor_name']) ?> |
= htmlspecialchars($inc['device_location']) ?> |
= htmlspecialchars($inc['started_at']) ?> |
미복구
= htmlspecialchars($inc['recovered_at']) ?>
|
-
|
| 아직 누수 사고 이력이 없습니다. |
추세 분석 Trend
온습도 추이
0): ?>
· = htmlspecialchars($chart_sensor_name !== '' ? $chart_sensor_name : ('센서 ' . $chart_sensor_id)) ?> 최근 = count($chart_points) ?>개
= 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'), '.');
?>
추이를 표시할 측정 데이터가 충분하지 않습니다 (2개 이상 필요).
운영 이력 History
30일 운영 요약
고온 경보 SMS
= $summary['high_temp'] ?>건
저온 경보 SMS
= $summary['low_temp'] ?>건
고습 경보 SMS
= $summary['high_humidity'] ?>건
저습 경보 SMS
= $summary['low_humidity'] ?>건
정상 복귀 SMS
= $summary['recovery'] ?>건
오프라인 SMS
= $summary['offline'] ?>건
장비 복구 SMS
= $summary['device_online'] ?>건
누수감지 SMS
= $summary['leak_alert'] ?>건
누수복구 SMS
= $summary['leak_recovery'] ?>건
SMS 실패
= $summary['sms_fail'] ?>건
평균 온도
= $avg_temp !== null ? htmlspecialchars((string)$avg_temp) . '°C' : '-' ?>
평균 습도
= $avg_hum !== null ? htmlspecialchars((string)$avg_hum) . '%' : '-' ?>
최근 온도
= htmlspecialchars(metric_display($latest_temp !== null ? (string)$latest_temp : null, '°C')) ?>
최근 습도
= htmlspecialchars(metric_display($latest_hum !== null ? (string)$latest_hum : null, '%')) ?>
최근 온습도 측정 (30건)
최근 온습도 측정 30건 기준
| 측정시각 | 센서 | 위치 |
온도 | 습도 | 상태 |
| = htmlspecialchars($row['measured_at']) ?> |
= htmlspecialchars($row['sensor_name']) ?> |
= htmlspecialchars($row['device_location']) ?> |
= htmlspecialchars(metric_display($row['temperature_c'], '°C')) ?> |
= htmlspecialchars(metric_display($row['humidity_percent'], '%')) ?> |
= htmlspecialchars(metric_status_label($status_code)) ?> |
| 아직 온습도 측정 데이터가 없습니다. |
센서 이벤트 로그 (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);
?>
| = (int)$row['id'] ?> |
= htmlspecialchars($row['sensor_time']) ?> |
= htmlspecialchars($row['sensor_name']) ?> |
= htmlspecialchars($row['device_location']) ?> |
= htmlspecialchars($event_labels[$evt]) ?>
= htmlspecialchars($event_labels[$evt] ?? $evt) ?>
|
| 아직 센서 이벤트가 없습니다. |
SMS 발송 이력 (최근 20건)
SMS 발송 이력 최근 20건
| # | 발송시각 | 센서 | 수신자 | 상태 | 결과 |
| = (int)$row['id'] ?> |
= htmlspecialchars($row['sent_at']) ?> |
= htmlspecialchars($row['sensor_name']) ?> |
= htmlspecialchars(mask_phone($row['receivers'] ?? '')) ?> |
성공
실패
|
= htmlspecialchars($row['result_message']) ?> |
| 아직 SMS 발송 이력이 없습니다. |