POSA_LEAKSMS/php/dashboard_data.php
cw2007 f063060e2d refactor(dashboard): 탭 구조 전면 재개편(실시간/추이/이력/시스템)
단일 1594줄 스크롤 페이지를 상시 영역 + 4탭 서버렌더(?tab=) 구조로 재개편한다.
5인 리뷰 패널(정보구조·관제도메인·UX·구현·접근성) 종합 스펙 기반.

상시 영역(전 탭 공통):
- 시스템 상태 히어로 — 누수(is_leak)·미복구 사고를 FORECAST_ENABLED 와 무관하게
  반영(기존: 누수 중에도 '정상 감시 중' 표시 가능한 결함). 경보 시 센서명·위치·
  현재값·경보 시작 시각·SMS 발송 결과를 한 줄로 명시.
- 알림 스트립(SMS 실패/미복구 누수/수집 거부 1줄 요약 + 탭 딥링크)
- 대응 필요 패널(플레이북·담당자) — 안전 알림은 탭과 무관하게 상시 노출
- 통합 KPI 스트립 6타일 — 모바일 퀵타일·듀얼 KPI·게이지 배지를 하나로 통합
- 탭바(nav+aria-current, 경보 배지)

탭:
- 실시간 현황: 온습도/누수 카드(예보 여유 상시 표시), 미복구 사고, SMS 채널·오늘 요약
- 추이·통계: 온습도 추이 차트, 7일 경보 추이, 경보 종류 도넛(단위 명기), 가동률, 리더보드
- 이력 조회: 누수 사고·측정·이벤트·SMS 이력, 30일 센서별 평균 온습도, SMS 발송 통계
- 시스템 점검: 자가진단, 수집 거부 관측성, SMS 채널 점검, 플레이북 사본, 임계 설정 요약
- ?tab=all: 인쇄용 전체 렌더(details 강제 펼침, 자동 새로고침 없음)

구현:
- 데이터 계층 분리(dashboard_data.php): 전역 vs 탭 전용 쿼리 — 로드당 쿼리 ~50→전역
  14+탭 5~8개(약 60% 절감). ops_checks 파일 캐시(TTL 120초, 시스템 탭은 우회).
- 탭 partial(tabs/*.php)은 dash_render_tab() 함수 스코프 격리(EXTR_SKIP)로 렌더.
- 30초 자동 새로고침은 URL 쿼리 보존으로 탭·기간 유지. 스크롤/details 복원은 탭별 키.
- 폐쇄망 유지: 외부 CDN/JS 없음(인라인 SVG). app.css 는 기존 규칙 불변, 추가만.
- 보안: dashboard_data·forecast·tabs 를 .htaccess FilesMatch + tabs/.htaccess +
  PHP 직접접근 가드로 삼중 차단.

security_hardening_test: 대시보드 표면을 분리 구조(셸+데이터+탭)에 맞춰 검사하도록 보정.
2026-07-11 11:23:14 +09:00

756 lines
34 KiB
PHP

<?php
// =============================================================================
// dashboard_data.php - 대시보드 데이터 계층 (dashboard.php 전용)
//
// 탭 재개편(전역 셸 + ?tab= 서버 렌더)에 맞춰 데이터 준비를 두 층으로 나눈다:
// · load_global_data() : 모든 탭에서 필요한 것(히어로 판정, KPI 스트립, 알림
// 스트립, 대응 필요 패널, 탭 배지) — 매 로드 실행
// · load_tab_*() : 활성 탭에서만 필요한 쿼리 — 비활성 탭 쿼리는 실행 안 함
//
// 표시용 헬퍼(metric_status_label 등)와 URL 빌더(dash_url)도 여기에 둔다.
// =============================================================================
// include 전용 — 웹 직접 접근 차단(.htaccess FilesMatch 와 이중 방어. 호스팅이
// .htaccess 를 무시해도 아래 requires 가 익명 요청 컨텍스트에서 실행되지 않게).
if (PHP_SAPI !== 'cli'
&& basename((string)($_SERVER['SCRIPT_FILENAME'] ?? '')) === basename(__FILE__)) {
http_response_code(404);
exit;
}
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/ops_checks.php';
require_once __DIR__ . '/forecast.php';
// ── 탭 정의 (단일 출처) ─────────────────────────────────────
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;
$s['is_offline'] = ($s['offline_alerted'] == 1) || ($elapsed >= HEARTBEAT_TIMEOUT_SEC);
$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');
// 서버측 라벨 오버라이드(위치/관리주체) — 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['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_SEC);
$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['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'];
// 누수 중(오프라인 아님)
if ((int)($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 로 조정)
$t['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) . '분'],
['오프라인 판정', (HEARTBEAT_TIMEOUT_SEC / 60) . '분 무수신'],
['SMS 잔액 경고 임계', SMS_BALANCE_WARN_THRESHOLD . '건'],
['온습도 센서', implode(', ', array_map(fn($i) => $i . '번', METRIC_SENSOR_IDS))],
['누수 센서', implode(', ', array_map(fn($i) => $i . '번', LEAK_SENSOR_IDS))],
];
return $t;
}