POSA_LEAKSMS/php/tabs/trend.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

344 lines
21 KiB
PHP

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