From f063060e2d854dfcdc2e0c40a8054291eb118cc1 Mon Sep 17 00:00:00 2001 From: cw2007 Date: Sat, 11 Jul 2026 11:23:14 +0900 Subject: [PATCH] =?UTF-8?q?refactor(dashboard):=20=ED=83=AD=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EC=A0=84=EB=A9=B4=20=EC=9E=AC=EA=B0=9C=ED=8E=B8(?= =?UTF-8?q?=EC=8B=A4=EC=8B=9C=EA=B0=84/=EC=B6=94=EC=9D=B4/=EC=9D=B4?= =?UTF-8?q?=EB=A0=A5/=EC=8B=9C=EC=8A=A4=ED=85=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 단일 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: 대시보드 표면을 분리 구조(셸+데이터+탭)에 맞춰 검사하도록 보정. --- php/.htaccess | 3 +- php/assets/app.css | 169 ++++ php/dashboard.php | 1517 ++++------------------------- php/dashboard_data.php | 756 ++++++++++++++ php/tabs/.htaccess | 9 + php/tabs/history.php | 234 +++++ php/tabs/live.php | 214 ++++ php/tabs/system.php | 182 ++++ php/tabs/trend.php | 344 +++++++ tests/security_hardening_test.php | 8 +- 10 files changed, 2083 insertions(+), 1353 deletions(-) create mode 100644 php/dashboard_data.php create mode 100644 php/tabs/.htaccess create mode 100644 php/tabs/history.php create mode 100644 php/tabs/live.php create mode 100644 php/tabs/system.php create mode 100644 php/tabs/trend.php diff --git a/php/.htaccess b/php/.htaccess index a71e44a..5ba4ba5 100644 --- a/php/.htaccess +++ b/php/.htaccess @@ -21,7 +21,8 @@ # ─── 민감 PHP 파일 직접 접근 차단 ───────────────────────── - +# (dashboard_data·forecast·live·trend·history·system 은 include 전용 — tabs/.htaccess 와 이중 차단) + Require all denied diff --git a/php/assets/app.css b/php/assets/app.css index b7a995e..38674d0 100644 --- a/php/assets/app.css +++ b/php/assets/app.css @@ -454,3 +454,172 @@ tbody tr:hover td { background: var(--surface-2); } .panel, .card, table, .chart-panel, .status-hero { box-shadow: none; border: 1px solid #999; page-break-inside: avoid; break-inside: avoid; } .badge, .pill { border: 1px solid currentColor; } } + +/* ============================================================================= + * 대시보드 탭 재개편 추가 컴포넌트 (기존 규칙 불변 — 추가만) + * 탭바 / 탭 배지 / 알림 스트립 / 통합 KPI 스트립 / 히어로 상세줄 / 미니 패널 / + * 범주형 차트 토큰(--cat-*) / 시스템 점검 그리드 / 인쇄 보강 + * ============================================================================= */ + +:root { + /* 범주형 차트 토큰 — 경보 종류 도넛·범례(인라인 hex 치환, 인쇄에서 진한 값으로 교체) */ + --cat-hot: #ff8a5c; + --cat-humid: #ffb23e; + --cat-leak: #ff5470; + --cat-cold: #5fd6ee; + --cat-dry: #b0b8ff; +} + +/* ── 탭바 (?tab= 서버 렌더 링크 — nav + aria-current) ───────────────────────── */ +.tabbar { + display: flex; align-items: stretch; gap: var(--sp-1); + background: var(--header-bg); border: 1px solid var(--line); border-radius: var(--r-lg); + padding: var(--sp-1); margin: var(--sp-5) 0 var(--sp-4); + overflow-x: auto; -webkit-overflow-scrolling: touch; +} +.tabbar .tab-link { + flex: 0 0 auto; display: inline-flex; align-items: center; gap: 7px; + color: var(--ink-2); font-size: .9rem; font-weight: 700; text-decoration: none; + padding: 10px 18px; border-radius: var(--r); position: relative; white-space: nowrap; +} +.tabbar .tab-link:hover { color: var(--ink); background: var(--surface-2); } +.tabbar .tab-link.active { + color: var(--ink); background: var(--surface-2); +} +.tabbar .tab-link.active::after { + content: ""; position: absolute; left: 14px; right: 14px; bottom: 4px; height: 2px; + border-radius: 2px; background: linear-gradient(90deg, var(--primary), var(--chart-hum)); +} +.tab-badge { + display: inline-flex; align-items: center; justify-content: center; + min-width: 1.5em; padding: 1px 7px; border-radius: var(--r-pill); + font-size: .72rem; font-weight: 800; font-variant-numeric: tabular-nums; line-height: 1.5; +} +.tab-badge.warn { background: var(--warn-bg); color: var(--warn-ink); } +.tab-badge.crit { background: var(--leak-bg); color: var(--leak-ink); } +.tabbar .tab-print { + flex: 0 0 auto; margin-left: auto; align-self: center; + color: var(--ink-3); font-size: .78rem; font-weight: 600; text-decoration: none; + padding: 8px 12px; border-radius: var(--r); white-space: nowrap; +} +.tabbar .tab-print:hover, .tabbar .tab-print.active { color: var(--ink); background: var(--surface-2); } +.print-note { color: var(--ink-2); font-size: .8125rem; margin-bottom: var(--sp-4); } + +/* ── 알림 스트립 (즉시 조치 1줄 — 상세는 해당 탭 딥링크) ────────────────────── */ +.alert-strip { + display: flex; align-items: baseline; gap: var(--sp-3); flex-wrap: wrap; + border: 1px solid var(--line); border-left-width: 4px; border-radius: var(--r); + padding: 10px 14px; margin-bottom: var(--sp-3); + text-decoration: none; color: var(--ink); background: var(--surface); +} +.alert-strip strong { font-size: .9rem; } +.alert-strip span { color: var(--ink-2); font-size: .8125rem; flex: 1 1 auto; min-width: 0; } +.alert-strip em { font-style: normal; font-size: .8125rem; font-weight: 700; color: var(--primary-600); white-space: nowrap; } +.alert-strip.danger { border-left-color: var(--leak); background: linear-gradient(90deg, var(--leak-bg), transparent 45%), var(--surface); } +.alert-strip.danger strong { color: var(--leak-ink); } +.alert-strip.warn { border-left-color: var(--warn); background: linear-gradient(90deg, var(--warn-bg), transparent 45%), var(--surface); } +.alert-strip.warn strong { color: var(--warn-ink); } +.alert-strip:hover { border-color: var(--primary); } + +/* ── 히어로 상세줄 (경보 중 센서별 값·시작 시각·SMS 발송 결과) ──────────────── */ +.hero-lines { list-style: none; margin: var(--sp-3) 0 0; padding: 0; display: grid; gap: 6px; } +.hero-lines li { + font-size: .875rem; font-weight: 600; color: var(--ink); + display: flex; align-items: baseline; gap: 8px; + font-variant-numeric: tabular-nums; +} +.hero-lines li::before { content: "\25B8"; color: var(--leak-ink); flex: none; } + +/* ── 통합 KPI 스트립 (6타일 · 탭 딥링크 · 색+액센트+텍스트 3중 인코딩) ──────── */ +.kpi-strip { + display: grid; grid-template-columns: repeat(6, minmax(0,1fr)); gap: var(--sp-3); + margin-bottom: var(--sp-2); +} +.kpi { + display: flex; flex-direction: column; justify-content: center; gap: 2px; + background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg); + box-shadow: var(--shadow); padding: var(--sp-3) var(--sp-4); min-height: 76px; + text-decoration: none; color: var(--ink); position: relative; overflow: hidden; +} +.kpi::before { content: ""; position: absolute; inset: 0 0 auto 0; height: 3px; background: var(--primary); } +.kpi:hover { border-color: var(--primary); } +.kpi .kpi-lbl { color: var(--ink-2); font-size: .72rem; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; } +.kpi .kpi-val { font-size: clamp(1.3rem, 2.2vw, 1.7rem); font-weight: 800; line-height: 1.1; letter-spacing: -.02em; font-variant-numeric: tabular-nums; } +.kpi .kpi-sub { color: var(--ink-3); font-size: .72rem; font-variant-numeric: tabular-nums; } +.kpi.ok::before { background: var(--normal); } +.kpi.warn::before { background: var(--warn); } +.kpi.err::before { background: var(--leak); } +.kpi.ok .kpi-val { color: var(--normal-ink); } +.kpi.warn .kpi-val { color: var(--warn-ink); } +.kpi.err .kpi-val { color: var(--leak-ink); } + +/* ── 미니 패널 (live 탭: SMS 채널 상태 / 오늘 경보 요약) ────────────────────── */ +.mini-panels { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: var(--sp-4); margin-bottom: var(--sp-6); } +.mini-panel h3, .sub-h3 { font-size: .875rem; font-weight: 700; color: var(--ink); margin: 0 0 var(--sp-3); } +.sub-h3 { margin-top: var(--sp-4); } +.mini-rows { display: grid; gap: var(--sp-2); margin: 0; } +.mini-rows > div { + display: flex; align-items: baseline; justify-content: space-between; gap: var(--sp-3); + border-bottom: 1px solid var(--line-2); padding-bottom: var(--sp-2); +} +.mini-rows > div:last-child { border-bottom: none; padding-bottom: 0; } +.mini-rows dt { color: var(--ink-2); font-size: .8125rem; font-weight: 600; } +.mini-rows dd { margin: 0; font-size: .95rem; font-weight: 800; font-variant-numeric: tabular-nums; text-align: right; } + +/* ── 예보 칩: 여유(safe) 상시 표시용 중립 스타일 ────────────────────────────── */ +.forecast-chip.safe { background: var(--surface-2); color: var(--ink-2); } + +/* ── 범주형 차트 클래스 (도넛 세그먼트 stroke / 범례 스와치 background) ─────── */ +.donut-ring circle.seg.cat-hot { stroke: var(--cat-hot); } +.donut-ring circle.seg.cat-humid { stroke: var(--cat-humid); } +.donut-ring circle.seg.cat-leak { stroke: var(--cat-leak); } +.donut-ring circle.seg.cat-cold { stroke: var(--cat-cold); } +.donut-ring circle.seg.cat-dry { stroke: var(--cat-dry); } +.dl i.cat-hot { background: var(--cat-hot); } +.dl i.cat-humid { background: var(--cat-humid); } +.dl i.cat-leak { background: var(--cat-leak); } +.dl i.cat-cold { background: var(--cat-cold); } +.dl i.cat-dry { background: var(--cat-dry); } +.donut-note { margin-top: var(--sp-2); } + +/* ── trend 탭 위젯 그리드 (기존 .overview 12열 위에서 3타일/행 균등 배치) ────── */ +.trend-widgets > .ov-tile { grid-column: span 4; } +@media (max-width: 1080px) { .trend-widgets > .ov-tile { grid-column: span 6; } } + +/* ── system 탭 패널 그리드 ──────────────────────────────────────────────────── */ +.system-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: var(--sp-4); align-items: start; } +.system-grid > .panel { margin: 0; } +.playbook-item { border-top: 1px solid var(--line-2); padding: var(--sp-2) 0; } +.playbook-item:first-of-type { border-top: none; padding-top: 0; } +.playbook-item summary { cursor: pointer; font-size: .875rem; font-weight: 700; color: var(--ink); padding: 4px 0; } +.playbook-item .playbook-steps { margin: var(--sp-2) 0 var(--sp-2); padding-left: 1.6em; } +.playbook-item .playbook-steps li { font-size: .875rem; line-height: 1.7; } +.link-list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--sp-2); } +.link-list li { font-size: .875rem; color: var(--ink-2); } +.link-list a { font-weight: 700; } + +/* ── 반응형: KPI 3×2 / 탭바 모바일 sticky(모바일은 header 가 static) ─────────── */ +@media (max-width: 900px) { + .kpi-strip { grid-template-columns: repeat(3, minmax(0,1fr)); } + .mini-panels { grid-template-columns: 1fr; } + .system-grid { grid-template-columns: 1fr; } +} +@media (max-width: 768px) { + .tabbar { position: sticky; top: 0; z-index: 18; border-radius: 0; margin-left: calc(-1 * var(--sp-5)); margin-right: calc(-1 * var(--sp-5)); border-left: none; border-right: none; } +} +@media (max-width: 480px) { + .kpi-strip { grid-template-columns: repeat(2, minmax(0,1fr)); } + .tabbar { margin-left: calc(-1 * var(--sp-3)); margin-right: calc(-1 * var(--sp-3)); } +} + +/* ── 인쇄 보강: 탐색 요소 숨김 + 범주 토큰 고대비 값 ─────────────────────────── */ +@media print { + :root { + --cat-hot: #b34a1f; --cat-humid: #8a5a00; --cat-leak: #b11f1f; + --cat-cold: #0f5e74; --cat-dry: #3b4aa0; + } + .tabbar, .print-note { display: none !important; } + .alert-strip, .kpi { box-shadow: none; border: 1px solid #999; } + .kpi::before { display: none; } + .alert-strip em { display: none; } +} diff --git a/php/dashboard.php b/php/dashboard.php index 223c8fe..9e59590 100644 --- a/php/dashboard.php +++ b/php/dashboard.php @@ -1,18 +1,24 @@ '정상', - 'high_temp' => '고온', - 'low_temp' => '저온', - 'high_humidity' => '고습', - 'low_humidity' => '저습', - ]; - return $labels[$status] ?? $status; +// ── 탭 라우팅 ($_GET 화이트리스트 — dashboard_data.php) ───── +$current_tab = dash_current_tab(); // live|trend|history|system|all +$tabs_to_render = $current_tab === 'all' ? array_keys(DASH_TABS) : [$current_tab]; + +// ── 데이터 로드: 전역(모든 탭) + 활성 탭 전용 ─────────────── +// 시스템 점검/전체 탭에서는 자가진단 캐시를 우회(진단이 주인공인 화면은 항상 신선) +$G = load_global_data($db, in_array($current_tab, ['system', 'all'], true)); +extract($G); + +// 탭 partial 렌더 — 함수 스코프로 격리해 셸 변수 오염·탭 간 변수 누출을 차단한다. +// EXTR_SKIP: 만약 로더 키가 전역 키와 겹쳐도 조용히 덮어쓰지 않는다. +function dash_render_tab(string $tab_file, array $shared_vars, array $tab_vars): void { + extract($shared_vars, EXTR_SKIP); + extract($tab_vars, EXTR_SKIP); + include $tab_file; } -// 측정값을 사람이 읽는 짧은 문자열로 (소수점 1자리) -function metric_display(?string $value, string $unit): string { - if ($value === null || $value === '') { - return '-'; - } - return number_format((float)$value, 1) . $unit; -} - -// 테이블 존재 확인(누수 미통합 서버에서 leak_incident/leak_photo 부재 시 graceful degrade). -function table_exists(PDO $db, string $table): bool { - try { - $stmt = $db->prepare(" - SELECT COUNT(*) FROM information_schema.TABLES - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table - "); - $stmt->execute([':table' => $table]); - return (int)$stmt->fetchColumn() > 0; - } catch (Throwable $e) { - return false; - } -} - -// ── 센서별 현재 상태 + 최신 측정값 ────────────────────────── -$statuses = $db->query("SELECT * FROM leak_sensor_status ORDER BY sensor_id")->fetchAll(); - -// 센서별 최신 leak_sensor_metric (최대 id 기준) -$latest_metrics = []; -$metric_rows = $db->query(" - SELECT sm.* - FROM leak_sensor_metric sm - INNER JOIN ( - SELECT sensor_id, MAX(id) AS latest_id - FROM leak_sensor_metric - GROUP BY sensor_id - ) latest ON latest.latest_id = sm.id - ORDER BY sm.sensor_id -")->fetchAll(); -foreach ($metric_rows as $metric_row) { - $latest_metrics[(int)$metric_row['sensor_id']] = $metric_row; -} - -// 각 센서에 대해 실시간 오프라인/경보 상태 계산 -$now = time(); -foreach ($statuses as &$s) { - $sid = (int)$s['sensor_id']; - $metric = $latest_metrics[$sid] ?? null; - $s['latest_metric'] = $metric; - - $last_seen_ts = strtotime($s['last_seen'] ?? '2000-01-01'); - $elapsed = $now - $last_seen_ts; - // offline_alerted 플래그가 1이거나, last_seen이 HEARTBEAT_TIMEOUT_SEC 이상 경과했으면 오프라인 - $s['is_offline'] = ($s['offline_alerted'] == 1) || ($elapsed >= HEARTBEAT_TIMEOUT_SEC); - $s['offline_elapsed_min'] = (int)round($elapsed / 60); - - $status_code = $metric['metric_status'] ?? 'normal'; - $s['metric_status'] = $status_code; - $s['is_alerting'] = ($status_code !== 'normal'); -} -unset($s); // 참조 해제 - -// ── 온습도 추이 차트용: 한 센서의 최근 100개 측정 ─────────── -// 기본 sensor_id=2, 없으면 첫 센서 -$chart_sensor_ids = array_map(fn($s) => (int)$s['sensor_id'], $statuses); -$chart_sensor_id = in_array(2, $chart_sensor_ids, true) - ? 2 - : ($chart_sensor_ids[0] ?? 0); - -$chart_sensor_name = ''; -foreach ($statuses as $s) { - if ((int)$s['sensor_id'] === $chart_sensor_id) { - $chart_sensor_name = $s['sensor_name']; - break; - } -} - -// 추이 차트 기간(오늘/3일/7일) — 링크 토글(폐쇄망: 외부 라이브러리 없음, 서버 렌더) -$RANGE_DAYS = ['today' => 1, '3d' => 3, '7d' => 7]; -$chart_range = (isset($_GET['range']) && isset($RANGE_DAYS[$_GET['range']])) ? $_GET['range'] : '3d'; -$range_days = $RANGE_DAYS[$chart_range]; - -$chart_points = []; -if ($chart_sensor_id > 0) { - $chart_stmt = $db->prepare(" - SELECT measured_at, temperature_c, humidity_percent - FROM leak_sensor_metric - WHERE sensor_id = :sid AND measured_at >= DATE_SUB(NOW(), INTERVAL :days DAY) - ORDER BY id DESC - LIMIT 600 - "); - $chart_stmt->bindValue(':sid', $chart_sensor_id, PDO::PARAM_INT); - $chart_stmt->bindValue(':days', $range_days, PDO::PARAM_INT); - $chart_stmt->execute(); - $chart_rows = $chart_stmt->fetchAll(); - // 오래된 → 최신 순으로 (그래프 좌→우) - $chart_rows = array_reverse($chart_rows); - foreach ($chart_rows as $cr) { - $chart_points[] = [ - 't' => $cr['measured_at'], - 'temp' => $cr['temperature_c'] !== null ? (float)$cr['temperature_c'] : null, - 'hum' => $cr['humidity_percent'] !== null ? (float)$cr['humidity_percent'] : null, - ]; - } -} - -// ── 최근 측정 이력 (leak_sensor_metric 30건) ───────────────────── -$recent_metrics = $db->query(" - SELECT sensor_id, sensor_name, device_location, - temperature_c, humidity_percent, metric_status, measured_at - FROM leak_sensor_metric - ORDER BY id DESC - LIMIT 30 -")->fetchAll(); - -// ── 최근 센서 이벤트 로그 (leak_sensor_log: startup/periodic/leak_alert/leak_recovery) ── -$logs = $db->query(" - SELECT id, sensor_name, device_location, event_type, is_leak, sensor_time - FROM leak_sensor_log - ORDER BY id DESC - LIMIT 30 -")->fetchAll(); - -// ── 최근 SMS 발송 이력 ───────────────────────────────────── -$sms_logs = $db->query(" - SELECT * FROM leak_sms_log - ORDER BY id DESC - LIMIT 20 -")->fetchAll(); - -// ── 30일 운영 요약 ───────────────────────────────────────── -// 종류별 임계 경보 SMS 건수 (leak_sms_log message prefix 기준) -$sms_summary_rows = $db->query(" - SELECT - " . sms_summary_select_sql() . " - FROM leak_sms_log - WHERE sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) -")->fetch(); - -$summary = [ - 'high_temp' => (int)($sms_summary_rows['high_temp'] ?? 0), - 'low_temp' => (int)($sms_summary_rows['low_temp'] ?? 0), - 'high_humidity' => (int)($sms_summary_rows['high_humidity'] ?? 0), - 'low_humidity' => (int)($sms_summary_rows['low_humidity'] ?? 0), - 'recovery' => (int)($sms_summary_rows['recovery'] ?? 0), - 'offline' => (int)($sms_summary_rows['device_offline'] ?? 0), - 'device_online' => (int)($sms_summary_rows['device_online'] ?? 0), - 'leak_alert' => (int)($sms_summary_rows['leak_alert'] ?? 0), - 'leak_recovery' => (int)($sms_summary_rows['leak_recovery'] ?? 0), - 'sms_fail' => (int)($sms_summary_rows['sms_fail'] ?? 0), +$tab_loaders = [ + 'live' => 'load_tab_live', + 'trend' => 'load_tab_trend', + 'history' => 'load_tab_history', + 'system' => 'load_tab_system', ]; -$summary['alert_total'] = $summary['high_temp'] + $summary['low_temp'] - + $summary['high_humidity'] + $summary['low_humidity']; - -// 30일 평균 온습도 + 최근 온습도(차트 센서 기준 최신값) -$metric_avg = $db->query(" - SELECT AVG(temperature_c) AS avg_temp, AVG(humidity_percent) AS avg_hum - FROM leak_sensor_metric - WHERE measured_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) -")->fetch(); -$avg_temp = $metric_avg['avg_temp'] !== null ? round((float)$metric_avg['avg_temp'], 1) : null; -$avg_hum = $metric_avg['avg_hum'] !== null ? round((float)$metric_avg['avg_hum'], 1) : null; - -$latest_overall = !empty($chart_points) ? end($chart_points) : null; -$latest_temp = $latest_overall['temp'] ?? null; -$latest_hum = $latest_overall['hum'] ?? null; - -// ── 운영 자가진단 (ops_checks.php) ───────────────────────── -$ops_checks = collect_ops_checks(); -$ops_failed = ops_failed_count($ops_checks); -$ops_critical_count = count(array_filter( - $ops_checks, - fn($c) => !$c['ok'] && $c['severity'] === 'critical' -)); -$ops_warn_count = count(array_filter( - $ops_checks, - fn($c) => !$c['ok'] && $c['severity'] !== 'critical' -)); -// 컴팩트 표시: 실패(확인 필요) 항목만 카드로 노출하고 정상 항목은 접는다(패널 길이 억제). -$ops_issues = array_values(array_filter($ops_checks, fn($c) => !$c['ok'])); -$ops_ok = array_values(array_filter($ops_checks, fn($c) => $c['ok'])); -usort($ops_issues, fn($a, $b) => ($a['severity'] === 'critical' ? 0 : 1) <=> ($b['severity'] === 'critical' ? 0 : 1)); -$ops_ok_count = count($ops_ok); -$ops_pill = $ops_critical_count > 0 ? 'crit' : ($ops_warn_count > 0 ? 'warn' : 'ok'); -$ops_pill_text = $ops_failed > 0 ? '확인 필요' : '정상'; - -// ── 상단 시스템 상태 집계 ────────────────────────────────── -$offline_count = count(array_filter($statuses, fn($s) => $s['is_offline'])); -$alert_count = count(array_filter($statuses, fn($s) => $s['is_alerting'] && !$s['is_offline'])); -$normal_count = count($statuses) - $offline_count - $alert_count; - -// ── 경보 SMS 발송 실패 / 수집 거부 사유 (신뢰성·관측성) ───── -// 최근 1시간 내 경보/오프라인/에스컬레이션 SMS 가 실패했는가(미수신 가능 경고). -$recent_alert_fail = (int)$db->query(" - SELECT COUNT(*) FROM leak_sms_log - WHERE status = 'fail' - AND sent_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR) - AND (message LIKE '" . SMS_TAG_HIGH_TEMP . "%' OR message LIKE '" . SMS_TAG_LOW_TEMP . "%' - OR message LIKE '" . SMS_TAG_HIGH_HUMIDITY . "%' OR message LIKE '" . SMS_TAG_LOW_HUMIDITY . "%' - OR message LIKE '" . SMS_TAG_CONDENSATION . "%' OR message LIKE '" . SMS_TAG_DEVICE_OFFLINE . "%' - OR message LIKE '" . SMS_TAG_LEAK_ALERT . "%' OR message LIKE '" . SMS_TAG_LEAK_RECOVERY . "%' - OR message LIKE '" . SMS_TAG_ESCALATION . "%') -")->fetchColumn(); -// 최근 24시간(오늘) 수집 거부 사유별 집계(왜 데이터가 안 들어오는가). -$ingest_rejections = read_ingest_rejections(1); -$rejection_total = array_sum($ingest_rejections); -// 최근 거부 출처(IP·사유·모드) — '우리 노드 오설정 vs 외부 스캐너' 판별용. -$reject_events = read_ingest_reject_events(10); - -// ── 누수 모듈 현재 상태 + 사고/사진 ─────────────────── -// 누수 센서(LEAK_SENSOR_IDS, config.php — 기본 [1])의 leak_sensor_status.is_leak 로 -// 누수/정상을 표시한다. 오프라인 판정은 위 $statuses 계산과 동일한 로직을 -// 따른다(행이 없으면 '데이터 없음'). 신규 누수 지점은 config.local.php 의 -// 'LEAK_SENSOR_IDS' => [1, 3] 오버라이드로 카드가 추가된다. -$leak_statuses = []; // sensor_id => leak_sensor_status 행 (미수신이면 null) -foreach (LEAK_SENSOR_IDS as $leak_sid) { - $leak_statuses[$leak_sid] = null; -} -foreach ($statuses as $s) { - $sid = (int)$s['sensor_id']; - if (array_key_exists($sid, $leak_statuses)) { - $leak_statuses[$sid] = $s; - } +$tab_data = []; +foreach ($tabs_to_render as $render_tab_id) { + $tab_data[$render_tab_id] = $tab_loaders[$render_tab_id]($db, $G); } -// 온습도(SHT30) 센서 카드 목록: 기대 온습도 센서(METRIC_SENSOR_IDS)는 미수신도 -// placeholder(null)로 항상 표시한다(누수 카드와 동일 원칙 — 최초 기동 전 SHT30 이 -// 화면에서 조용히 빠지지 않게). 실제 온습도 데이터가 있는 센서도 포함하되, 순수 -// 누수 센서(측정값 없음)는 아래 "누수 감지" 섹션에서만 표시한다(중복 방지). -$metric_statuses = []; -foreach (METRIC_SENSOR_IDS as $mid) { - $metric_statuses[(int)$mid] = null; -} -foreach ($statuses as $s) { - $sid = (int)$s['sensor_id']; - if (!empty($s['latest_metric']) || array_key_exists($sid, $metric_statuses)) { - $metric_statuses[$sid] = $s; - } -} -ksort($metric_statuses); - -// 최근 누수 사고(에피소드) 20건 + 각 사고의 사진(leak_photo) — 테이블 없으면 graceful. -$leak_incidents = []; -$leak_photos_by_log = []; // log_id => [ ['id'=>..,'log_id'=>..,'captured_at'=>..], ... ] -if (table_exists($db, 'leak_incident')) { - $leak_incidents = $db->query(" - SELECT id, sensor_name, device_location, started_at, recovered_at, log_id_alert - FROM leak_incident - ORDER BY id DESC - LIMIT 20 - ")->fetchAll(); - - if (!empty($leak_incidents) && table_exists($db, 'leak_photo')) { - $photo_stmt = $db->prepare(" - SELECT id, log_id, captured_at - FROM leak_photo - WHERE log_id = :lid - ORDER BY id ASC - "); - foreach ($leak_incidents as $inc) { - $lid = $inc['log_id_alert'] !== null ? (int)$inc['log_id_alert'] : 0; - if ($lid < 1 || isset($leak_photos_by_log[$lid])) { - continue; - } - $photo_stmt->execute([':lid' => $lid]); - $leak_photos_by_log[$lid] = $photo_stmt->fetchAll(); - } - } -} - -if ($alert_count > 0) { - $system_state = 'danger'; - $system_title = '온습도 경보 발생'; - $system_detail = "임계 경보 센서 {$alert_count}개, 오프라인 {$offline_count}개"; -} elseif ($offline_count > 0) { - $system_state = 'warning'; - $system_title = '장비 오프라인 확인 필요'; - $system_detail = "오프라인 {$offline_count}개, 중요 진단 {$ops_critical_count}건"; -} elseif ($ops_critical_count > 0) { - $system_state = 'warning'; - $system_title = '운영 확인 필요'; - $system_detail = "중요 진단 {$ops_critical_count}건을 확인하세요."; -} else { - $system_state = 'normal'; - $system_title = '정상 감시 중'; - $system_detail = '현재 온습도 경보와 중요 운영 경고가 없습니다.'; -} - -// ── 예보·대응 신호 (forecast.php — 순수계산) ────────────────────────────── -// 기존 측정 데이터만으로 임계 여유·추세 ETA·이슬점·변화율·신선도를 산출한다. -// $statuses 는 모든 센서(누수 노드 포함)를 담으므로 한 번만 순회한다(중복 없음). -require_once __DIR__ . '/forecast.php'; -$forecasts = []; // sensor_id => forecast_metric() 결과 (카드 예보줄에 사용) -$risk_items_all = []; // 대응 섹션용 정규화 위험 목록 -if (FORECAST_ENABLED) { - $fc_cfg = [ - 'temp_high' => METRIC_TEMP_HIGH_C, - 'temp_low' => METRIC_TEMP_LOW_C, - 'rh_high' => METRIC_RH_HIGH, - 'rh_low' => (METRIC_RH_LOW >= 0 ? METRIC_RH_LOW : null), // -1 = 비활성 - 'dew_margin'=> METRIC_DEWPOINT_MARGIN_C, // 0 이하 = 결로 판정 스킵 - 'warn_temp' => FORECAST_WARN_MARGIN_TEMP_C, - 'warn_rh' => FORECAST_WARN_MARGIN_RH, - 'horizon_min'=> FORECAST_HORIZON_MIN, - 'min_points'=> FORECAST_MIN_POINTS, - 'min_r2' => FORECAST_MIN_R2, - 'rate_temp_10'=> FORECAST_RATE_TEMP_C_PER_10MIN, - 'rate_rh_10' => FORECAST_RATE_RH_PER_10MIN, - ]; - $now_ts = time(); - $recent_stmt = $db->prepare( - "SELECT temperature_c, humidity_percent, UNIX_TIMESTAMP(measured_at) AS t - FROM leak_sensor_metric WHERE sensor_id = :sid ORDER BY id DESC LIMIT :lim" - ); - foreach ($statuses as $s) { - $sid = (int)$s['sensor_id']; - $metric = $s['latest_metric'] ?? null; - $temp = ($metric && $metric['temperature_c'] !== null) ? (float)$metric['temperature_c'] : null; - $hum = ($metric && $metric['humidity_percent'] !== null) ? (float)$metric['humidity_percent'] : null; - - $recent = []; - if ($temp !== null || $hum !== null) { - $recent_stmt->bindValue(':sid', $sid, PDO::PARAM_INT); - $recent_stmt->bindValue(':lim', (int)FORECAST_RECENT_POINTS, PDO::PARAM_INT); - $recent_stmt->execute(); - foreach (array_reverse($recent_stmt->fetchAll()) as $rr) { // 오래된→최신 - $recent[] = [ - 't' => (int)$rr['t'], - 'temp' => $rr['temperature_c'] !== null ? (float)$rr['temperature_c'] : null, - 'hum' => $rr['humidity_percent'] !== null ? (float)$rr['humidity_percent'] : null, - ]; - } - } - - $fc = forecast_metric($temp, $hum, $recent, $fc_cfg); - $forecasts[$sid] = $fc; - - // 신선도 — offline 은 대시보드 기존 판정($s['is_offline'])을 단일 진실원으로 사용 - $fr = forecast_freshness(strtotime($s['last_seen'] ?? '') ?: null, $now_ts, HEARTBEAT_TIMEOUT_SEC); - $fr['offline'] = (bool)$s['is_offline']; - if ($fr['offline']) { $fr['imminent'] = false; } - - $risk_items_all = array_merge($risk_items_all, risk_items( - ['sensor_id' => $sid, 'name' => $s['sensor_name'], 'location' => $s['device_location'], - 'is_leak' => (int)($s['is_leak'] ?? 0)], - $fc, $fr - )); - } - // critical 먼저, 그다음 warn - usort($risk_items_all, fn($a, $b) => ($a['severity'] === 'critical' ? 0 : 1) <=> ($b['severity'] === 'critical' ? 0 : 1)); -} - -// ── 개요 벤토 위젯 데이터 (측정 기준 — 하단 측정 이력·이벤트 로그와 동일 소스로 정합) ── -// 상단 경보 집계는 SMS 발송(leak_sms_log)이 아니라 실제 센서 데이터 기준으로 센다: -// · 온습도 경보 = leak_sensor_metric.metric_status (비정상 측정) — 하단 "측정 이력"과 동일 소스 -// · 누수 = leak_sensor_log.event_type='leak_alert' (누수 감지 이벤트) — "이벤트 로그"와 동일 -// (SMS 발송 통계는 하단 "30일 운영 요약"에 별도로, 명시적 SMS 라벨로 유지) - -// (1) 가동률 게이지 — 온라인 센서 비율 (last_seen 기준, 경보 소스와 무관) -$sensor_total = count($statuses); -$online_count = $sensor_total - $offline_count; -$uptime_pct = $sensor_total > 0 ? (int)round($online_count / $sensor_total * 100) : 100; - -// (2) 7일 일별 온습도 경보 측정 / 누수 감지 — 게이지 스파크라인 + 발생 추이 라인차트 공용 -$mtrend_map = $db->query(" - SELECT DATE(measured_at) d, COUNT(*) c - FROM leak_sensor_metric - WHERE measured_at >= DATE_SUB(CURDATE(), INTERVAL 6 DAY) AND metric_status <> 'normal' - GROUP BY DATE(measured_at) -")->fetchAll(PDO::FETCH_KEY_PAIR); -$ltrend_map = $db->query(" - SELECT DATE(sensor_time) d, COUNT(*) c - FROM leak_sensor_log - WHERE event_type = 'leak_alert' AND sensor_time >= DATE_SUB(CURDATE(), INTERVAL 6 DAY) - GROUP BY DATE(sensor_time) -")->fetchAll(PDO::FETCH_KEY_PAIR); -$trend_days = []; $trend_metric = []; $trend_leak = []; $spark_alerts = []; -for ($i = 6; $i >= 0; $i--) { - $d = date('Y-m-d', strtotime("-$i day")); - $tm = (int)($mtrend_map[$d] ?? 0); - $tl = (int)($ltrend_map[$d] ?? 0); - $trend_days[] = $d; - $trend_metric[] = $tm; - $trend_leak[] = $tl; - $spark_alerts[] = $tm + $tl; -} -$trend_max = max(1, max(array_merge($trend_metric, $trend_leak))); -$spark_today = end($spark_alerts); -reset($spark_alerts); -if ($alert_count > 0) { $ov_badge = 'crit'; $ov_badge_txt = '경보'; } -elseif ($offline_count > 0 || $spark_today > 0) { $ov_badge = 'warn'; $ov_badge_txt = '주의'; } -else { $ov_badge = 'ok'; $ov_badge_txt = '정상'; } - -// (3) 경보 최다 센서 리더보드 (30일) — 온습도 경보 측정 + 누수 감지, 센서별 합산 -$board_metric = $db->query(" - SELECT sensor_name, COUNT(*) c - FROM leak_sensor_metric - WHERE measured_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) AND metric_status <> 'normal' - GROUP BY sensor_name -")->fetchAll(PDO::FETCH_KEY_PAIR); -$board_leak = $db->query(" - SELECT sensor_name, COUNT(*) c - FROM leak_sensor_log - WHERE event_type = 'leak_alert' AND sensor_time >= DATE_SUB(NOW(), INTERVAL 30 DAY) - GROUP BY sensor_name -")->fetchAll(PDO::FETCH_KEY_PAIR); -$board_combined = []; -foreach ([$board_metric, $board_leak] as $src) { - foreach ($src as $name => $c) { $board_combined[$name] = ($board_combined[$name] ?? 0) + (int)$c; } -} -arsort($board_combined); -$board_rows = []; -foreach (array_slice($board_combined, 0, 6, true) as $name => $c) { - $board_rows[] = ['sensor_name' => $name, 'c' => $c]; -} -$board_max = $board_rows ? max(array_column($board_rows, 'c')) : 0; - -// (4) 경보 종류 도넛 (30일) — 온습도 경보 상태별 측정 건수 + 누수 감지 건수 -$mstatus_map = $db->query(" - SELECT metric_status, COUNT(*) c - FROM leak_sensor_metric - WHERE measured_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) AND metric_status <> 'normal' - GROUP BY metric_status -")->fetchAll(PDO::FETCH_KEY_PAIR); -$leak_alert_30d = (int)$db->query(" - SELECT COUNT(*) FROM leak_sensor_log - WHERE event_type = 'leak_alert' AND sensor_time >= DATE_SUB(NOW(), INTERVAL 30 DAY) -")->fetchColumn(); -$donut_segs = [ - ['label' => '고온', 'val' => (int)($mstatus_map['high_temp'] ?? 0), 'color' => '#ff8a5c'], - ['label' => '고습', 'val' => (int)($mstatus_map['high_humidity'] ?? 0), 'color' => '#ffb23e'], - ['label' => '누수', 'val' => $leak_alert_30d, 'color' => '#ff5470'], - ['label' => '저온', 'val' => (int)($mstatus_map['low_temp'] ?? 0), 'color' => '#5fd6ee'], - ['label' => '저습', 'val' => (int)($mstatus_map['low_humidity'] ?? 0), 'color' => '#b0b8ff'], +// KPI 스트립 타일 정의: [라벨, 값, 부가텍스트, 상태클래스, 이동 탭] +$kpi_tiles = [ + ['경보 센서', $alert_count . '개', null, + $alert_count > 0 ? 'err' : 'ok', 'live'], + ['오프라인', $offline_count . '개', $online_count . '/' . $sensor_total . ' 온라인', + $offline_count > 0 ? 'warn' : 'ok', 'live'], + ['미복구 누수', $unrecovered_count . '건', null, + $unrecovered_count > 0 ? 'err' : 'ok', 'live'], + ['오늘 경보', $today_alert_count . '건', '측정·이벤트 건수', + $today_alert_count > 0 ? 'warn' : 'ok', 'trend'], + ['SMS 잔여', $sms_balance !== null ? $sms_balance . '건' : '확인 전', null, + ($sms_balance !== null && $sms_balance <= SMS_BALANCE_WARN_THRESHOLD) ? 'err' : 'ok', 'system'], + ['중요 진단', $ops_critical_count . '건', null, + $ops_critical_count > 0 ? 'warn' : 'ok', 'system'], ]; -usort($donut_segs, fn($a, $b) => $b['val'] <=> $a['val']); -$donut_total = array_sum(array_column($donut_segs, 'val')); - -// (5) 추이 차트 피크(기간 내 최고 온도) — $chart_points 재사용 -$peak_i = null; $peak_v = null; -foreach ($chart_points as $ci => $cp) { - if ($cp['temp'] !== null && ($peak_v === null || $cp['temp'] > $peak_v)) { - $peak_v = (float)$cp['temp']; $peak_i = $ci; - } -} ?> @@ -514,31 +106,11 @@ foreach ($chart_points as $ci => $cp) {