refactor(dashboard): 탭 구조 전면 재개편(실시간/추이/이력/시스템)
단일 1594줄 스크롤 페이지를 상시 영역 + 4탭 서버렌더(?tab=) 구조로 재개편한다. 5인 리뷰 패널(정보구조·관제도메인·UX·구현·접근성) 종합 스펙 기반. 상시 영역(전 탭 공통): - 시스템 상태 히어로 — 누수(is_leak)·미복구 사고를 FORECAST_ENABLED 와 무관하게 반영(기존: 누수 중에도 '정상 감시 중' 표시 가능한 결함). 경보 시 센서명·위치· 현재값·경보 시작 시각·SMS 발송 결과를 한 줄로 명시. - 알림 스트립(SMS 실패/미복구 누수/수집 거부 1줄 요약 + 탭 딥링크) - 대응 필요 패널(플레이북·담당자) — 안전 알림은 탭과 무관하게 상시 노출 - 통합 KPI 스트립 6타일 — 모바일 퀵타일·듀얼 KPI·게이지 배지를 하나로 통합 - 탭바(nav+aria-current, 경보 배지) 탭: - 실시간 현황: 온습도/누수 카드(예보 여유 상시 표시), 미복구 사고, SMS 채널·오늘 요약 - 추이·통계: 온습도 추이 차트, 7일 경보 추이, 경보 종류 도넛(단위 명기), 가동률, 리더보드 - 이력 조회: 누수 사고·측정·이벤트·SMS 이력, 30일 센서별 평균 온습도, SMS 발송 통계 - 시스템 점검: 자가진단, 수집 거부 관측성, SMS 채널 점검, 플레이북 사본, 임계 설정 요약 - ?tab=all: 인쇄용 전체 렌더(details 강제 펼침, 자동 새로고침 없음) 구현: - 데이터 계층 분리(dashboard_data.php): 전역 vs 탭 전용 쿼리 — 로드당 쿼리 ~50→전역 14+탭 5~8개(약 60% 절감). ops_checks 파일 캐시(TTL 120초, 시스템 탭은 우회). - 탭 partial(tabs/*.php)은 dash_render_tab() 함수 스코프 격리(EXTR_SKIP)로 렌더. - 30초 자동 새로고침은 URL 쿼리 보존으로 탭·기간 유지. 스크롤/details 복원은 탭별 키. - 폐쇄망 유지: 외부 CDN/JS 없음(인라인 SVG). app.css 는 기존 규칙 불변, 추가만. - 보안: dashboard_data·forecast·tabs 를 .htaccess FilesMatch + tabs/.htaccess + PHP 직접접근 가드로 삼중 차단. security_hardening_test: 대시보드 표면을 분리 구조(셸+데이터+탭)에 맞춰 검사하도록 보정.
This commit is contained in:
parent
0966f6d32b
commit
f063060e2d
10 changed files with 2083 additions and 1353 deletions
|
|
@ -21,7 +21,8 @@
|
||||||
</IfModule>
|
</IfModule>
|
||||||
|
|
||||||
# ─── 민감 PHP 파일 직접 접근 차단 ─────────────────────────
|
# ─── 민감 PHP 파일 직접 접근 차단 ─────────────────────────
|
||||||
<FilesMatch "^(config|config\.local|config\.local\.example|sms_send|incidents|ops_checks|setup_hash|test_mobile)\.php$">
|
# (dashboard_data·forecast·live·trend·history·system 은 include 전용 — tabs/.htaccess 와 이중 차단)
|
||||||
|
<FilesMatch "^(config|config\.local|config\.local\.example|sms_send|incidents|ops_checks|setup_hash|test_mobile|dashboard_data|forecast|live|trend|history|system)\.php$">
|
||||||
<IfModule mod_authz_core.c>
|
<IfModule mod_authz_core.c>
|
||||||
Require all denied
|
Require all denied
|
||||||
</IfModule>
|
</IfModule>
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
.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; }
|
.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; }
|
||||||
|
}
|
||||||
|
|
|
||||||
1517
php/dashboard.php
1517
php/dashboard.php
File diff suppressed because it is too large
Load diff
756
php/dashboard_data.php
Normal file
756
php/dashboard_data.php
Normal file
|
|
@ -0,0 +1,756 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
9
php/tabs/.htaccess
Normal file
9
php/tabs/.htaccess
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# tabs/ 는 dashboard.php 가 include 하는 뷰 partial 전용 — 웹 직접 접근 전면 차단.
|
||||||
|
# (각 partial 의 APP_VERSION 가드 + 루트 .htaccess FilesMatch 와 삼중 방어)
|
||||||
|
<IfModule mod_authz_core.c>
|
||||||
|
Require all denied
|
||||||
|
</IfModule>
|
||||||
|
<IfModule !mod_authz_core.c>
|
||||||
|
Order deny,allow
|
||||||
|
Deny from all
|
||||||
|
</IfModule>
|
||||||
234
php/tabs/history.php
Normal file
234
php/tabs/history.php
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
<?php
|
||||||
|
// tabs/history.php — [이력 조회] 탭. dashboard.php 셸에서 include 된다.
|
||||||
|
// 장애 원인 추적용: 누수 사고·측정·이벤트·SMS 이력을 한 탭에서 대조한다.
|
||||||
|
// 사용 변수: load_tab_history() 결과(extract) — $leak_incidents, $leak_photos_by_log,
|
||||||
|
// $recent_metrics, $logs, $sms_logs, $sms_stats.
|
||||||
|
if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
|
||||||
|
?>
|
||||||
|
<div class="zone" id="tab-history">
|
||||||
|
<div class="zone-label"><b>운영 이력</b> <span class="en">History</span></div>
|
||||||
|
|
||||||
|
<!-- 누수 사고 이력 + 사진 -->
|
||||||
|
<h2>누수 사고 이력 (최근 20건)</h2>
|
||||||
|
<details class="mobile-detail" id="d-incidents" data-mobile-collapsed open>
|
||||||
|
<summary>누수 사고 이력 <span>최근 20건</span></summary>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-hide-xs">#</th><th>센서</th><th class="col-hide-xs">위치</th>
|
||||||
|
<th>감지 시각</th><th>복구 시각</th><th>사진</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($leak_incidents as $inc):
|
||||||
|
$is_open = ($inc['recovered_at'] === null);
|
||||||
|
$lid = $inc['log_id_alert'] !== null ? (int)$inc['log_id_alert'] : 0;
|
||||||
|
$photos = $leak_photos_by_log[$lid] ?? [];
|
||||||
|
?>
|
||||||
|
<tr class="<?= $is_open ? 'alert-row' : '' ?>">
|
||||||
|
<td class="col-hide-xs"><?= (int)$inc['id'] ?></td>
|
||||||
|
<td><?= htmlspecialchars($inc['sensor_name']) ?></td>
|
||||||
|
<td class="col-hide-xs"><?= htmlspecialchars($inc['device_location']) ?></td>
|
||||||
|
<td><?= htmlspecialchars($inc['started_at']) ?></td>
|
||||||
|
<td>
|
||||||
|
<?php if ($is_open): ?>
|
||||||
|
<span class="pill leak">미복구</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<?= htmlspecialchars($inc['recovered_at']) ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<?php if (!empty($photos)): ?>
|
||||||
|
<div class="leak-thumbs">
|
||||||
|
<?php foreach ($photos as $ph): ?>
|
||||||
|
<a href="photo.php?id=<?= (int)$ph['id'] ?>" target="_blank" rel="noopener">
|
||||||
|
<img src="photo.php?id=<?= (int)$ph['id'] ?>"
|
||||||
|
alt="누수 사진 <?= htmlspecialchars($ph['captured_at']) ?>"
|
||||||
|
loading="lazy">
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="caption">-</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (empty($leak_incidents)): ?>
|
||||||
|
<tr><td colspan="6" class="empty-cell">아직 누수 사고 이력이 없습니다.</td></tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<!-- 최근 온습도 측정 이력 -->
|
||||||
|
<h2 id="metrics-section">최근 온습도 측정 (30건)</h2>
|
||||||
|
<details class="mobile-detail" id="d-metrics" data-mobile-collapsed open>
|
||||||
|
<summary>최근 온습도 측정 <span>30건 기준</span></summary>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>측정시각</th><th>센서</th><th class="col-hide-xs">위치</th>
|
||||||
|
<th>온도</th><th>습도</th><th>상태</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($recent_metrics as $row):
|
||||||
|
$status_code = $row['metric_status'] ?? 'normal';
|
||||||
|
$is_alert = $status_code !== 'normal';
|
||||||
|
?>
|
||||||
|
<tr class="<?= $is_alert ? 'alert-row' : '' ?>">
|
||||||
|
<td><?= htmlspecialchars($row['measured_at']) ?></td>
|
||||||
|
<td><?= htmlspecialchars($row['sensor_name']) ?></td>
|
||||||
|
<td class="col-hide-xs"><?= htmlspecialchars($row['device_location']) ?></td>
|
||||||
|
<td><?= htmlspecialchars(metric_display($row['temperature_c'], '°C')) ?></td>
|
||||||
|
<td><?= htmlspecialchars(metric_display($row['humidity_percent'], '%')) ?></td>
|
||||||
|
<td><span class="pill <?= htmlspecialchars($status_code) ?>"><?= htmlspecialchars(metric_status_label($status_code)) ?></span></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (empty($recent_metrics)): ?>
|
||||||
|
<tr><td colspan="6" class="empty-cell">아직 온습도 측정 데이터가 없습니다.</td></tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<!-- 센서 이벤트 로그 (startup/periodic/leak_alert/leak_recovery) -->
|
||||||
|
<h2 id="logs-section">센서 이벤트 로그 (30건)</h2>
|
||||||
|
<details class="mobile-detail" id="d-logs" data-mobile-collapsed open>
|
||||||
|
<summary>센서 이벤트 로그 <span>30건 기준</span></summary>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-hide-xs">#</th><th>시각</th><th>센서</th>
|
||||||
|
<th class="col-hide-xs">위치</th><th>이벤트</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($logs as $row):
|
||||||
|
$event_labels = [
|
||||||
|
'startup' => '장비시작',
|
||||||
|
'periodic' => '정기보고',
|
||||||
|
'leak_alert' => '누수감지',
|
||||||
|
'leak_recovery' => '누수복구',
|
||||||
|
];
|
||||||
|
$evt = $row['event_type'];
|
||||||
|
?>
|
||||||
|
<tr class="<?= $evt === 'leak_alert' ? 'alert-row' : '' ?>">
|
||||||
|
<td class="col-hide-xs"><?= (int)$row['id'] ?></td>
|
||||||
|
<td><?= htmlspecialchars($row['sensor_time']) ?></td>
|
||||||
|
<td><?= htmlspecialchars($row['sensor_name']) ?></td>
|
||||||
|
<td class="col-hide-xs"><?= htmlspecialchars($row['device_location']) ?></td>
|
||||||
|
<td>
|
||||||
|
<?php if ($evt === 'leak_alert' || $evt === 'leak_recovery'): ?>
|
||||||
|
<span class="pill <?= htmlspecialchars($evt) ?>"><?= htmlspecialchars($event_labels[$evt]) ?></span>
|
||||||
|
<?php else: ?>
|
||||||
|
<?= htmlspecialchars($event_labels[$evt] ?? $evt) ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (empty($logs)): ?>
|
||||||
|
<tr><td colspan="5" class="empty-cell">아직 센서 이벤트가 없습니다.</td></tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<!-- SMS 발송 로그 -->
|
||||||
|
<h2 id="sms-section">SMS 발송 이력 (최근 20건)</h2>
|
||||||
|
<details class="mobile-detail" id="d-sms" data-mobile-collapsed open>
|
||||||
|
<summary>SMS 발송 이력 <span>최근 20건</span></summary>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th class="col-hide-xs">#</th><th>발송시각</th><th>센서</th><th>수신자</th><th>상태</th><th class="col-hide-xs">결과</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($sms_logs as $row): ?>
|
||||||
|
<tr>
|
||||||
|
<td class="col-hide-xs"><?= (int)$row['id'] ?></td>
|
||||||
|
<td><?= htmlspecialchars($row['sent_at']) ?></td>
|
||||||
|
<td><?= htmlspecialchars($row['sensor_name']) ?></td>
|
||||||
|
<td><?= htmlspecialchars(mask_phone($row['receivers'] ?? '')) ?></td>
|
||||||
|
<td>
|
||||||
|
<?php if ($row['status'] === 'success'): ?>
|
||||||
|
<span class="ok-color">성공</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="err-color">실패</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td class="col-hide-xs caption"><?= htmlspecialchars($row['result_message']) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (empty($sms_logs)): ?>
|
||||||
|
<tr><td colspan="6" class="empty-cell">아직 SMS 발송 이력이 없습니다.</td></tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<!-- 30일 평균 온습도 (센서별 — 전 센서 혼합 평균은 위치별 환경 차이를 뭉개므로 분리) -->
|
||||||
|
<?php if (!empty($metric_avgs)): ?>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>30일 평균 온습도 <span class="h2-sub">· 센서별</span></h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>센서</th><th class="col-hide-xs">위치</th><th>평균 온도</th><th>평균 습도</th><th class="col-hide-xs">측정 수</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($metric_avgs as $ma): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= htmlspecialchars($ma['name']) ?></td>
|
||||||
|
<td class="col-hide-xs"><?= htmlspecialchars($ma['location']) ?></td>
|
||||||
|
<td><?= $ma['avg_temp'] !== null ? htmlspecialchars(number_format((float)$ma['avg_temp'], 1)) . '°C' : '-' ?></td>
|
||||||
|
<td><?= $ma['avg_hum'] !== null ? htmlspecialchars(number_format((float)$ma['avg_hum'], 1)) . '%' : '-' ?></td>
|
||||||
|
<td class="col-hide-xs"><?= (int)$ma['n'] ?>건</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- 30일 SMS 발송 통계 (발송 건수 기준 — 측정 기준 경보 통계는 추이·통계 탭) -->
|
||||||
|
<section class="panel" id="summary-section">
|
||||||
|
<h2>30일 SMS 발송 통계</h2>
|
||||||
|
<div class="report-grid">
|
||||||
|
<div class="report-stat">
|
||||||
|
<span>총 발송</span>
|
||||||
|
<strong><?= (int)$sms_stats['total'] ?>건</strong>
|
||||||
|
</div>
|
||||||
|
<div class="report-stat">
|
||||||
|
<span>온습도 경보 SMS</span>
|
||||||
|
<strong class="<?= $sms_stats['alert_total'] > 0 ? 'warn-color' : '' ?>"><?= (int)$sms_stats['alert_total'] ?>건</strong>
|
||||||
|
</div>
|
||||||
|
<div class="report-stat">
|
||||||
|
<span>누수감지 SMS</span>
|
||||||
|
<strong class="<?= $sms_stats['leak_alert'] > 0 ? 'err-color' : '' ?>"><?= (int)$sms_stats['leak_alert'] ?>건</strong>
|
||||||
|
</div>
|
||||||
|
<div class="report-stat">
|
||||||
|
<span>복구 SMS</span>
|
||||||
|
<strong><?= (int)$sms_stats['recovery'] ?>건</strong>
|
||||||
|
</div>
|
||||||
|
<div class="report-stat">
|
||||||
|
<span>오프라인 SMS</span>
|
||||||
|
<strong><?= (int)$sms_stats['offline'] ?>건</strong>
|
||||||
|
</div>
|
||||||
|
<div class="report-stat">
|
||||||
|
<span>발송 실패</span>
|
||||||
|
<strong class="<?= $sms_stats['fail'] > 0 ? 'err-color' : '' ?>"><?= (int)$sms_stats['fail'] ?>건</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="panel-note">종류별 상세 집계와 월별 분석은 <a href="monthly_report.php">월간 보고</a>에서 확인하세요. 측정 기준 경보 통계(도넛·추이)는 <a href="<?= htmlspecialchars(dash_url(['tab' => 'trend'])) ?>">추이·통계 탭</a>에 있습니다.</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
214
php/tabs/live.php
Normal file
214
php/tabs/live.php
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
<?php
|
||||||
|
// tabs/live.php — [실시간 현황] 탭. dashboard.php 셸에서 include 된다.
|
||||||
|
// 사용 변수: load_global_data() 결과(extract) — $metric_statuses, $leak_statuses,
|
||||||
|
// $unrecovered_incidents, $forecasts, $sms_* , $today_* 등.
|
||||||
|
if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
|
||||||
|
?>
|
||||||
|
<div class="zone" id="tab-live">
|
||||||
|
<div class="zone-label"><b>실시간 상태</b> <span class="en">Live</span></div>
|
||||||
|
|
||||||
|
<!-- 온습도 센서 카드 -->
|
||||||
|
<h2 id="sensors-section">온습도 센서</h2>
|
||||||
|
<div class="sensor-grid">
|
||||||
|
<?php foreach ($metric_statuses as $metric_sid => $s):
|
||||||
|
// 미수신 placeholder: 기대 온습도 센서(METRIC_SENSOR_IDS)가 아직 보고 전이면
|
||||||
|
// '데이터 없음' 카드로 항상 노출한다(기대 센서가 화면에서 조용히 빠지지 않게).
|
||||||
|
if ($s === null): ?>
|
||||||
|
<div class="sensor-card">
|
||||||
|
<strong><?= (int)$metric_sid ?>번 센서 (온습도)</strong><br>
|
||||||
|
<div class="badge-row"><span class="badge offline">데이터 없음</span></div>
|
||||||
|
<div class="metric-time">아직 수신된 온습도 데이터가 없습니다.</div>
|
||||||
|
</div>
|
||||||
|
<?php continue; endif;
|
||||||
|
// 상태 우선순위: 오프라인 > 임계 경보 > 정상
|
||||||
|
$status_code = $s['metric_status'] ?? 'normal';
|
||||||
|
if ($s['is_offline']) {
|
||||||
|
$card_class = 'offline';
|
||||||
|
$badge_class = 'offline';
|
||||||
|
$badge_text = $s['is_alerting'] ? ('오프라인 (' . metric_status_label($status_code) . ' 중 끊김)') : '오프라인';
|
||||||
|
} elseif ($s['is_alerting']) {
|
||||||
|
$card_class = 'alert';
|
||||||
|
$badge_class = 'alert';
|
||||||
|
$badge_text = metric_status_label($status_code) . ' 경보!';
|
||||||
|
} else {
|
||||||
|
$card_class = '';
|
||||||
|
$badge_class = 'ok';
|
||||||
|
$badge_text = '정상';
|
||||||
|
}
|
||||||
|
|
||||||
|
$temp_chip_class = '';
|
||||||
|
$hum_chip_class = '';
|
||||||
|
if ($status_code === 'high_temp') { $temp_chip_class = 'hot'; }
|
||||||
|
elseif ($status_code === 'low_temp') { $temp_chip_class = 'cold'; }
|
||||||
|
elseif ($status_code === 'high_humidity') { $hum_chip_class = 'humid'; }
|
||||||
|
elseif ($status_code === 'low_humidity') { $hum_chip_class = 'dry'; }
|
||||||
|
?>
|
||||||
|
<div class="sensor-card <?= $card_class ?>">
|
||||||
|
<strong><?= htmlspecialchars($s['sensor_name']) ?></strong><br>
|
||||||
|
<small><?= htmlspecialchars($s['device_location']) ?> / <?= htmlspecialchars($s['device_id']) ?></small><br>
|
||||||
|
<div class="badge-row">
|
||||||
|
<span class="badge <?= $badge_class ?>">
|
||||||
|
<?= htmlspecialchars($badge_text) ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<?php if (!empty($s['latest_metric'])):
|
||||||
|
$metric = $s['latest_metric'];
|
||||||
|
$metric_temp = metric_display($metric['temperature_c'], '°C');
|
||||||
|
$metric_humidity = metric_display($metric['humidity_percent'], '%');
|
||||||
|
?>
|
||||||
|
<div class="metric-line">
|
||||||
|
<span class="metric-chip <?= $temp_chip_class ?>">온도 <?= htmlspecialchars($metric_temp) ?></span>
|
||||||
|
<span class="metric-chip <?= $hum_chip_class ?>">습도 <?= htmlspecialchars($metric_humidity) ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-time">측정: <?= htmlspecialchars($metric['measured_at']) ?></div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="metric-time">측정값 없음</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div class="metric-time<?= $s['is_offline'] ? ' stale' : '' ?>">
|
||||||
|
최종확인: <?= htmlspecialchars($s['last_seen']) ?>
|
||||||
|
<?php if ($s['is_offline']): ?>
|
||||||
|
(<?= $s['offline_elapsed_min'] ?>분 전)
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
// 카드 예보줄(상시): 온라인 + 예보 있으면 임계 여유를 항상 표시한다.
|
||||||
|
// 초과(over)/근접(near)은 경고색, 여유(safe)도 숫자를 회색 칩으로 상시 노출
|
||||||
|
// ("정상"이 임계까지 얼마나 남았는지 즉시 파악 — 리뷰 반영).
|
||||||
|
$sid_fc = (int)$s['sensor_id'];
|
||||||
|
$fc = (!$s['is_offline'] && isset($forecasts[$sid_fc])) ? $forecasts[$sid_fc] : null;
|
||||||
|
if ($fc):
|
||||||
|
$chips = [];
|
||||||
|
foreach ($fc['headroom'] as $h) {
|
||||||
|
$chips[] = ['state' => $h['state'], 'text' => $h['label'] . ' 여유 ' . $h['margin'] . $h['unit']];
|
||||||
|
}
|
||||||
|
foreach (['temp', 'hum'] as $vk) {
|
||||||
|
$e = $fc['trend'][$vk]['eta'] ?? null;
|
||||||
|
if ($e) { $chips[] = ['state' => 'near', 'text' => '↗ ~' . (int)$e['min'] . '분 뒤 ' . metric_status_label($e['key'])]; }
|
||||||
|
}
|
||||||
|
foreach ($fc['rate'] as $r) {
|
||||||
|
$chips[] = ['state' => 'near', 'text' => ($r['spike'] === 'rise' ? '급상승 ' : '급락 ') . ($r['per10'] > 0 ? '+' : '') . $r['per10'] . '/10분'];
|
||||||
|
}
|
||||||
|
if (!empty($fc['dew']['risk'])) {
|
||||||
|
$chips[] = ['state' => 'near', 'text' => '결로주의 (마진 ' . $fc['dew']['margin'] . '℃)'];
|
||||||
|
}
|
||||||
|
// 위험한 것 먼저: over > near > safe
|
||||||
|
$chip_rank = ['over' => 0, 'near' => 1, 'safe' => 2];
|
||||||
|
usort($chips, fn($a, $b) => ($chip_rank[$a['state']] ?? 3) <=> ($chip_rank[$b['state']] ?? 3));
|
||||||
|
?>
|
||||||
|
<div class="forecast">
|
||||||
|
<?php if ($chips): foreach ($chips as $c): ?>
|
||||||
|
<span class="forecast-chip <?= htmlspecialchars($c['state']) ?>"><?= htmlspecialchars($c['text']) ?></span>
|
||||||
|
<?php endforeach; else: ?>
|
||||||
|
<span class="forecast-note">예보: 임계 여유 충분</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (empty($metric_statuses)): ?>
|
||||||
|
<p class="empty-note">아직 수신된 센서 데이터가 없습니다.</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 누수 감지 카드 -->
|
||||||
|
<h2 id="leak-section">누수 감지</h2>
|
||||||
|
<div class="sensor-grid">
|
||||||
|
<?php foreach ($leak_statuses as $leak_sid => $leak_status):
|
||||||
|
// 누수 센서 상태 표시: 오프라인 > 누수 > 정상 우선순위
|
||||||
|
if ($leak_status === null) {
|
||||||
|
$leak_card_class = '';
|
||||||
|
$leak_badge_class = 'offline';
|
||||||
|
$leak_badge_text = '데이터 없음';
|
||||||
|
} elseif ($leak_status['is_offline']) {
|
||||||
|
$leak_card_class = 'offline';
|
||||||
|
$leak_badge_class = 'offline';
|
||||||
|
$leak_badge_text = ((int)($leak_status['is_leak'] ?? 0) === 1) ? '오프라인 (누수 중 끊김)' : '오프라인';
|
||||||
|
} elseif ((int)($leak_status['is_leak'] ?? 0) === 1) {
|
||||||
|
$leak_card_class = 'leak';
|
||||||
|
$leak_badge_class = 'leak';
|
||||||
|
$leak_badge_text = '누수';
|
||||||
|
} else {
|
||||||
|
$leak_card_class = '';
|
||||||
|
$leak_badge_class = 'ok';
|
||||||
|
$leak_badge_text = '정상';
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<div class="sensor-card leak-status-card <?= $leak_card_class ?>">
|
||||||
|
<?php if ($leak_status === null): ?>
|
||||||
|
<strong><?= (int)$leak_sid ?>번 센서 (누수)</strong><br>
|
||||||
|
<div class="badge-row">
|
||||||
|
<span class="badge <?= $leak_badge_class ?>"><?= htmlspecialchars($leak_badge_text) ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-time">아직 수신된 누수 센서 데이터가 없습니다.</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<strong><?= htmlspecialchars($leak_status['sensor_name']) ?></strong><br>
|
||||||
|
<small><?= htmlspecialchars($leak_status['device_location']) ?> / <?= htmlspecialchars($leak_status['device_id']) ?></small><br>
|
||||||
|
<div class="badge-row">
|
||||||
|
<span class="badge <?= $leak_badge_class ?>"><?= htmlspecialchars($leak_badge_text) ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-time<?= $leak_status['is_offline'] ? ' stale' : '' ?>">
|
||||||
|
최종확인: <?= htmlspecialchars($leak_status['last_seen']) ?>
|
||||||
|
<?php if ($leak_status['is_offline']): ?>
|
||||||
|
(<?= $leak_status['offline_elapsed_min'] ?>분 전)
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($unrecovered_incidents)): ?>
|
||||||
|
<!-- 미복구 누수 사고 (전체 이력·사진은 '이력 조회' 탭) -->
|
||||||
|
<h2>미복구 누수 사고 <span class="h2-sub">· <?= count($unrecovered_incidents) ?>건</span></h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th class="col-hide-xs">#</th><th>센서</th><th>위치</th><th>감지 시각</th><th>상태</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($unrecovered_incidents as $inc): ?>
|
||||||
|
<tr class="alert-row">
|
||||||
|
<td class="col-hide-xs"><?= (int)$inc['id'] ?></td>
|
||||||
|
<td><?= htmlspecialchars($inc['sensor_name']) ?></td>
|
||||||
|
<td><?= htmlspecialchars($inc['device_location']) ?></td>
|
||||||
|
<td><?= htmlspecialchars($inc['started_at']) ?></td>
|
||||||
|
<td><span class="pill leak">미복구</span></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="panel-note">사진·복구 시각 등 전체 사고 이력은 <a href="<?= htmlspecialchars(dash_url(['tab' => 'history'])) ?>">이력 조회 탭</a>에서 확인하세요.</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- 채널·오늘 요약 -->
|
||||||
|
<h2>채널·오늘 요약</h2>
|
||||||
|
<div class="mini-panels">
|
||||||
|
<section class="panel mini-panel" aria-label="SMS 채널 상태">
|
||||||
|
<h3>SMS 채널 상태</h3>
|
||||||
|
<dl class="mini-rows">
|
||||||
|
<div><dt>잔여 건수</dt>
|
||||||
|
<dd class="<?= ($sms_balance !== null && $sms_balance <= SMS_BALANCE_WARN_THRESHOLD) ? 'err-color' : '' ?>">
|
||||||
|
<?= $sms_balance !== null ? (int)$sms_balance . '건' : '확인 전' ?>
|
||||||
|
</dd></div>
|
||||||
|
<div><dt>마지막 성공 발송</dt>
|
||||||
|
<dd><?= $sms_last_success_at !== null ? htmlspecialchars($sms_last_success_at) : '기록 없음' ?></dd></div>
|
||||||
|
<div><dt>오늘 발송 / 실패</dt>
|
||||||
|
<dd><?= (int)$sms_today_total ?>건 / <span class="<?= $sms_today_fail > 0 ? 'err-color' : '' ?>"><?= (int)$sms_today_fail ?>건</span></dd></div>
|
||||||
|
</dl>
|
||||||
|
<p class="panel-note">SMS는 유일한 경보 채널입니다. 잔여 건수가 <?= (int)SMS_BALANCE_WARN_THRESHOLD ?>건 이하면 충전하세요.</p>
|
||||||
|
</section>
|
||||||
|
<section class="panel mini-panel" aria-label="오늘 경보 요약">
|
||||||
|
<h3>오늘 경보 요약</h3>
|
||||||
|
<dl class="mini-rows">
|
||||||
|
<div><dt>오늘 경보 횟수</dt>
|
||||||
|
<dd class="<?= $today_alert_count > 0 ? 'warn-color' : 'ok-color' ?>"><?= (int)$today_alert_count ?>건</dd></div>
|
||||||
|
<div><dt>오늘 최근 경보</dt>
|
||||||
|
<dd><?= $today_last_alert_at !== null ? htmlspecialchars($today_last_alert_at) : '오늘 없음' ?></dd></div>
|
||||||
|
<div><dt>가동률</dt>
|
||||||
|
<dd><?= (int)$uptime_pct ?>% (<?= (int)$online_count ?>/<?= (int)$sensor_total ?> 온라인)</dd></div>
|
||||||
|
</dl>
|
||||||
|
<p class="panel-note">7일·30일 추이는 <a href="<?= htmlspecialchars(dash_url(['tab' => 'trend'])) ?>">추이·통계 탭</a>에서 확인하세요.</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
182
php/tabs/system.php
Normal file
182
php/tabs/system.php
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
<?php
|
||||||
|
// tabs/system.php — [시스템 점검] 탭. dashboard.php 셸에서 include 된다.
|
||||||
|
// 관제(무슨 일이 나는가)와 분리된 관리 정보(시스템이 올바로 설정·동작하는가):
|
||||||
|
// 자가진단 · 수집 거부 관측성 · SMS 채널 점검 · 대응 플레이북 사본 · 임계 설정 요약.
|
||||||
|
// 사용 변수: load_tab_system() 결과(extract) — $ops_issues, $ops_ok, $reject_events,
|
||||||
|
// $sms_fail_30d, $config_rows + 전역 $ops_*, $ingest_rejections, $sms_* 등.
|
||||||
|
if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
|
||||||
|
|
||||||
|
$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 ? '확인 필요' : '정상';
|
||||||
|
|
||||||
|
// 수집 거부 사유 한국어 라벨(관측성 — api/sensor_data.php 의 거부 사유 키와 대응)
|
||||||
|
$reason_ko = [
|
||||||
|
'signature' => '서명불일치(API키/펌웨어 점검)',
|
||||||
|
'timestamp' => '시각오차(SNTP/RTC 점검)',
|
||||||
|
'key_default' => '기본 API키(미설정)',
|
||||||
|
'body_too_large' => '본문과대',
|
||||||
|
'invalid_json' => 'JSON오류',
|
||||||
|
'missing_field' => '필드누락',
|
||||||
|
'sensor_id_range' => 'sensor_id 범위',
|
||||||
|
];
|
||||||
|
?>
|
||||||
|
<div class="zone" id="tab-system">
|
||||||
|
<div class="zone-label"><b>시스템 점검</b> <span class="en">System</span></div>
|
||||||
|
|
||||||
|
<div class="system-grid">
|
||||||
|
|
||||||
|
<!-- 운영 자가진단 (전체) -->
|
||||||
|
<section class="panel" id="diagnostics-section">
|
||||||
|
<div class="diag-head">
|
||||||
|
<h2>운영 자가진단</h2>
|
||||||
|
<span class="diag-pill <?= $ops_pill ?>"><?= htmlspecialchars($ops_pill_text) ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="diag-counts">
|
||||||
|
<span class="dc ok">정상<b><?= $ops_ok_count ?></b></span>
|
||||||
|
<span class="dc warn">주의<b><?= $ops_warn_count ?></b></span>
|
||||||
|
<span class="dc crit">중요<b><?= $ops_critical_count ?></b></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($ops_issues)): ?>
|
||||||
|
<ul class="diag-issues">
|
||||||
|
<?php foreach ($ops_issues as $check):
|
||||||
|
// 색 외 채널: 심각도를 텍스트로도 구분(critical=중요, warn=주의)
|
||||||
|
$sev_text = $check['severity'] === 'critical' ? '확인 필요 · 중요' : '확인 필요 · 주의';
|
||||||
|
$issue_detail = $check['detail']
|
||||||
|
. ($check['fix'] !== '' ? ' — ' . $check['fix'] : '');
|
||||||
|
?>
|
||||||
|
<li class="diag-issue <?= htmlspecialchars($check['severity']) ?>">
|
||||||
|
<i class="diag-dot"></i>
|
||||||
|
<div>
|
||||||
|
<strong><?= htmlspecialchars($check['label']) ?> · <?= htmlspecialchars($sev_text) ?></strong>
|
||||||
|
<span><?= htmlspecialchars($issue_detail) ?></span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($ops_ok)): ?>
|
||||||
|
<details class="diag-ok" id="d-diag-ok"<?= !empty($render_all) ? ' open' : '' ?>>
|
||||||
|
<summary>정상 항목 <?= $ops_ok_count ?>건 보기</summary>
|
||||||
|
<ul class="diag-ok-grid">
|
||||||
|
<?php foreach ($ops_ok as $check): ?>
|
||||||
|
<li><i></i><span><?= htmlspecialchars($check['label']) ?></span></li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<p class="panel-note">진단 결과는 최대 5분 캐시됩니다. 보안대책서 첨부용 점검표는 <a href="security_evidence.php">보안 증적</a>에서 내려받을 수 있습니다.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 수집 거부 관측성 (왜 데이터가 안 들어오는가 / 무단 접근 출처) -->
|
||||||
|
<section class="panel" aria-label="수집 거부 관측성">
|
||||||
|
<h2>수집 거부 <span class="h2-sub">· 최근 24시간 <?= (int)$rejection_total ?>건</span></h2>
|
||||||
|
<?php if ($rejection_total > 0): ?>
|
||||||
|
<p class="diag-summary"><?php
|
||||||
|
$parts = [];
|
||||||
|
foreach ($ingest_rejections as $r => $c) {
|
||||||
|
$parts[] = htmlspecialchars($reason_ko[$r] ?? $r) . ' ' . (int)$c;
|
||||||
|
}
|
||||||
|
echo implode(' · ', $parts);
|
||||||
|
?></p>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="diag-summary">최근 24시간 수집 거부가 없습니다.</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($reject_events)): ?>
|
||||||
|
<h3 class="sub-h3">최근 거부 출처 <?= count($reject_events) ?>건 <span class="h2-sub">(우리 노드 오설정 vs 외부 스캐너 판별)</span></h3>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>시각</th><th>출처 IP</th><th>사유</th><th class="col-hide-xs">모드</th><th class="col-hide-xs">device_id</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($reject_events as $ev): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= htmlspecialchars((string)($ev['t'] ?? '')) ?></td>
|
||||||
|
<td><?= htmlspecialchars((string)($ev['ip'] ?? '?')) ?><?php if (!empty($ev['fwd'])): ?> <small>(xff <?= htmlspecialchars((string)$ev['fwd']) ?>)</small><?php endif; ?></td>
|
||||||
|
<td><?= htmlspecialchars($reason_ko[$ev['reason'] ?? ''] ?? (string)($ev['reason'] ?? '')) ?></td>
|
||||||
|
<td class="col-hide-xs"><?= htmlspecialchars((string)($ev['mode'] ?? '')) ?></td>
|
||||||
|
<td class="col-hide-xs"><?= htmlspecialchars((string)($ev['dev'] ?? '')) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SMS 채널 점검 -->
|
||||||
|
<section class="panel" aria-label="SMS 채널 점검">
|
||||||
|
<h2>SMS 채널 점검</h2>
|
||||||
|
<dl class="mini-rows">
|
||||||
|
<div><dt>잔여 건수</dt>
|
||||||
|
<dd class="<?= ($sms_balance !== null && $sms_balance <= SMS_BALANCE_WARN_THRESHOLD) ? 'err-color' : '' ?>">
|
||||||
|
<?= $sms_balance !== null ? (int)$sms_balance . '건 (경고 임계 ' . (int)SMS_BALANCE_WARN_THRESHOLD . '건)' : '확인 전' ?>
|
||||||
|
</dd></div>
|
||||||
|
<div><dt>마지막 성공 발송</dt>
|
||||||
|
<dd><?= $sms_last_success_at !== null ? htmlspecialchars($sms_last_success_at) : '기록 없음' ?></dd></div>
|
||||||
|
<div><dt>최근 1시간 경보 SMS 실패</dt>
|
||||||
|
<dd class="<?= $recent_alert_fail > 0 ? 'err-color' : 'ok-color' ?>"><?= (int)$recent_alert_fail ?>건</dd></div>
|
||||||
|
<div><dt>최근 30일 발송 실패</dt>
|
||||||
|
<dd class="<?= $sms_fail_30d > 0 ? 'warn-color' : 'ok-color' ?>"><?= (int)$sms_fail_30d ?>건</dd></div>
|
||||||
|
</dl>
|
||||||
|
<p class="panel-note">실패 지속 시 Cafe24 SMS 잔액·계정·발신번호를 확인하세요. 발송 이력은 <a href="<?= htmlspecialchars(dash_url(['tab' => 'history'], 'sms-section')) ?>">이력 조회 탭</a>에 있습니다.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 대응 플레이북·담당자 (평시 열람 사본 — 경보 시에는 상단 '대응 필요'에 자동 표시) -->
|
||||||
|
<section class="panel" aria-label="대응 플레이북">
|
||||||
|
<h2>대응 플레이북 <span class="h2-sub">· 평시 열람용</span></h2>
|
||||||
|
<?php foreach (RESPONSE_PLAYBOOKS as $pb_key => $pb): if (empty($pb['steps'])) { continue; } ?>
|
||||||
|
<details class="playbook-item" id="pb-<?= htmlspecialchars((string)$pb_key) ?>"<?= !empty($render_all) ? ' open' : '' ?>>
|
||||||
|
<summary><?= htmlspecialchars($pb['title'] ?? '대응 절차') ?></summary>
|
||||||
|
<ol class="playbook-steps">
|
||||||
|
<?php foreach ($pb['steps'] as $step): ?><li><?= htmlspecialchars($step) ?></li><?php endforeach; ?>
|
||||||
|
</ol>
|
||||||
|
</details>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (!empty(RESPONSE_CONTACTS)): ?>
|
||||||
|
<div class="response-contacts">
|
||||||
|
<span class="contacts-label">담당자</span>
|
||||||
|
<?php foreach (RESPONSE_CONTACTS as $ct):
|
||||||
|
$ph = preg_replace('/[^0-9+]/', '', (string)($ct['phone'] ?? '')); ?>
|
||||||
|
<span class="contact">
|
||||||
|
<?= htmlspecialchars(trim(($ct['role'] ?? '') . ' ' . ($ct['name'] ?? ''))) ?><?php if ($ph !== ''): ?> <a href="tel:<?= htmlspecialchars($ph) ?>"><?= htmlspecialchars((string)$ct['phone']) ?></a><?php endif; ?>
|
||||||
|
</span>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="panel-note">담당자 연락처는 config.local.php 의 RESPONSE_CONTACTS 로 등록합니다.</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 임계·설정 요약 (읽기 전용) -->
|
||||||
|
<section class="panel" aria-label="임계 및 설정 요약">
|
||||||
|
<h2>임계·설정 요약 <span class="h2-sub">· 읽기 전용</span></h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>항목</th><th>현재값</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($config_rows as [$label, $value]): ?>
|
||||||
|
<tr><td><?= htmlspecialchars($label) ?></td><td><?= htmlspecialchars((string)$value) ?></td></tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="panel-note">값 변경은 config.local.php 오버라이드로 합니다(폐쇄망에서 펌웨어 재배포 없이 조정 가능).</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 관리 화면 바로가기 -->
|
||||||
|
<section class="panel" aria-label="관리 화면 바로가기">
|
||||||
|
<h2>관리 화면 바로가기</h2>
|
||||||
|
<ul class="link-list">
|
||||||
|
<li><a href="setup_wizard.php">설치 점검</a> — DB·테이블·수신 상태 점검</li>
|
||||||
|
<li><a href="monthly_report.php">월간 보고</a> — 월별 경보·SMS 통계 보고서</li>
|
||||||
|
<li><a href="security_evidence.php">보안 증적</a> — 보안대책서 첨부용 점검표</li>
|
||||||
|
<li><a href="<?= htmlspecialchars(dash_url(['tab' => 'all'])) ?>">인쇄용 전체 보기</a> — 전 탭 한 페이지 렌더(자동 새로고침 없음)</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
344
php/tabs/trend.php
Normal file
344
php/tabs/trend.php
Normal file
|
|
@ -0,0 +1,344 @@
|
||||||
|
<?php
|
||||||
|
// tabs/trend.php — [추이·통계] 탭. dashboard.php 셸에서 include 된다.
|
||||||
|
// 사용 변수: load_tab_trend() 결과(extract) — $chart_*, $trend_*, $donut_*, $board_*,
|
||||||
|
// $spark_alerts, $ov_badge* + 전역 $uptime_pct, $online_count, $sensor_total.
|
||||||
|
if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
|
||||||
|
?>
|
||||||
|
<div class="zone" id="tab-trend">
|
||||||
|
<div class="zone-label"><b>추세 분석</b> <span class="en">Trend</span></div>
|
||||||
|
|
||||||
|
<!-- 온습도 추이 차트 (인라인 SVG, 외부 라이브러리 없음) -->
|
||||||
|
<h2 id="chart-section">온습도 추이
|
||||||
|
<?php if ($chart_sensor_id > 0): ?>
|
||||||
|
<span class="h2-sub">
|
||||||
|
· <?= htmlspecialchars($chart_sensor_name !== '' ? $chart_sensor_name : ('센서 ' . $chart_sensor_id)) ?> 최근 <?= count($chart_points) ?>개
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</h2>
|
||||||
|
<div class="chart-panel">
|
||||||
|
<div class="chart-head">
|
||||||
|
<div class="chart-legend" style="margin:0">
|
||||||
|
<span><i class="legend-swatch temp"></i> 온도(°C)</span>
|
||||||
|
<span><i class="legend-swatch hum"></i> 습도(%)</span>
|
||||||
|
</div>
|
||||||
|
<div class="range-toggle" role="group" aria-label="추이 기간 선택">
|
||||||
|
<?php foreach (['today' => '오늘', '3d' => '3일', '7d' => '7일'] as $rk => $rlabel): ?>
|
||||||
|
<a href="<?= htmlspecialchars(dash_url(['range' => $rk], 'chart-section')) ?>"<?= $chart_range === $rk ? ' aria-current="true"' : '' ?>><?= htmlspecialchars($rlabel) ?></a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php if (count($chart_points) >= 2): ?>
|
||||||
|
<div class="chart-svg-wrap">
|
||||||
|
<?php
|
||||||
|
// ── 인라인 SVG 라인 차트 (온도/습도 2계열) ──────────────
|
||||||
|
$chart_w = 920; $chart_h = 280;
|
||||||
|
$pad_l = 44; $pad_r = 44; $pad_t = 16; $pad_b = 34;
|
||||||
|
$plot_w = $chart_w - $pad_l - $pad_r;
|
||||||
|
$plot_h = $chart_h - $pad_t - $pad_b;
|
||||||
|
$n = count($chart_points);
|
||||||
|
|
||||||
|
$temps = array_values(array_filter(array_map(fn($p) => $p['temp'], $chart_points), fn($v) => $v !== null));
|
||||||
|
$hums = array_values(array_filter(array_map(fn($p) => $p['hum'], $chart_points), fn($v) => $v !== null));
|
||||||
|
|
||||||
|
// 온도 축 범위 (여유 패딩)
|
||||||
|
$t_min = !empty($temps) ? min($temps) : 0;
|
||||||
|
$t_max = !empty($temps) ? max($temps) : 1;
|
||||||
|
if ($t_min === $t_max) { $t_min -= 1; $t_max += 1; }
|
||||||
|
$t_pad = ($t_max - $t_min) * 0.1;
|
||||||
|
$t_min -= $t_pad; $t_max += $t_pad;
|
||||||
|
|
||||||
|
// 습도 축 범위
|
||||||
|
$h_min = !empty($hums) ? min($hums) : 0;
|
||||||
|
$h_max = !empty($hums) ? max($hums) : 1;
|
||||||
|
if ($h_min === $h_max) { $h_min -= 1; $h_max += 1; }
|
||||||
|
$h_pad = ($h_max - $h_min) * 0.1;
|
||||||
|
$h_min -= $h_pad; $h_max += $h_pad;
|
||||||
|
|
||||||
|
$x_at = function (int $i) use ($pad_l, $plot_w, $n): float {
|
||||||
|
if ($n <= 1) { return $pad_l; }
|
||||||
|
return $pad_l + ($plot_w * $i / ($n - 1));
|
||||||
|
};
|
||||||
|
$y_temp = function (?float $v) use ($pad_t, $plot_h, $t_min, $t_max): ?float {
|
||||||
|
if ($v === null) { return null; }
|
||||||
|
$r = ($t_max - $t_min) ?: 1;
|
||||||
|
return $pad_t + $plot_h * (1 - (($v - $t_min) / $r));
|
||||||
|
};
|
||||||
|
$y_hum = function (?float $v) use ($pad_t, $plot_h, $h_min, $h_max): ?float {
|
||||||
|
if ($v === null) { return null; }
|
||||||
|
$r = ($h_max - $h_min) ?: 1;
|
||||||
|
return $pad_t + $plot_h * (1 - (($v - $h_min) / $r));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 결측(null)은 구간을 끊어 polyline 들을 만든다
|
||||||
|
$build_segments = function (string $key, callable $y_fn) use ($chart_points, $x_at): array {
|
||||||
|
$segments = [];
|
||||||
|
$current = [];
|
||||||
|
foreach ($chart_points as $i => $p) {
|
||||||
|
$val = $p[$key];
|
||||||
|
if ($val === null) {
|
||||||
|
if (count($current) >= 2) { $segments[] = $current; }
|
||||||
|
$current = [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$current[] = sprintf('%.1f,%.1f', $x_at($i), $y_fn((float)$val));
|
||||||
|
}
|
||||||
|
if (count($current) >= 2) { $segments[] = $current; }
|
||||||
|
return $segments;
|
||||||
|
};
|
||||||
|
|
||||||
|
$temp_segments = $build_segments('temp', $y_temp);
|
||||||
|
$hum_segments = $build_segments('hum', $y_hum);
|
||||||
|
|
||||||
|
// 각 계열의 마지막 유효 지점(끝점 마커 + 최신값 라벨용)
|
||||||
|
$last_temp_i = null; $last_temp_v = null;
|
||||||
|
$last_hum_i = null; $last_hum_v = null;
|
||||||
|
foreach ($chart_points as $i => $p) {
|
||||||
|
if ($p['temp'] !== null) { $last_temp_i = $i; $last_temp_v = (float)$p['temp']; }
|
||||||
|
if ($p['hum'] !== null) { $last_hum_i = $i; $last_hum_v = (float)$p['hum']; }
|
||||||
|
}
|
||||||
|
|
||||||
|
$fmt = fn($v) => rtrim(rtrim(number_format($v, 1, '.', ''), '0'), '.');
|
||||||
|
?>
|
||||||
|
<svg viewBox="0 0 <?= $chart_w ?> <?= $chart_h ?>" width="100%" height="<?= $chart_h ?>"
|
||||||
|
preserveAspectRatio="xMidYMid meet" role="img"
|
||||||
|
aria-label="온습도 추이 라인 차트" class="chart-svg">
|
||||||
|
<!-- 플롯 배경/축 -->
|
||||||
|
<rect x="<?= $pad_l ?>" y="<?= $pad_t ?>" width="<?= $plot_w ?>" height="<?= $plot_h ?>"
|
||||||
|
class="plot-bg"></rect>
|
||||||
|
<?php
|
||||||
|
// 가로 그리드 + 좌(온도)/우(습도) 눈금 라벨 (5단계)
|
||||||
|
for ($g = 0; $g <= 4; $g++):
|
||||||
|
$gy = $pad_t + $plot_h * $g / 4;
|
||||||
|
$tv = $t_max - ($t_max - $t_min) * $g / 4;
|
||||||
|
$hv = $h_max - ($h_max - $h_min) * $g / 4;
|
||||||
|
?>
|
||||||
|
<line x1="<?= $pad_l ?>" y1="<?= sprintf('%.1f', $gy) ?>"
|
||||||
|
x2="<?= $pad_l + $plot_w ?>" y2="<?= sprintf('%.1f', $gy) ?>"
|
||||||
|
class="grid-line" stroke-dasharray="3,3"></line>
|
||||||
|
<text x="<?= $pad_l - 6 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>"
|
||||||
|
text-anchor="end" font-size="10" class="axis-temp"><?= htmlspecialchars($fmt($tv)) ?></text>
|
||||||
|
<text x="<?= $pad_l + $plot_w + 6 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>"
|
||||||
|
text-anchor="start" font-size="10" class="axis-hum"><?= htmlspecialchars($fmt($hv)) ?></text>
|
||||||
|
<?php endfor; ?>
|
||||||
|
|
||||||
|
<!-- X축 시간 라벨 (시작/중간/끝) -->
|
||||||
|
<?php
|
||||||
|
$label_idx = $n > 1 ? [0, intdiv($n - 1, 2), $n - 1] : [0];
|
||||||
|
foreach ($label_idx as $li):
|
||||||
|
$lx = $x_at($li);
|
||||||
|
$anchor = $li === 0 ? 'start' : ($li === $n - 1 ? 'end' : 'middle');
|
||||||
|
$tlabel = substr($chart_points[$li]['t'], 5, 11); // MM-DD HH:MM
|
||||||
|
?>
|
||||||
|
<text x="<?= sprintf('%.1f', $lx) ?>" y="<?= $chart_h - 12 ?>"
|
||||||
|
text-anchor="<?= $anchor ?>" font-size="10" class="axis-x"><?= htmlspecialchars($tlabel) ?></text>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<!-- 임계선 오버레이 (폴리라인과 동일한 y 스케일; -1/미정의는 생략, 스케일 밖도 생략) -->
|
||||||
|
<?php
|
||||||
|
// [임계값, y 계산 클로저, 축(temp|hum), 라벨, 단위]
|
||||||
|
$threshold_defs = [
|
||||||
|
[METRIC_TEMP_HIGH_C, $y_temp, 'temp', '고온', '℃'],
|
||||||
|
[METRIC_TEMP_LOW_C, $y_temp, 'temp', '저온', '℃'],
|
||||||
|
[METRIC_RH_HIGH, $y_hum, 'hum', '고습', '%'],
|
||||||
|
[METRIC_RH_LOW, $y_hum, 'hum', '저습', '%'],
|
||||||
|
];
|
||||||
|
foreach ($threshold_defs as [$cval, $yfn, $axis, $tlabel, $unit]):
|
||||||
|
if ($cval === null || (float)$cval === -1.0) { continue; } // 비활성
|
||||||
|
$ty = $yfn((float)$cval);
|
||||||
|
if ($ty === null || $ty < $pad_t || $ty > $pad_t + $plot_h) { continue; } // 스케일 밖
|
||||||
|
$thr_line_cls = $axis === 'temp' ? 'line-temp' : 'line-hum';
|
||||||
|
$thr_label_cls = $axis === 'temp' ? 'axis-temp' : 'axis-hum';
|
||||||
|
$thr_lx = $axis === 'temp' ? ($pad_l + 4) : ($pad_l + $plot_w - 4);
|
||||||
|
$thr_anchor = $axis === 'temp' ? 'start' : 'end';
|
||||||
|
?>
|
||||||
|
<line x1="<?= $pad_l ?>" y1="<?= sprintf('%.1f', $ty) ?>"
|
||||||
|
x2="<?= $pad_l + $plot_w ?>" y2="<?= sprintf('%.1f', $ty) ?>"
|
||||||
|
class="<?= $thr_line_cls ?>" stroke-width="1" stroke-dasharray="2,4" opacity="0.85"></line>
|
||||||
|
<text x="<?= sprintf('%.1f', $thr_lx) ?>" y="<?= sprintf('%.1f', $ty - 3) ?>"
|
||||||
|
text-anchor="<?= $thr_anchor ?>" font-size="9"
|
||||||
|
class="<?= $thr_label_cls ?>"><?= htmlspecialchars($tlabel . ' ' . $fmt((float)$cval) . $unit) ?></text>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<!-- 습도 라인 (파선 — 흑백 인쇄 시 온도선과 형태로 구분) -->
|
||||||
|
<?php foreach ($hum_segments as $seg): ?>
|
||||||
|
<polyline points="<?= htmlspecialchars(implode(' ', $seg)) ?>"
|
||||||
|
class="line-hum" fill="none" stroke-width="1.8" stroke-dasharray="6,3"
|
||||||
|
stroke-linejoin="round" stroke-linecap="round"></polyline>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<!-- 온도 라인 -->
|
||||||
|
<?php foreach ($temp_segments as $seg): ?>
|
||||||
|
<polyline points="<?= htmlspecialchars(implode(' ', $seg)) ?>"
|
||||||
|
class="line-temp" fill="none" stroke-width="1.8"
|
||||||
|
stroke-linejoin="round" stroke-linecap="round"></polyline>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<!-- 각 계열 마지막 점: 채운 원 + 최신값 라벨(폴리라인과 동일 스케일) -->
|
||||||
|
<?php if ($last_hum_i !== null):
|
||||||
|
$hx = $x_at($last_hum_i); $hy = $y_hum($last_hum_v);
|
||||||
|
?>
|
||||||
|
<circle cx="<?= sprintf('%.1f', $hx) ?>" cy="<?= sprintf('%.1f', $hy) ?>" r="3" class="axis-hum"></circle>
|
||||||
|
<text x="<?= sprintf('%.1f', $hx - 5) ?>" y="<?= sprintf('%.1f', $hy - 5) ?>"
|
||||||
|
text-anchor="end" font-size="10" class="axis-hum"><?= htmlspecialchars($fmt($last_hum_v) . '%') ?></text>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($last_temp_i !== null):
|
||||||
|
$tx = $x_at($last_temp_i); $tyv = $y_temp($last_temp_v);
|
||||||
|
?>
|
||||||
|
<circle cx="<?= sprintf('%.1f', $tx) ?>" cy="<?= sprintf('%.1f', $tyv) ?>" r="3" class="axis-temp"></circle>
|
||||||
|
<text x="<?= sprintf('%.1f', $tx - 5) ?>" y="<?= sprintf('%.1f', $tyv - 5) ?>"
|
||||||
|
text-anchor="end" font-size="10" class="axis-temp"><?= htmlspecialchars($fmt($last_temp_v) . '℃') ?></text>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- 피크(기간 내 최고 온도) 마커 + 플래그 라벨 -->
|
||||||
|
<?php if ($peak_i !== null):
|
||||||
|
$pk_x = $x_at($peak_i); $pk_y = $y_temp($peak_v);
|
||||||
|
$pk_fw = 66;
|
||||||
|
$pk_fx = min(max($pk_x - $pk_fw / 2, $pad_l), $pad_l + $plot_w - $pk_fw);
|
||||||
|
?>
|
||||||
|
<line x1="<?= sprintf('%.1f', $pk_x) ?>" y1="<?= sprintf('%.1f', $pk_y) ?>"
|
||||||
|
x2="<?= sprintf('%.1f', $pk_x) ?>" y2="<?= $pad_t ?>" class="grid-line"></line>
|
||||||
|
<rect x="<?= sprintf('%.1f', $pk_fx) ?>" y="<?= $pad_t - 15 ?>" width="<?= $pk_fw ?>" height="14" rx="4" class="peak-flag"></rect>
|
||||||
|
<text x="<?= sprintf('%.1f', $pk_fx + $pk_fw / 2) ?>" y="<?= $pad_t - 4 ?>"
|
||||||
|
text-anchor="middle" font-size="10" class="peak-label"><?= htmlspecialchars('피크 ' . $fmt($peak_v) . '℃') ?></text>
|
||||||
|
<circle cx="<?= sprintf('%.1f', $pk_x) ?>" cy="<?= sprintf('%.1f', $pk_y) ?>" r="4" class="peak-marker"></circle>
|
||||||
|
<?php endif; ?>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="chart-empty">추이를 표시할 측정 데이터가 충분하지 않습니다 (2개 이상 필요).</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 통계 위젯 (7일/30일 — 시간축 라벨을 각 타일 부제에 명기) -->
|
||||||
|
<h2>경보·가동 통계</h2>
|
||||||
|
<div class="overview trend-widgets">
|
||||||
|
|
||||||
|
<!-- 경보 발생 추이 (7일 · 온습도 경보 측정 vs 누수 감지 이벤트) -->
|
||||||
|
<div class="ov-tile ov-trend">
|
||||||
|
<h3>경보 발생 추이 <span class="sub">최근 7일 · 유형별</span></h3>
|
||||||
|
<div class="mini-legend">
|
||||||
|
<span><i style="background:var(--warn)"></i>온습도 경보</span>
|
||||||
|
<span><i style="background:var(--leak)"></i>누수 감지</span>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
$tw = 640; $th = 200; $tpl = 40; $tpr = 16; $tpt = 16; $tpb = 34;
|
||||||
|
$tplot_w = $tw - $tpl - $tpr; $tplot_h = $th - $tpt - $tpb; $tn = count($trend_days);
|
||||||
|
$t_x = function ($i) use ($tpl, $tplot_w, $tn) { return $tn > 1 ? $tpl + $tplot_w * $i / ($tn - 1) : $tpl; };
|
||||||
|
$t_y = function ($v) use ($tpt, $tplot_h, $trend_max) { return $tpt + $tplot_h * (1 - $v / $trend_max); };
|
||||||
|
$t_line = function (array $vals) use ($t_x, $t_y) {
|
||||||
|
$pts = [];
|
||||||
|
foreach ($vals as $i => $v) { $pts[] = sprintf('%.1f,%.1f', $t_x($i), $t_y($v)); }
|
||||||
|
return implode(' ', $pts);
|
||||||
|
};
|
||||||
|
$t_peak_i = 0;
|
||||||
|
foreach ($trend_metric as $i => $v) { if ($v > $trend_metric[$t_peak_i]) { $t_peak_i = $i; } }
|
||||||
|
?>
|
||||||
|
<svg class="trend-svg" viewBox="0 0 <?= $tw ?> <?= $th ?>" preserveAspectRatio="xMidYMid meet" role="img" aria-label="온습도 경보 vs 누수 감지 7일 추이">
|
||||||
|
<rect x="<?= $tpl ?>" y="<?= $tpt ?>" width="<?= $tplot_w ?>" height="<?= $tplot_h ?>" class="plot-bg"></rect>
|
||||||
|
<?php for ($g = 0; $g <= 2; $g++):
|
||||||
|
$gy = $tpt + $tplot_h * $g / 2; $gv = $trend_max - $trend_max * $g / 2; ?>
|
||||||
|
<line x1="<?= $tpl ?>" y1="<?= sprintf('%.1f', $gy) ?>" x2="<?= $tpl + $tplot_w ?>" y2="<?= sprintf('%.1f', $gy) ?>" class="grid-line" stroke-dasharray="3,3"></line>
|
||||||
|
<text x="<?= $tpl - 6 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>" text-anchor="end" font-size="10" class="ax"><?= htmlspecialchars(rtrim(rtrim(number_format($gv, 1, '.', ''), '0'), '.')) ?></text>
|
||||||
|
<?php endfor; ?>
|
||||||
|
<polyline points="<?= htmlspecialchars($t_line($trend_leak)) ?>" class="line-alert" fill="none" stroke-width="2" stroke-dasharray="6,3" stroke-linejoin="round" stroke-linecap="round"></polyline>
|
||||||
|
<polyline points="<?= htmlspecialchars($t_line($trend_metric)) ?>" class="line-metric" fill="none" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"></polyline>
|
||||||
|
<?php if ($trend_metric[$t_peak_i] > 0): ?>
|
||||||
|
<circle cx="<?= sprintf('%.1f', $t_x($t_peak_i)) ?>" cy="<?= sprintf('%.1f', $t_y($trend_metric[$t_peak_i])) ?>" r="3.2" class="dot-metric"></circle>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php
|
||||||
|
$t_lbl_idx = $tn > 1 ? [0, intdiv($tn - 1, 2), $tn - 1] : [0];
|
||||||
|
foreach ($t_lbl_idx as $li):
|
||||||
|
$anchor = $li === 0 ? 'start' : ($li === $tn - 1 ? 'end' : 'middle'); ?>
|
||||||
|
<text x="<?= sprintf('%.1f', $t_x($li)) ?>" y="<?= $th - 12 ?>" text-anchor="<?= $anchor ?>" font-size="9" class="ax"><?= htmlspecialchars(substr($trend_days[$li], 5)) ?></text>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 경보 종류 도넛 (30일) — 단위가 다른 두 카운트를 명기해 오독 방지 -->
|
||||||
|
<div class="ov-tile ov-donut">
|
||||||
|
<h3>경보 종류 <span class="sub">최근 30일</span></h3>
|
||||||
|
<?php if ($donut_total > 0): ?>
|
||||||
|
<div class="donut-body">
|
||||||
|
<div class="donut-ring">
|
||||||
|
<svg viewBox="0 0 120 120" role="img" aria-label="경보 종류 구성 총 <?= (int)$donut_total ?>건">
|
||||||
|
<circle class="d-track" cx="60" cy="60" r="47" pathLength="100"></circle>
|
||||||
|
<g transform="rotate(-90 60 60)">
|
||||||
|
<?php $d_acc = 0; foreach ($donut_segs as $seg):
|
||||||
|
if ((int)$seg['val'] <= 0) { continue; }
|
||||||
|
$d_pct = $seg['val'] / $donut_total * 100; ?>
|
||||||
|
<circle class="seg cat-<?= htmlspecialchars($seg['cat']) ?>" cx="60" cy="60" r="47" pathLength="100"
|
||||||
|
stroke-dasharray="<?= sprintf('%.2f', $d_pct) ?> 100"
|
||||||
|
stroke-dashoffset="<?= sprintf('%.2f', -$d_acc) ?>"></circle>
|
||||||
|
<?php $d_acc += $d_pct; endforeach; ?>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<div class="donut-num"><b><?= (int)$donut_total ?></b><small>건</small></div>
|
||||||
|
</div>
|
||||||
|
<div class="donut-legend">
|
||||||
|
<?php foreach ($donut_segs as $seg): if ((int)$seg['val'] <= 0) { continue; } ?>
|
||||||
|
<span class="dl"><i class="cat-<?= htmlspecialchars($seg['cat']) ?>"></i><?= htmlspecialchars($seg['label']) ?><span class="dl-c"><?= (int)$seg['val'] ?></span></span>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="caption donut-note">단위 — 온습도: 비정상 측정 건수 · 누수: 감지 이벤트 건수</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="donut-empty">최근 30일 경보 없음</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 가동률 게이지 + 7일 경보 스파크라인 -->
|
||||||
|
<div class="ov-tile ov-gauge">
|
||||||
|
<h3>가동률 <span class="sub">온라인 센서</span></h3>
|
||||||
|
<div class="gauge-body">
|
||||||
|
<div class="gauge-ring <?= htmlspecialchars($ov_badge) ?>">
|
||||||
|
<svg viewBox="0 0 120 120" role="img" aria-label="가동률 <?= (int)$uptime_pct ?>%">
|
||||||
|
<g transform="rotate(-90 60 60)">
|
||||||
|
<circle class="g-track" cx="60" cy="60" r="52" pathLength="100"></circle>
|
||||||
|
<circle class="g-val" cx="60" cy="60" r="52" pathLength="100" stroke-dasharray="<?= (int)$uptime_pct ?> 100"></circle>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<div class="gauge-num"><b><?= (int)$uptime_pct ?>%</b><small><?= (int)$online_count ?> / <?= (int)$sensor_total ?> 온라인</small></div>
|
||||||
|
</div>
|
||||||
|
<div class="gauge-foot">
|
||||||
|
<span class="lbl">7일 경보</span>
|
||||||
|
<?php
|
||||||
|
$sp_n = count($spark_alerts);
|
||||||
|
$sp_max = max(1, max($spark_alerts));
|
||||||
|
$sp_pts = [];
|
||||||
|
foreach ($spark_alerts as $spi => $spv) {
|
||||||
|
$spx = $sp_n > 1 ? $spi * (84 / ($sp_n - 1)) : 42;
|
||||||
|
$spy = 4 + (1 - $spv / $sp_max) * 22;
|
||||||
|
$sp_pts[] = sprintf('%.1f,%.1f', $spx, $spy);
|
||||||
|
}
|
||||||
|
$sp_last = explode(',', end($sp_pts));
|
||||||
|
?>
|
||||||
|
<svg class="spark" viewBox="0 0 84 30" preserveAspectRatio="none" aria-hidden="true">
|
||||||
|
<polyline points="<?= htmlspecialchars(implode(' ', $sp_pts)) ?>"></polyline>
|
||||||
|
<circle class="sp-dot" cx="<?= htmlspecialchars($sp_last[0]) ?>" cy="<?= htmlspecialchars($sp_last[1]) ?>" r="2"></circle>
|
||||||
|
</svg>
|
||||||
|
<span class="g-badge <?= htmlspecialchars($ov_badge) ?>"><?= htmlspecialchars($ov_badge_txt) ?></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($board_rows)): ?>
|
||||||
|
<!-- 경보 최다 센서 리더보드 (30일 · 온습도 센서 4개 이상 규모에서만 렌더) -->
|
||||||
|
<div class="ov-tile ov-board">
|
||||||
|
<h3>경보 최다 센서 <span class="sub">30일 · TOP <?= count($board_rows) ?></span></h3>
|
||||||
|
<div class="board">
|
||||||
|
<?php foreach ($board_rows as $b_rank => $br):
|
||||||
|
$b_w = $board_max > 0 ? (int)round((int)$br['c'] / $board_max * 100) : 0; ?>
|
||||||
|
<div class="board-row">
|
||||||
|
<span class="board-rank"><?= $b_rank + 1 ?></span>
|
||||||
|
<div class="board-main">
|
||||||
|
<div class="board-name"><?= htmlspecialchars($br['sensor_name']) ?></div>
|
||||||
|
<div class="board-bar"><span style="width:<?= $b_w ?>%"></span></div>
|
||||||
|
</div>
|
||||||
|
<span class="board-cnt"><?= (int)$br['c'] ?><small>건</small></span>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -87,7 +87,13 @@ check(str_contains($smsSend, 'function evaluate_metric_thresholds'), 'sms_send.p
|
||||||
|
|
||||||
// ── 통합 누수+카메라(1번 모듈) 기능 회귀 (소스 기반 고정) ────
|
// ── 통합 누수+카메라(1번 모듈) 기능 회귀 (소스 기반 고정) ────
|
||||||
// 누수 모듈이 SHT30 서버에 통합되었으므로, 누수/사진/사고 기능은 존재해야 한다.
|
// 누수 모듈이 SHT30 서버에 통합되었으므로, 누수/사진/사고 기능은 존재해야 한다.
|
||||||
$dashboard = file_text($root . '/php/dashboard.php');
|
// 대시보드 표면 = 셸(dashboard.php) + 데이터 계층(dashboard_data.php) + 탭 partial
|
||||||
|
// (탭 재개편으로 파일이 분리되었으므로 소스 기반 회귀 검사는 표면 전체를 본다)
|
||||||
|
$dashboard = file_text($root . '/php/dashboard.php')
|
||||||
|
. file_text($root . '/php/dashboard_data.php');
|
||||||
|
foreach (['live', 'trend', 'history', 'system'] as $dashTabFile) {
|
||||||
|
$dashboard .= file_text($root . '/php/tabs/' . $dashTabFile . '.php');
|
||||||
|
}
|
||||||
$monthly = file_text($root . '/php/monthly_report.php');
|
$monthly = file_text($root . '/php/monthly_report.php');
|
||||||
$sensorApi = file_text($root . '/php/api/sensor_data.php');
|
$sensorApi = file_text($root . '/php/api/sensor_data.php');
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue