POSA_LEAKSMS/php/tabs/live.php
cw2007 f063060e2d 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: 대시보드 표면을 분리 구조(셸+데이터+탭)에 맞춰 검사하도록 보정.
2026-07-11 11:23:14 +09:00

214 lines
12 KiB
PHP

<?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>