참고 대시보드 미학(대형 숫자 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: 미세먼지 표시·추세·이력 반영
308 lines
17 KiB
PHP
308 lines
17 KiB
PHP
<?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>
|