참고 대시보드 미학(대형 숫자 KPI·플랫 카드)으로 재테마하고, 통합 센서(누수+온습도+미세먼지)를 유형 통합 그리드로 표시. PM2.5/PM10 카드·트렌드·이력을 추가. - php/assets/dash-v2.css: 후행 캐스케이드 오버라이드(기존 파일 무수정 가역 방식) - php/api/status_summary.php: 상태 요약 폴링 엔드포인트(try/catch·LIMIT·제네릭 에러 JSON) - dashboard_data.php: PM 카드 데이터·결로/예보 메트릭 정합 - tabs/live·trend·history: 미세먼지 표시·추세·이력 반영
1025 lines
49 KiB
PHP
1025 lines
49 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';
|
|
|
|
/**
|
|
* 수신자 번호 목록을 마스킹하여 짧게 요약(시스템 탭 설정 표시용). 개인정보 최소표시.
|
|
* 예: ['010...1111','010...2222'] → "010-****-1111 외 1명". 비면 "없음".
|
|
*/
|
|
function dash_mask_recipients(array $list): string {
|
|
$list = array_values(array_filter($list, fn($v) => trim((string)$v) !== ''));
|
|
$n = count($list);
|
|
if ($n === 0) {
|
|
return '없음';
|
|
}
|
|
$first = mask_phone((string)$list[0]);
|
|
return $n === 1 ? $first : "{$first} 외 " . ($n - 1) . '명';
|
|
}
|
|
|
|
// ── 탭 정의 (단일 출처) ─────────────────────────────────────
|
|
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';
|
|
}
|
|
|
|
// 현재 추이 대상 센서 ($_GET; 0=자동 선택). 유효성(수신 이력)은 load_tab_trend 에서 재확인.
|
|
function dash_current_sensor(): int {
|
|
$s = isset($_GET['sensor']) && is_numeric($_GET['sensor']) ? (int)$_GET['sensor'] : 0;
|
|
return $s > 0 ? $s : 0;
|
|
}
|
|
|
|
// 내부 링크 빌더 — tab·range·sensor 를 병행 보존한다(토글이 서로의 선택을 잃지 않도록).
|
|
// 기본값(live/3d/자동)은 생략해 URL 을 짧게 유지.
|
|
function dash_url(array $over = [], string $frag = ''): string {
|
|
$tab = $over['tab'] ?? dash_current_tab();
|
|
$range = $over['range'] ?? dash_current_range();
|
|
$sensor = $over['sensor'] ?? dash_current_sensor();
|
|
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; }
|
|
if ((int)$sensor > 0) { $params['sensor'] = (int)$sensor; }
|
|
$qs = http_build_query($params);
|
|
return 'dashboard.php' . ($qs !== '' ? '?' . $qs : '') . ($frag !== '' ? '#' . $frag : '');
|
|
}
|
|
|
|
// ── 표시 헬퍼 ───────────────────────────────────────────────
|
|
// metric_status: normal|high_temp|low_temp|high_humidity|low_humidity
|
|
// |condensation_risk|high_pm25|high_pm10 (결로·미세먼지는 추가 상태)
|
|
function metric_status_label(string $status): string {
|
|
$labels = [
|
|
'normal' => '정상',
|
|
'high_temp' => '고온',
|
|
'low_temp' => '저온',
|
|
'high_humidity' => '고습',
|
|
'low_humidity' => '저습',
|
|
'condensation_risk' => '결로주의',
|
|
'high_pm25' => '미세먼지(PM2.5)',
|
|
'high_pm10' => '미세먼지(PM10)',
|
|
];
|
|
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();
|
|
|
|
// 최신 측정을 (sensor_id, metric_type) 단위로 가져온다. 누수 노드(1·3)는 SHT30 행
|
|
// (온습도, pm NULL)과 PMS7003 행(미세먼지, 온습도 NULL)이 같은 sensor_id 로 교대로
|
|
// 들어오므로, metric_type-agnostic 인 MAX(id) GROUP BY sensor_id 로는 서로를 덮어써
|
|
// 온습도 또는 미세먼지 한쪽이 사라진다. metric_type 을 GROUP BY 에 포함해 두 계통을
|
|
// 각각 최신값으로 버킷팅한다. 레거시 빈 metric_type('') 은 SHT30(온습도)으로 취급.
|
|
$latest_sht30 = []; // sensor_id => 최신 온습도(SHT30) 행
|
|
$latest_pms = []; // sensor_id => 최신 미세먼지(PMS7003) 행
|
|
$metric_rows = $db->query("
|
|
SELECT sm.*
|
|
FROM leak_sensor_metric sm
|
|
INNER JOIN (
|
|
SELECT sensor_id, metric_type, MAX(id) AS latest_id
|
|
FROM leak_sensor_metric
|
|
GROUP BY sensor_id, metric_type
|
|
) latest ON latest.latest_id = sm.id
|
|
ORDER BY sm.sensor_id
|
|
")->fetchAll();
|
|
foreach ($metric_rows as $metric_row) {
|
|
$mid = (int)$metric_row['sensor_id'];
|
|
$mtyp = (string)($metric_row['metric_type'] ?? '');
|
|
if ($mtyp === 'pms7003') {
|
|
$latest_pms[$mid] = $metric_row;
|
|
} else {
|
|
// 'sht30' 또는 레거시 '' → 온습도 계통. 동일 sid 에 둘 다 있으면 id 큰 쪽 유지.
|
|
if (!isset($latest_sht30[$mid]) || (int)$metric_row['id'] > (int)$latest_sht30[$mid]['id']) {
|
|
$latest_sht30[$mid] = $metric_row;
|
|
}
|
|
}
|
|
}
|
|
// 온습도 계통을 대표 '최신 측정'으로 노출(temp/hum/metric_status 는 SHT30 행에서만).
|
|
$latest_metrics = $latest_sht30;
|
|
|
|
foreach ($statuses as &$s) {
|
|
$sid = (int)$s['sensor_id'];
|
|
$s['latest_metric'] = $latest_sht30[$sid] ?? null; // 온습도(SHT30) 행
|
|
$s['latest_pm'] = $latest_pms[$sid] ?? null; // 미세먼지(PMS7003) 행
|
|
|
|
$last_seen_ts = strtotime($s['last_seen'] ?? '2000-01-01');
|
|
$elapsed = $now - $last_seen_ts;
|
|
// 오프라인 판정도 센서별 타임아웃(온습도 1시간 주기 오탐 방지) — cron 과 동일 기준.
|
|
$s['is_offline'] = ($s['offline_alerted'] == 1) || ($elapsed >= heartbeat_timeout_for($sid));
|
|
$s['offline_elapsed_min'] = (int)round($elapsed / 60);
|
|
|
|
// 온습도(th) 경보 상태 — SHT30 행 metric_status 만 본다(미세먼지 행에 오염되지 않게).
|
|
$status_code = $s['latest_metric']['metric_status'] ?? 'normal';
|
|
$s['metric_status'] = $status_code;
|
|
$s['is_alerting'] = ($status_code !== 'normal');
|
|
|
|
// 미세먼지(pm) 경보 상태 — PMS7003 행 metric_status(normal|high_pm25|high_pm10) 만.
|
|
$pm_code = $s['latest_pm']['metric_status'] ?? 'normal';
|
|
$s['pm_status'] = $pm_code;
|
|
$s['is_pm_alerting'] = ($pm_code !== 'normal');
|
|
|
|
// 누수 확정 상태(디바운스): 컬럼이 있으면 leak_confirmed 를, 없으면 raw is_leak 를
|
|
// 헤드라인/집계의 단일 기준으로 삼는다(SMS 는 확정 엣지에서만 나가므로 표시와 일치).
|
|
// leak_pending: raw 가 확정과 어긋나 확인창 대기 중(카드에 '확인중' 배지).
|
|
$raw_leak = (int)($s['is_leak'] ?? 0);
|
|
if (array_key_exists('leak_confirmed', $s)) {
|
|
$s['leak_confirmed'] = (int)$s['leak_confirmed'];
|
|
$s['leak_pending'] = (($s['leak_pending_since'] ?? null) !== null) && ($raw_leak !== $s['leak_confirmed']);
|
|
} else {
|
|
$s['leak_confirmed'] = $raw_leak; // 구배포(컬럼 없음) 폴백
|
|
$s['leak_pending'] = false;
|
|
}
|
|
|
|
// 서버측 라벨 오버라이드(위치/관리주체) — 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']));
|
|
// 미세먼지 경보 활성(오프라인 아님) — 온습도(alert_count)와 별도 집계.
|
|
$g['pm_alert_count'] = count(array_filter($statuses, fn($s) => !empty($s['is_pm_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['leak_confirmed'] ?? $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_HIGH_PM . "%'
|
|
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,
|
|
];
|
|
// 온습도 회귀 표본은 SHT30 행만(미세먼지 PMS7003 행은 temp/hum NULL → 표본 오염 방지).
|
|
$recent_stmt = $db->prepare(
|
|
"SELECT temperature_c, humidity_percent, UNIX_TIMESTAMP(measured_at) AS t
|
|
FROM leak_sensor_metric
|
|
WHERE sensor_id = :sid AND (metric_type = 'sht30' OR metric_type = '')
|
|
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_for($sid));
|
|
$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['leak_confirmed'] ?? $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 {
|
|
$pm_alert = (int)($g['pm_alert_count'] ?? 0);
|
|
$parts = [];
|
|
if ($g['leak_active_count'] > 0) { $parts[] = "누수 {$g['leak_active_count']}개"; }
|
|
if ($pm_alert > 0) { $parts[] = "미세먼지 {$pm_alert}개"; }
|
|
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);
|
|
|
|
// 누수(최우선) — 온습도 동반 시 기존 결합 문구 유지(추가적으로 미세먼지는 detail 에 표기).
|
|
if ($g['leak_active_count'] > 0 && $g['alert_count'] > 0) {
|
|
return ['danger', '누수·온습도 경보 발생', $detail];
|
|
}
|
|
if ($g['leak_active_count'] > 0) {
|
|
return ['danger', '누수 감지 발생', $detail];
|
|
}
|
|
// 미세먼지(누수 다음, 온습도보다 우선)
|
|
if ($pm_alert > 0 && $g['alert_count'] > 0) {
|
|
return ['danger', '미세먼지·온습도 경보 발생', $detail];
|
|
}
|
|
if ($pm_alert > 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
|
|
");
|
|
// 미세먼지 경보 SMS(가장 최근) — [미세먼지경보] 태그.
|
|
$sms_pm_stmt = $db->prepare("
|
|
SELECT sent_at, status FROM leak_sms_log
|
|
WHERE sensor_id = :sid AND message LIKE '" . SMS_TAG_HIGH_PM . "%'
|
|
ORDER BY id DESC LIMIT 1
|
|
");
|
|
// 온습도 경보 시작('마지막 정상' 이후) — SHT30 행만(미세먼지 정상 행에 오염되지 않게).
|
|
$start_stmt = $db->prepare("
|
|
SELECT MAX(measured_at) FROM leak_sensor_metric
|
|
WHERE sensor_id = :sid AND metric_status = 'normal'
|
|
AND (metric_type = 'sht30' OR metric_type = '')
|
|
");
|
|
// 미세먼지 경보 시작('마지막 정상 PM 측정' 이후) — PMS7003 행만.
|
|
$start_pm_stmt = $db->prepare("
|
|
SELECT MAX(measured_at) FROM leak_sensor_metric
|
|
WHERE sensor_id = :sid AND metric_status = 'normal' AND metric_type = 'pms7003'
|
|
");
|
|
|
|
$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'];
|
|
|
|
// 누수 중(오프라인 아님) — 디바운스 확정 기준(SMS 와 일치)
|
|
if ((int)($s['leak_confirmed'] ?? $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) . ')' : ''),
|
|
];
|
|
}
|
|
|
|
// 미세먼지 경보 중(오프라인 아님) — PMS7003 행 기준, µg/m³ 표기
|
|
if (!empty($s['is_pm_alerting']) && !$s['is_offline']) {
|
|
$pm = $s['latest_pm'] ?? null;
|
|
$pm_code = $s['pm_status'] ?? 'normal';
|
|
// high_pm10 이면 PM10, 그 외(high_pm25)는 PM2.5 값을 대표로 표시.
|
|
$pm_val_raw = $pm_code === 'high_pm10' ? ($pm['pm10'] ?? null) : ($pm['pm2_5'] ?? null);
|
|
$val = ($pm_val_raw !== null) ? ((int)$pm_val_raw . 'µg/m³') : '-';
|
|
$start_pm_stmt->execute([':sid' => $sid]);
|
|
$last_normal = $start_pm_stmt->fetchColumn() ?: null;
|
|
$sms_pm_stmt->execute([':sid' => $sid]);
|
|
$bits = array_filter([$fmt_since($last_normal), $fmt_sms($sms_pm_stmt->fetch())]);
|
|
$lines[] = [
|
|
'severity' => 'danger',
|
|
'text' => "{$s['sensor_name']} · {$s['device_location']} — "
|
|
. metric_status_label($pm_code) . " {$val}"
|
|
. ($bits ? ' (' . implode(' · ', $bits) . ')' : ''),
|
|
];
|
|
}
|
|
}
|
|
return $lines;
|
|
}
|
|
|
|
// ── live 탭: 전역 데이터만으로 충분(추가 쿼리 없음) ─────────
|
|
function load_tab_live(PDO $db, array $g): array {
|
|
$t = [];
|
|
|
|
// (1) 24h 스파크라인 — 온습도 센서별 최근 24시간 측정, 센서당 ≤48pt 다운샘플.
|
|
// 카드에서 인라인 SVG polyline 으로 그린다(외부 라이브러리 없음).
|
|
$t['live_sparks'] = [];
|
|
$metric_ids = array_map('intval', METRIC_SENSOR_IDS);
|
|
if (!empty($metric_ids)) {
|
|
$ph = implode(',', array_fill(0, count($metric_ids), '?'));
|
|
// 온습도 스파크는 SHT30 행만(미세먼지 PMS7003 행은 temp/hum NULL — 결측점 방지).
|
|
$sp_stmt = $db->prepare("
|
|
SELECT sensor_id, temperature_c, humidity_percent
|
|
FROM leak_sensor_metric
|
|
WHERE sensor_id IN ({$ph}) AND measured_at >= DATE_SUB(NOW(), INTERVAL 1 DAY)
|
|
AND (metric_type = 'sht30' OR metric_type = '')
|
|
ORDER BY sensor_id, id
|
|
");
|
|
$sp_stmt->execute($metric_ids);
|
|
$spark_rows = [];
|
|
foreach ($sp_stmt->fetchAll() as $sr) {
|
|
$spark_rows[(int)$sr['sensor_id']][] = [
|
|
'temp' => $sr['temperature_c'] !== null ? (float)$sr['temperature_c'] : null,
|
|
'hum' => $sr['humidity_percent'] !== null ? (float)$sr['humidity_percent'] : null,
|
|
];
|
|
}
|
|
foreach ($spark_rows as $sp_sid => $pts) {
|
|
$n = count($pts);
|
|
if ($n > 48) { // 균등 간격 다운샘플(마지막 점은 항상 포함)
|
|
$sampled = [];
|
|
for ($k = 0; $k < 48; $k++) {
|
|
$sampled[] = $pts[(int)floor($k * ($n - 1) / 47)];
|
|
}
|
|
$pts = $sampled;
|
|
}
|
|
$t['live_sparks'][$sp_sid] = $pts;
|
|
}
|
|
}
|
|
|
|
// (2) 활성(미복구) 누수 메타 — 센서별 최신 사고의 시작 시각·사진·경보 SMS 건수.
|
|
// 결합 카드의 누수 행(경과 시간 · SMS n건 · 사진 썸네일)에 사용.
|
|
$t['live_leak_meta'] = [];
|
|
if (table_exists($db, 'leak_incident')) {
|
|
$open_incs = $db->query("
|
|
SELECT sensor_id, started_at, log_id_alert
|
|
FROM leak_incident
|
|
WHERE recovered_at IS NULL
|
|
ORDER BY id DESC
|
|
")->fetchAll();
|
|
foreach ($open_incs as $inc) {
|
|
$l_sid = (int)$inc['sensor_id'];
|
|
if (isset($t['live_leak_meta'][$l_sid])) { continue; } // 센서당 최신 1건
|
|
$meta = [
|
|
'started_at' => $inc['started_at'],
|
|
'elapsed_min' => max(0, (int)round((time() - strtotime($inc['started_at'])) / 60)),
|
|
'photos' => [],
|
|
'sms_count' => 0,
|
|
];
|
|
if ($inc['log_id_alert'] !== null && table_exists($db, 'leak_photo')) {
|
|
$lp_stmt = $db->prepare("
|
|
SELECT id, captured_at FROM leak_photo
|
|
WHERE log_id = :lid ORDER BY id DESC LIMIT 2
|
|
");
|
|
$lp_stmt->execute([':lid' => (int)$inc['log_id_alert']]);
|
|
$meta['photos'] = $lp_stmt->fetchAll();
|
|
}
|
|
$sms_stmt = $db->prepare("
|
|
SELECT COUNT(*) FROM leak_sms_log
|
|
WHERE sensor_id = :sid AND status = 'success' AND sent_at >= :started
|
|
");
|
|
$sms_stmt->execute([':sid' => $l_sid, ':started' => $inc['started_at']]);
|
|
$meta['sms_count'] = (int)$sms_stmt->fetchColumn();
|
|
$t['live_leak_meta'][$l_sid] = $meta;
|
|
}
|
|
}
|
|
|
|
return $t;
|
|
}
|
|
|
|
// ── trend 탭: 추이 차트 + 통계 위젯 ─────────────────────────
|
|
function load_tab_trend(PDO $db, array $g): array {
|
|
$t = [];
|
|
|
|
// 추이 차트 대상 센서: 선택 가능한 metric 센서(수신 이력 보유) 목록을 만들고,
|
|
// $_GET['sensor'] 요청이 유효하면 그걸, 아니면 첫 metric 센서를 자동 선택한다.
|
|
$status_ids = array_map(fn($s) => (int)$s['sensor_id'], $g['statuses']);
|
|
$metric_ids = array_map('intval', METRIC_SENSOR_IDS);
|
|
|
|
$chart_sensor_options = []; // 선택기 UI 용: [{id, name}]
|
|
foreach ($g['statuses'] as $s) {
|
|
$sid = (int)$s['sensor_id'];
|
|
if (in_array($sid, $metric_ids, true)) {
|
|
$chart_sensor_options[] = ['id' => $sid, 'name' => (string)($s['sensor_name'] ?? '')];
|
|
}
|
|
}
|
|
$option_ids = array_map(fn($o) => $o['id'], $chart_sensor_options);
|
|
|
|
$requested = dash_current_sensor();
|
|
if ($requested > 0 && in_array($requested, $option_ids, true)) {
|
|
$chart_sensor_id = $requested; // 사용자가 고른 센서
|
|
} else {
|
|
$chart_sensor_id = $option_ids[0] ?? ($status_ids[0] ?? 0); // 자동(첫 metric)
|
|
}
|
|
|
|
$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_sensor_options'] = $chart_sensor_options;
|
|
|
|
$t['chart_range'] = dash_current_range();
|
|
$range_days = DASH_RANGES[$t['chart_range']];
|
|
|
|
$chart_points = [];
|
|
$chart_pm_points = [];
|
|
if ($chart_sensor_id > 0) {
|
|
// 온습도 추이 — SHT30 행만(미세먼지 PMS7003 행은 temp/hum NULL → 축 왜곡 방지).
|
|
$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)
|
|
AND (metric_type = 'sht30' OR metric_type = '')
|
|
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,
|
|
];
|
|
}
|
|
|
|
// 미세먼지 추이 — PMS7003 행만(PM2.5/PM10, 온습도와 별도 스케일). PM 센서에서만 데이터.
|
|
if (in_array($chart_sensor_id, array_map('intval', PM_SENSOR_IDS), true)) {
|
|
$chart_pm_stmt = $db->prepare("
|
|
SELECT measured_at, pm2_5, pm10
|
|
FROM leak_sensor_metric
|
|
WHERE sensor_id = :sid AND metric_type = 'pms7003'
|
|
AND measured_at >= DATE_SUB(NOW(), INTERVAL :days DAY)
|
|
ORDER BY id DESC
|
|
LIMIT 600
|
|
");
|
|
$chart_pm_stmt->bindValue(':sid', $chart_sensor_id, PDO::PARAM_INT);
|
|
$chart_pm_stmt->bindValue(':days', $range_days, PDO::PARAM_INT);
|
|
$chart_pm_stmt->execute();
|
|
foreach (array_reverse($chart_pm_stmt->fetchAll()) as $cr) { // 오래된→최신
|
|
$chart_pm_points[] = [
|
|
't' => $cr['measured_at'],
|
|
'pm25' => $cr['pm2_5'] !== null ? (int)$cr['pm2_5'] : null,
|
|
'pm10' => $cr['pm10'] !== null ? (int)$cr['pm10'] : null,
|
|
];
|
|
}
|
|
}
|
|
}
|
|
$t['chart_points'] = $chart_points;
|
|
$t['chart_pm_points'] = $chart_pm_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 미세먼지 경보 vs 누수 감지 (라인차트 + 게이지 스파크 공용)
|
|
// 온습도(th) 라인은 SHT30 행만 — 미세먼지 경보(high_pm25/10)가 '온습도 경보'로 오분류되지 않게.
|
|
$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'
|
|
AND (metric_type = 'sht30' OR metric_type = '')
|
|
GROUP BY DATE(measured_at)
|
|
")->fetchAll(PDO::FETCH_KEY_PAIR);
|
|
// 미세먼지(pm) 라인 — PMS7003 행의 임계 초과 측정 건수(별도 카테고리).
|
|
$pmtrend_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'
|
|
AND metric_type = 'pms7003'
|
|
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_pm = []; $trend_leak = []; $spark_alerts = [];
|
|
for ($i = 6; $i >= 0; $i--) {
|
|
$d = date('Y-m-d', strtotime("-$i day"));
|
|
$tm = (int)($mtrend_map[$d] ?? 0);
|
|
$tp = (int)($pmtrend_map[$d] ?? 0);
|
|
$tl = (int)($ltrend_map[$d] ?? 0);
|
|
$trend_days[] = $d;
|
|
$trend_metric[] = $tm;
|
|
$trend_pm[] = $tp;
|
|
$trend_leak[] = $tl;
|
|
$spark_alerts[] = $tm + $tp + $tl; // 게이지 스파크 총계엔 미세먼지도 포함
|
|
}
|
|
$t['trend_days'] = $trend_days;
|
|
$t['trend_metric'] = $trend_metric;
|
|
$t['trend_pm'] = $trend_pm;
|
|
$t['trend_leak'] = $trend_leak;
|
|
$t['trend_max'] = max(1, max(array_merge($trend_metric, $trend_pm, $trend_leak)));
|
|
$t['spark_alerts'] = $spark_alerts;
|
|
|
|
$spark_today = end($spark_alerts); reset($spark_alerts);
|
|
if ($g['alert_count'] > 0 || (int)($g['pm_alert_count'] ?? 0) > 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일) — 단위: 온습도/미세먼지=비정상 측정 건수, 누수=감지 이벤트 건수
|
|
// (metric_status 는 계통별로 고유 값이라 온습도 vs 미세먼지가 자연히 분리된다.)
|
|
$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();
|
|
$pm_30d = (int)($mstatus_map['high_pm25'] ?? 0) + (int)($mstatus_map['high_pm10'] ?? 0);
|
|
$donut_segs = [
|
|
['label' => '고온', 'val' => (int)($mstatus_map['high_temp'] ?? 0), 'cat' => 'hot'],
|
|
['label' => '고습', 'val' => (int)($mstatus_map['high_humidity'] ?? 0), 'cat' => 'humid'],
|
|
['label' => '미세먼지', 'val' => $pm_30d, 'cat' => 'pm'],
|
|
['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건 — SHT30 행만(미세먼지 PMS7003 행은 아래 별도 표에서).
|
|
$t['recent_metrics'] = $db->query("
|
|
SELECT sensor_id, sensor_name, device_location,
|
|
temperature_c, humidity_percent, metric_status, measured_at
|
|
FROM leak_sensor_metric
|
|
WHERE metric_type = 'sht30' OR metric_type = ''
|
|
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건 — PMS7003 행만(PM1.0/PM2.5/PM10 µg/m³ + 임계 상태).
|
|
$t['recent_pm'] = $db->query("
|
|
SELECT sensor_id, sensor_name, device_location,
|
|
pm1_0, pm2_5, pm10, metric_status, measured_at
|
|
FROM leak_sensor_metric
|
|
WHERE metric_type = 'pms7003'
|
|
ORDER BY id DESC
|
|
LIMIT 30
|
|
")->fetchAll();
|
|
foreach ($t['recent_pm'] 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();
|
|
// 온습도 경보 SMS 합계엔 미세먼지(high_pm)를 넣지 않는다 — 별도 타일로 노출(계통 분리).
|
|
$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,
|
|
'high_pm' => (int)($row['high_pm'] ?? 0),
|
|
'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 로 조정)
|
|
$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) . '분'],
|
|
];
|
|
// 누수 디바운스 확인 지연(활성 시)
|
|
if (LEAK_DEBOUNCE_ENABLED) {
|
|
$config_rows[] = ['누수 확인 지연', '감지 ' . LEAK_ALERT_CONFIRM_SEC . 's / 복구 ' . LEAK_RECOVERY_CONFIRM_SEC . 's'];
|
|
}
|
|
// 오프라인 판정 — 센서 유형별 타임아웃(온습도 1시간 주기 대응)
|
|
$config_rows[] = ['오프라인 판정',
|
|
'누수 ' . (int)round(HEARTBEAT_TIMEOUT_SEC / 60) . '분 / 온습도 ' . (int)round(METRIC_HEARTBEAT_TIMEOUT_SEC / 60) . '분 무수신'];
|
|
$config_rows[] = ['SMS 잔액 경고 임계', SMS_BALANCE_WARN_THRESHOLD . '건'];
|
|
$config_rows[] = ['온습도 센서', implode(', ', array_map(fn($i) => $i . '번', METRIC_SENSOR_IDS))];
|
|
$config_rows[] = ['누수 센서', implode(', ', array_map(fn($i) => $i . '번', LEAK_SENSOR_IDS))];
|
|
// 수신자: 전역 + 센서별 그룹(지정된 것만, 번호 마스킹)
|
|
$config_rows[] = ['수신자 · 전역', dash_mask_recipients(SMS_RECIPIENTS)];
|
|
foreach (SENSOR_RECIPIENTS as $rsid => $rlist) {
|
|
$config_rows[] = ["수신자 · {$rsid}번", dash_mask_recipients($rlist)];
|
|
}
|
|
$t['config_rows'] = $config_rows;
|
|
|
|
return $t;
|
|
}
|