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

308 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
// tabs/live.php — [실시간 현황] 탭. dashboard.php 셸에서 include 된다.
// 사용 변수: load_global_data() 결과 — $metric_statuses, $leak_statuses,
// $unrecovered_incidents, $forecasts, $sms_*, $today_* 등
// + load_tab_live() 결과 — $live_sparks, $live_leak_meta.
if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
// ── 센서 id 별 결합 카드 구성 ────────────────────────────────────────────────
// 1·3번(누수+온습도 결합 노드)은 한 장의 카드에 두 계통을 함께 표시한다.
// (구버전은 metric 카드가 leak 카드를 가려 누수 상태가 실시간 화면에서 빠졌음)
$sensor_cards = [];
foreach ($metric_statuses as $sid => $s) {
$sensor_cards[(int)$sid] = ['metric' => true, 'leak' => false, 's' => $s];
}
foreach ($leak_statuses as $sid => $s) {
$sid = (int)$sid;
if (isset($sensor_cards[$sid])) {
$sensor_cards[$sid]['leak'] = true;
if ($sensor_cards[$sid]['s'] === null) { $sensor_cards[$sid]['s'] = $s; }
} else {
$sensor_cards[$sid] = ['metric' => false, 'leak' => true, 's' => $s];
}
}
ksort($sensor_cards);
?>
<div class="zone" id="tab-live">
<div class="zone-label"><b>실시간 상태</b> <span class="en">Live</span></div>
<h2 id="sensors-section">센서 현황 <span class="h2-sub">· <?= count($sensor_cards) ?>개소</span></h2>
<div class="sensor-grid" id="leak-section">
<?php foreach ($sensor_cards as $sid => $card):
$is_metric = $card['metric'];
$is_leak_node = $card['leak'];
$s = $card['s'];
// 보고 주기 표기 — 누수 노드(결합)는 10분, 온습도 단독은 1시간
// (config.php heartbeat 주석 기준: raspberry_pi4 REPORT_INTERVAL=600 / sht30=3600)
$cycle_label = $is_leak_node ? '10분 주기' : '1시간 주기';
// 미세먼지 노드 여부(PM_SENSOR_IDS) — 온습도·누수와 독립적으로 카드에 PM 계통 표시.
$is_pm_node = in_array((int)$sid, array_map('intval', PM_SENSOR_IDS), true);
$type_labels = [];
if ($is_leak_node) { $type_labels[] = ['leak', '누수']; }
if ($is_pm_node) { $type_labels[] = ['pm', '미세먼지']; }
if ($is_metric) { $type_labels[] = ['metric', '온습도']; }
// ── 미수신 placeholder ──
if ($s === null): ?>
<div class="sensor-card offline">
<div class="sc-head">
<strong><?= (int)$sid ?>번 센서</strong>
<span class="sc-types">
<?php foreach ($type_labels as [$t_cls, $t_txt]): ?>
<span class="sc-type <?= $t_cls ?>"><?= $t_txt ?></span>
<?php endforeach; ?>
</span>
</div>
<div class="status-line off"><span class="sdot"></span>데이터 없음</div>
<div class="metric-time">아직 수신된 데이터가 없습니다.</div>
<div class="sc-foot"><span>수신 대기</span><span><?= $cycle_label ?></span></div>
</div>
<?php continue; endif;
// ── 상태 판정: 오프라인 > 누수 확정 > 확인중(디바운스) > 미세먼지 경보 > 온습도 경보 > 정상 ──
$metric_code = $s['metric_status'] ?? 'normal';
$pm_code = $s['pm_status'] ?? 'normal';
$has_m_alert = $is_metric && !empty($s['is_alerting']);
$has_pm_alert = $is_pm_node && !empty($s['is_pm_alerting']);
$is_leaking = $is_leak_node && (int)($s['leak_confirmed'] ?? $s['is_leak'] ?? 0) === 1;
$is_pending = $is_leak_node && !empty($s['leak_pending']);
if ($s['is_offline']) {
$card_class = 'offline'; $line_class = 'off';
$line_text = '오프라인';
if ($is_leaking) { $line_text .= ' (누수 중 끊김)'; }
elseif ($has_pm_alert) { $line_text .= ' (' . metric_status_label($pm_code) . ' 중 끊김)'; }
elseif ($has_m_alert) { $line_text .= ' (' . metric_status_label($metric_code) . ' 중 끊김)'; }
} elseif ($is_leaking) {
$card_class = 'leak'; $line_class = 'leakst';
$line_text = '누수 감지';
} elseif ($is_pending) {
$card_class = 'pending'; $line_class = 'pend';
$line_text = '확인중(디바운스)';
} elseif ($has_pm_alert) {
$card_class = 'pm'; $line_class = 'pmst';
$line_text = metric_status_label($pm_code) . ' 경보';
} elseif ($has_m_alert) {
$card_class = 'alert'; $line_class = 'err';
$line_text = metric_status_label($metric_code) . ' 경보';
} else {
$card_class = ''; $line_class = 'ok';
$line_text = $is_leak_node ? '정상 (건조)' : '정상';
}
?>
<div class="sensor-card <?= $card_class ?>">
<div class="sc-head">
<strong><?= htmlspecialchars($s['sensor_name']) ?></strong>
<span class="sc-types">
<?php foreach ($type_labels as [$t_cls, $t_txt]): ?>
<span class="sc-type <?= $t_cls ?>"><?= $t_txt ?></span>
<?php endforeach; ?>
</span>
</div>
<small><?= htmlspecialchars($s['device_location']) ?> / <?= htmlspecialchars($s['device_id']) ?></small>
<div class="status-line <?= $line_class ?>"><span class="sdot"></span><?= htmlspecialchars($line_text) ?></div>
<?php
// ── 누수 행: 확정 누수 시 경과·SMS·사진 (결합/단독 공통) ──
if ($is_leaking):
$lm = $live_leak_meta[$sid] ?? null; ?>
<div class="leak-row">
<?php if ($lm): ?>
<span class="leak-fact">경과 <b><?= (int)$lm['elapsed_min'] ?>분</b></span>
<span class="leak-fact">SMS <b><?= (int)$lm['sms_count'] ?>건</b></span>
<?php if (!empty($lm['photos'])): ?>
<div class="leak-thumbs">
<?php foreach ($lm['photos'] as $lp): ?>
<a href="photo.php?id=<?= (int)$lp['id'] ?>" target="_blank" rel="noopener">
<img src="photo.php?id=<?= (int)$lp['id'] ?>"
alt="누수 사진 <?= htmlspecialchars($lp['captured_at']) ?>" loading="lazy">
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php else: ?>
<span class="leak-fact">감지 확정 — 현장 확인 필요</span>
<?php endif; ?>
</div>
<?php endif; ?>
<?php
// ── 온습도 블록: 큰 수치 2열 + 24h 스파크라인 ──
if ($is_metric):
$temp_cls = ''; $hum_cls = '';
if ($metric_code === 'high_temp') { $temp_cls = 'hot'; }
elseif ($metric_code === 'low_temp') { $temp_cls = 'cold'; }
elseif ($metric_code === 'high_humidity') { $hum_cls = 'humid'; }
elseif ($metric_code === 'low_humidity') { $hum_cls = 'dry'; }
if (!empty($s['latest_metric'])):
$metric = $s['latest_metric']; ?>
<div class="vals">
<div class="v temp">
<div class="n <?= $temp_cls ?>"><?= htmlspecialchars(metric_display($metric['temperature_c'], '')) ?><small>°C</small></div>
<div class="l">온도</div>
</div>
<div class="v hum">
<div class="n <?= $hum_cls ?>"><?= htmlspecialchars(metric_display($metric['humidity_percent'], '')) ?><small>%</small></div>
<div class="l">습도</div>
</div>
</div>
<?php
// 24h 스파크라인 (2pt 이상일 때만 — 온도 실선/습도 파선, viewBox 300×34)
$sp = $live_sparks[$sid] ?? [];
$sp_n = count($sp);
if ($sp_n >= 2):
$sp_series = [];
foreach (['temp' => 'sp-t', 'hum' => 'sp-h'] as $sp_key => $sp_cls) {
$sp_vals = array_map(fn($p) => $p[$sp_key], $sp);
$sp_nums = array_filter($sp_vals, fn($v) => $v !== null);
if (count($sp_nums) < 2) { continue; }
$sp_min = min($sp_nums); $sp_max = max($sp_nums);
$sp_rng = ($sp_max - $sp_min) ?: 1;
$sp_pts = [];
foreach ($sp_vals as $sp_i => $sp_v) {
if ($sp_v === null) { continue; }
$sp_x = $sp_n > 1 ? $sp_i * (300 / ($sp_n - 1)) : 150;
$sp_y = 3 + (1 - ($sp_v - $sp_min) / $sp_rng) * 28;
$sp_pts[] = sprintf('%.1f,%.1f', $sp_x, $sp_y);
}
if (count($sp_pts) >= 2) { $sp_series[$sp_cls] = implode(' ', $sp_pts); }
}
if (!empty($sp_series)): ?>
<svg class="spark24" viewBox="0 0 300 34" preserveAspectRatio="none" role="img" aria-label="최근 24시간 온습도 추이">
<?php foreach ($sp_series as $sp_cls => $sp_points): ?>
<polyline class="<?= $sp_cls ?>" points="<?= htmlspecialchars($sp_points) ?>"></polyline>
<?php endforeach; ?>
</svg>
<?php endif; endif; ?>
<?php else: ?>
<div class="metric-time">측정값 없음</div>
<?php endif; ?>
<?php endif; ?>
<?php
// ── 미세먼지 블록: PM2.5/PM10 (µg/m³) — 온습도와 별도 계통, 위반 시 hot 강조 ──
if ($is_pm_node):
$pm25_cls = ($pm_code === 'high_pm25') ? 'hot' : '';
$pm10_cls = ($pm_code === 'high_pm10') ? 'hot' : '';
if (!empty($s['latest_pm'])):
$pmr = $s['latest_pm']; ?>
<div class="vals pm-vals">
<div class="v pm25">
<div class="n <?= $pm25_cls ?>"><?= $pmr['pm2_5'] !== null ? (int)$pmr['pm2_5'] : '-' ?><small>µg/m³</small></div>
<div class="l">PM2.5</div>
</div>
<div class="v pm10">
<div class="n <?= $pm10_cls ?>"><?= $pmr['pm10'] !== null ? (int)$pmr['pm10'] : '-' ?><small>µg/m³</small></div>
<div class="l">PM10</div>
</div>
</div>
<?php else: ?>
<div class="metric-time">미세먼지 측정값 없음</div>
<?php endif; endif; ?>
<?php
// ── 예보 칩: 온라인 + 예보 있으면 임계 여유 상시 표시(over>near>safe) ──
if ($is_metric):
$fc = (!$s['is_offline'] && isset($forecasts[$sid])) ? $forecasts[$sid] : 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'] . '℃)'];
}
$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; endif; ?>
<div class="sc-foot">
<span class="<?= $s['is_offline'] ? 'stale' : '' ?>">
최종확인 <?= htmlspecialchars($s['last_seen']) ?><?php if ($s['is_offline']): ?> (<?= $s['offline_elapsed_min'] ?>분 전)<?php endif; ?>
</span>
<span><?= $cycle_label ?></span>
</div>
<?php if ($is_leaking): ?>
<svg class="leak-wave" viewBox="0 0 300 42" preserveAspectRatio="none" aria-hidden="true">
<path d="M0 26 Q 25 16 50 26 T 100 26 T 150 26 T 200 26 T 250 26 T 300 26 V42 H0 Z"/>
</svg>
<?php endif; ?>
</div>
<?php endforeach; ?>
<?php if (empty($sensor_cards)): ?>
<p class="empty-note">아직 수신된 센서 데이터가 없습니다.</p>
<?php endif; ?>
</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>