POSA_LEAKSMS/php/dashboard.php
cw2007 6f8bf9413c feat(dashboard): v2 재테마 + status_summary API + 미세먼지 카드
참고 대시보드 미학(대형 숫자 KPI·플랫 카드)으로 재테마하고, 통합 센서(누수+온습도+미세먼지)를
유형 통합 그리드로 표시. PM2.5/PM10 카드·트렌드·이력을 추가.

- php/assets/dash-v2.css: 후행 캐스케이드 오버라이드(기존 파일 무수정 가역 방식)
- php/api/status_summary.php: 상태 요약 폴링 엔드포인트(try/catch·LIMIT·제네릭 에러 JSON)
- dashboard_data.php: PM 카드 데이터·결로/예보 메트릭 정합
- tabs/live·trend·history: 미세먼지 표시·추세·이력 반영
2026-07-19 10:47:27 +09:00

392 lines
19 KiB
PHP

<?php
// =============================================================================
// dashboard.php - 온습도·누수 통합 모니터링 대시보드 (탭 구조 셸)
// cafe24 웹호스팅에서 직접 브라우저로 접근하는 관리 페이지
//
// 구조 (전면 재개편 — 5인 리뷰 패널 종합 스펙 반영):
// · 상시 영역: 시스템 상태 히어로(누수·미복구 사고 포함) + 알림 스트립 +
// 대응 필요 패널 + 통합 KPI 스트립(6타일) + 탭바
// · 탭(?tab= 서버 렌더): live(실시간 현황) / trend(추이·통계) /
// history(이력 조회) / system(시스템 점검) / all(인쇄용 전체)
// · 데이터 준비는 dashboard_data.php(전역 vs 탭 전용), 마크업은 tabs/*.php
//
// 임계 판정과 SMS 발송은 서버(api/sensor_data.php + config.php METRIC_* 상수)에서
// 수행하고, 이 대시보드는 그 결과(leak_sensor_metric/status/leak_sms_log)를 표시한다.
// 폐쇄망: 외부 CDN/JS/CSS 없음(차트는 인라인 SVG 서버 렌더).
// =============================================================================
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/admin_security.php';
require_once __DIR__ . '/cron_heartbeat.php';
require_once __DIR__ . '/dashboard_data.php';
// ── 세션 인증 ─────────────────────────────────────────────
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_secure', 1);
ini_set('session.cookie_samesite', 'Strict');
ini_set('session.use_strict_mode', 1);
session_start();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// 로그아웃 처리
if (isset($_GET['logout'])) {
admin_audit_log('logout', $_SESSION['username'] ?? ADMIN_USER, true);
session_destroy();
header('Location: login.php');
exit;
}
// 세션 확인
if (empty($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
header('Location: login.php');
exit;
}
// 세션 타임아웃(유휴 + 절대 수명) 강제 — 모든 인증 페이지 공통 헬퍼(admin_security.php).
admin_enforce_session_timeout();
// ── Heartbeat: 오프라인 센서 체크 (HEARTBEAT_CHECK_INTERVAL_SEC 쓰로틀) ──────
check_offline_sensors_throttled();
$db = get_db();
// ── 탭 라우팅 ($_GET 화이트리스트 — dashboard_data.php) ─────
$current_tab = dash_current_tab(); // live|trend|history|system|all
$tabs_to_render = $current_tab === 'all' ? array_keys(DASH_TABS) : [$current_tab];
// ── 데이터 로드: 전역(모든 탭) + 활성 탭 전용 ───────────────
// 시스템 점검/전체 탭에서는 자가진단 캐시를 우회(진단이 주인공인 화면은 항상 신선)
$G = load_global_data($db, in_array($current_tab, ['system', 'all'], true));
extract($G);
// 탭 partial 렌더 — 함수 스코프로 격리해 셸 변수 오염·탭 간 변수 누출을 차단한다.
// EXTR_SKIP: 만약 로더 키가 전역 키와 겹쳐도 조용히 덮어쓰지 않는다.
function dash_render_tab(string $tab_file, array $shared_vars, array $tab_vars): void {
extract($shared_vars, EXTR_SKIP);
extract($tab_vars, EXTR_SKIP);
include $tab_file;
}
$tab_loaders = [
'live' => 'load_tab_live',
'trend' => 'load_tab_trend',
'history' => 'load_tab_history',
'system' => 'load_tab_system',
];
$tab_data = [];
foreach ($tabs_to_render as $render_tab_id) {
$tab_data[$render_tab_id] = $tab_loaders[$render_tab_id]($db, $G);
}
// KPI 스트립 타일 정의: [라벨, 값, 부가텍스트, 상태클래스, 이동 탭]
$kpi_tiles = [
['경보 센서', $alert_count . '개', null,
$alert_count > 0 ? 'err' : 'ok', 'live'],
['오프라인', $offline_count . '개', $online_count . '/' . $sensor_total . ' 온라인',
$offline_count > 0 ? 'warn' : 'ok', 'live'],
['미복구 누수', $unrecovered_count . '건', null,
$unrecovered_count > 0 ? 'err' : 'ok', 'live'],
['오늘 경보', $today_alert_count . '건', '측정·이벤트 건수',
$today_alert_count > 0 ? 'warn' : 'ok', 'trend'],
['SMS 잔여', $sms_balance !== null ? $sms_balance . '건' : '확인 전', null,
($sms_balance !== null && $sms_balance <= SMS_BALANCE_WARN_THRESHOLD) ? 'err' : 'ok', 'system'],
['중요 진단', $ops_critical_count . '건', null,
$ops_critical_count > 0 ? 'warn' : 'ok', 'system'],
];
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>온습도·누수 통합 모니터링 대시보드 <?= htmlspecialchars(APP_VERSION) ?></title>
<link rel="stylesheet" href="assets/app.css">
<link rel="stylesheet" href="assets/dash-v2.css">
<style>
/* dashboard.php 전용 잔여 스타일 — 값은 var(--*) 토큰만(app.css 규칙 재정의 아님) */
.h2-sub { font-weight: 400; color: var(--ink-2); font-size: .8125rem; }
.diag-summary { color: var(--ink-2); font-size: .8125rem; margin-bottom: var(--sp-3); }
.stale { color: var(--leak-ink); font-weight: 700; }
.empty-cell { text-align: center; color: var(--ink-3); }
/* 온습도 추이 SVG — app.css 미포함 요소 토큰화(라인=차트 토큰) */
.chart-svg { width: 100%; height: auto; display: block; }
@media (max-width: 560px) { .chart-svg { min-width: 480px; } }
.chart-svg-wrap .plot-bg { fill: var(--surface-2); stroke: var(--line); }
.chart-svg-wrap .grid-line { stroke: var(--chart-grid); }
.chart-svg-wrap .axis-temp { fill: var(--chart-temp); }
.chart-svg-wrap .axis-hum { fill: var(--chart-hum); }
.chart-svg-wrap .axis-x { fill: var(--ink-3); }
.chart-svg-wrap .line-temp { stroke: var(--chart-temp); }
.chart-svg-wrap .line-hum { stroke: var(--chart-hum); }
</style>
<!-- 자동 새로고침(토글·스크롤/펼침 복원)은 body 하단 스크립트에서 처리(WCAG 2.2.1) -->
</head>
<body>
<header>
<h1>온습도·누수 통합 모니터링 대시보드 <?= htmlspecialchars(APP_VERSION) ?></h1>
<div class="top-actions">
<a href="setup_wizard.php">설치 점검</a>
<a href="monthly_report.php">월간 보고</a>
<a href="security_evidence.php">보안 증적</a>
<a href="?logout">로그아웃</a>
</div>
</header>
<main class="container">
<!-- 시스템 상태 히어로: 어떤 탭에서도 최상단 고정. 경보 시 센서·값·시작·SMS 결과 명시 -->
<section class="status-hero <?= htmlspecialchars($system_state) ?>">
<div>
<h2><?= htmlspecialchars($system_title) ?></h2>
<p><?= htmlspecialchars($system_detail) ?></p>
<?php if (!empty($hero_alert_lines)): ?>
<ul class="hero-lines">
<?php foreach ($hero_alert_lines as $hl): ?>
<li><?= htmlspecialchars($hl['text']) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
<div class="status-clock">
<?= date('Y-m-d H:i:s') ?><br>
<?php if ($current_tab === 'all'): ?>
인쇄용 전체 보기 · 자동 새로고침 없음
<?php else: ?>
<span class="refresh" id="refresh-wrap"><svg class="refresh-ring" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="6"/></svg>30초 자동 새로고침<button type="button" id="refresh-toggle" class="refresh-toggle" aria-pressed="true" aria-label="자동 새로고침 일시정지">⏸ 일시정지</button></span>
<?php endif; ?>
</div>
</section>
<!-- 알림 스트립: 즉시 조치가 필요한 것만 1줄씩 (상세는 해당 탭 딥링크) -->
<?php if ($recent_alert_fail > 0): ?>
<a class="alert-strip danger" href="<?= htmlspecialchars(dash_url(['tab' => 'system'])) ?>">
<strong>경보 SMS 발송 실패</strong>
<span>최근 1시간 <?= (int)$recent_alert_fail ?>건 — 미수신 가능. Cafe24 SMS 잔액·계정·발신번호 확인</span>
<em>시스템 점검 →</em>
</a>
<?php endif; ?>
<?php if ($unrecovered_count > 0): ?>
<a class="alert-strip danger" href="<?= htmlspecialchars(dash_url(['tab' => 'live'])) ?>">
<strong>미복구 누수 사고 <?= (int)$unrecovered_count ?>건</strong>
<span>현장 조치·복구 확인이 필요합니다</span>
<em>실시간 현황 →</em>
</a>
<?php endif; ?>
<?php if ($rejection_total > 0): ?>
<a class="alert-strip warn" href="<?= htmlspecialchars(dash_url(['tab' => 'system'])) ?>">
<strong>수집 거부 <?= (int)$rejection_total ?>건</strong>
<span>최근 24시간 — 사유·출처 IP 상세 확인</span>
<em>시스템 점검 →</em>
</a>
<?php endif; ?>
<!-- 대응 필요 패널: 안전 알림 표면은 탭과 무관하게 상시 노출(조건부 렌더) -->
<?php if (FORECAST_ENABLED && !empty($risk_items_all)): ?>
<section class="response-panel" aria-label="대응 필요 항목">
<h2>대응 필요 <span class="response-count"><?= count($risk_items_all) ?>건</span></h2>
<?php foreach ($risk_items_all as $risk):
$pb = RESPONSE_PLAYBOOKS[$risk['condition']] ?? null;
$sev_pill = $risk['severity'] === 'critical' ? 'alert' : 'offline';
$sev_text = $risk['severity'] === 'critical' ? '경보' : '주의';
?>
<div class="response-item <?= htmlspecialchars($risk['severity']) ?>">
<div class="response-head">
<span class="pill <?= $sev_pill ?>"><?= $sev_text ?></span>
<strong><?= htmlspecialchars($risk['name']) ?><?= $risk['location'] ? ' · ' . htmlspecialchars($risk['location']) : '' ?></strong>
<span class="response-detail"><?= htmlspecialchars($risk['detail']) ?></span>
</div>
<?php if ($pb && !empty($pb['steps'])): ?>
<div class="playbook">
<div class="playbook-title"><?= htmlspecialchars($pb['title'] ?? '권장 조치') ?> — 권장 조치</div>
<ol class="playbook-steps">
<?php foreach ($pb['steps'] as $step): ?><li><?= htmlspecialchars($step) ?></li><?php endforeach; ?>
</ol>
</div>
<?php endif; ?>
</div>
<?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 endif; ?>
</section>
<?php endif; ?>
<!-- 통합 KPI 스트립: 데스크톱·모바일 공용 6타일, 각 타일은 해당 탭 딥링크 -->
<nav class="kpi-strip" aria-label="핵심 현황">
<?php foreach ($kpi_tiles as [$k_label, $k_val, $k_sub, $k_cls, $k_tab]): ?>
<a class="kpi <?= htmlspecialchars($k_cls) ?>" href="<?= htmlspecialchars(dash_url(['tab' => $k_tab])) ?>">
<span class="kpi-lbl"><?= htmlspecialchars($k_label) ?></span>
<span class="kpi-val"><?= htmlspecialchars($k_val) ?></span>
<?php if ($k_sub !== null): ?><span class="kpi-sub"><?= htmlspecialchars($k_sub) ?></span><?php endif; ?>
</a>
<?php endforeach; ?>
</nav>
<!-- 탭바: ?tab= 서버 렌더 링크 (30초 리로드가 URL 을 보존하므로 탭 상태 자동 유지) -->
<nav class="tabbar" aria-label="대시보드 섹션">
<?php foreach (DASH_TABS as $tab_id => $tab_label):
$is_active = ($current_tab === $tab_id);
$badge = 0; $badge_cls = 'warn';
if ($tab_id === 'live') { $badge = (int)$badge_live; $badge_cls = ($alert_count + $leak_active_count) > 0 ? 'crit' : 'warn'; }
if ($tab_id === 'system') { $badge = (int)$badge_system; $badge_cls = 'crit'; }
?>
<a class="tab-link<?= $is_active ? ' active' : '' ?>"
href="<?= htmlspecialchars(dash_url(['tab' => $tab_id])) ?>"<?= $is_active ? ' aria-current="page"' : '' ?>>
<?= htmlspecialchars($tab_label) ?><?php if ($badge > 0): ?><span class="tab-badge <?= $badge_cls ?>" aria-label="확인 필요 <?= $badge ?>건"><?= $badge ?></span><?php endif; ?>
</a>
<?php endforeach; ?>
<a class="tab-print<?= $current_tab === 'all' ? ' active' : '' ?>" href="<?= htmlspecialchars(dash_url(['tab' => 'all'])) ?>"<?= $current_tab === 'all' ? ' aria-current="page"' : '' ?>>🖨 인쇄용 전체</a>
</nav>
<?php if ($current_tab === 'all'): ?>
<p class="print-note">인쇄용 전체 보기입니다(전 탭 순차 렌더 · 자동 새로고침 없음). 평시 관제는 <a href="<?= htmlspecialchars(dash_url(['tab' => 'live'])) ?>">실시간 현황</a>을 사용하세요.</p>
<?php endif; ?>
<!-- 탭 콘텐츠 (tab=all 이면 4개 순차 렌더 — 인쇄/보안 증적 전체 출력) -->
<?php
$shared_view_vars = ['render_all' => ($current_tab === 'all')] + $G;
foreach ($tabs_to_render as $render_tab_id) {
dash_render_tab(__DIR__ . '/tabs/' . $render_tab_id . '.php', $shared_view_vars, $tab_data[$render_tab_id]);
}
?>
</main>
<script>
(function () {
'use strict';
var TAB = <?= json_encode($current_tab) ?>;
var REFRESH_MS = 30000;
var LS_REFRESH = 'leakDash.autoRefresh'; // 'on' | 'off' (기본 on)
var SS_SCROLL = 'leakDash.scrollY.' + TAB; // 탭별 분리(탭 간 오복원 방지)
var SS_DETAILS = 'leakDash.detailsOpen.' + TAB; // 열린 details id 목록 JSON
// ── 구 앵커(#sensors-section 등) 하위호환: 대상이 현재 탭에 없으면 해당 탭으로 ──
var HASH_TAB = {
'sensors-section': 'live', 'leak-section': 'live',
'chart-section': 'trend',
'summary-section': 'history', 'metrics-section': 'history',
'logs-section': 'history', 'sms-section': 'history',
'diagnostics-section': 'system'
};
if (TAB !== 'all' && location.hash) {
var hid = location.hash.slice(1);
if (!document.getElementById(hid) && HASH_TAB[hid]) {
var q = 'tab=' + HASH_TAB[hid];
var search = location.search.replace(/([?&])tab=[^&]*/, '$1' + q);
if (search.indexOf('tab=') === -1) {
search = search === '' ? '?' + q : search + '&' + q;
}
location.replace('dashboard.php' + search + location.hash);
return;
}
}
// details 는 고유 id 기반으로 저장/복원(탭마다 개수·순서가 달라 인덱스 방식 부적합)
var details = Array.prototype.slice.call(document.querySelectorAll('details[id]'));
// ── details 펼침 상태 저장/복원 ────────────────────────────
function saveDetailsState() {
try {
var openIds = [];
details.forEach(function (d) { if (d.open) { openIds.push(d.id); } });
sessionStorage.setItem(SS_DETAILS, JSON.stringify(openIds));
} catch (e) { /* storage 불가 시 무시 */ }
}
function restoreDetailsState() {
try {
var raw = sessionStorage.getItem(SS_DETAILS);
if (raw === null) { return; }
var openIds = JSON.parse(raw);
if (!Array.isArray(openIds)) { return; }
details.forEach(function (d) {
if (openIds.indexOf(d.id) !== -1) { d.setAttribute('open', ''); }
else { d.removeAttribute('open'); }
});
} catch (e) { /* 무시 */ }
}
details.forEach(function (d) { d.addEventListener('toggle', saveDetailsState); });
// ── 모바일 기본 접힘(최초 방문만) ──────────────────────────
function applyMobileDisclosureState() {
var shouldCollapse = window.matchMedia('(max-width: 768px)').matches;
document.querySelectorAll('details[data-mobile-collapsed]').forEach(function (detail) {
if (shouldCollapse && !detail.dataset.mobileInitialized) {
detail.removeAttribute('open');
detail.dataset.mobileInitialized = '1';
}
if (!shouldCollapse) {
detail.setAttribute('open', '');
delete detail.dataset.mobileInitialized;
}
});
}
// 인쇄용 전체 보기(tab=all)는 접힘/복원을 하지 않는다 — 서버가 강제 open 으로
// 렌더한 상태 그대로 인쇄되어야 함(접힌 details 는 인쇄물에서 통째로 빠짐).
if (TAB !== 'all') {
applyMobileDisclosureState();
window.addEventListener('resize', applyMobileDisclosureState);
// 사용자가 이전에 고른 펼침 상태가 기본값보다 우선
restoreDetailsState();
}
// ── 스크롤 위치 복원(펼침 반영 후 레이아웃 안정 시점) ──────
try {
var savedY = sessionStorage.getItem(SS_SCROLL);
if (savedY !== null) {
window.scrollTo(0, parseInt(savedY, 10) || 0);
sessionStorage.removeItem(SS_SCROLL);
}
} catch (e) { /* 무시 */ }
// ── 자동 새로고침 토글(WCAG 2.2.1: 끌 수 있어야 함) ────────
// location.reload() 는 URL(?tab=,?range=)을 보존하므로 탭 상태가 자동 유지된다.
// 인쇄용 전체 보기(tab=all)에서는 전체 렌더 부하 반복을 막기 위해 예약하지 않는다.
var timer = null;
var btn = document.getElementById('refresh-toggle');
function isOn() {
try { return localStorage.getItem(LS_REFRESH) !== 'off'; } // 기본 on
catch (e) { return true; }
}
function scheduleReload() {
if (timer !== null) { window.clearTimeout(timer); timer = null; }
if (TAB === 'all' || !isOn()) { return; }
timer = window.setTimeout(function () {
try { sessionStorage.setItem(SS_SCROLL, String(window.scrollY)); } catch (e) {}
saveDetailsState();
location.reload();
}, REFRESH_MS);
}
function renderBtn() {
if (!btn) { return; }
var on = isOn();
btn.textContent = on ? '⏸ 일시정지' : '▶ 재개';
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
btn.setAttribute('aria-label', on ? '자동 새로고침 일시정지' : '자동 새로고침 재개');
var wrap = document.getElementById('refresh-wrap');
if (wrap) { wrap.classList.toggle('paused', !on); }
}
if (btn) {
btn.addEventListener('click', function () {
try { localStorage.setItem(LS_REFRESH, isOn() ? 'off' : 'on'); } catch (e) {}
renderBtn();
scheduleReload();
});
}
renderBtn();
scheduleReload();
})();
</script>
</body>
</html>