단일 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: 대시보드 표면을 분리 구조(셸+데이터+탭)에 맞춰 검사하도록 보정.
756 lines
34 KiB
PHP
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;
|
|
}
|