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: 미세먼지 표시·추세·이력 반영
This commit is contained in:
cw2007 2026-07-19 10:47:27 +09:00
parent 42a6ee4bb0
commit 6f8bf9413c
7 changed files with 1119 additions and 106 deletions

261
php/api/status_summary.php Normal file
View file

@ -0,0 +1,261 @@
<?php
// =============================================================================
// api/status_summary.php — 미니 상태 표시기(Pi Zero 2 W + ST7789V)용 읽기 전용 요약 JSON
//
// 인증: X-Status-Key 헤더 = hash_hmac('sha256', 'status', API_KEY)
// · cron_heartbeat.php 의 CRON_SECRET 과 동일한 파생 방식(별도 시크릿 배포 불필요,
// 표시기는 API_KEY 로 로컬 계산). 헤더 방식이라 access log 에 키가 남지 않는다.
// · 기본 API_KEY 미교체 상태면 파생키도 예측 가능 → 접근 자체를 차단(fail closed).
// 쓰기 없음(SELECT 전용) · 세션/쿠키 없음 · GET 전용.
//
// 응답(요약): { ts, system:{state,online,total,unrecovered,sms_balance,today_alerts},
// sensors:[{id,name,location,leak_node,metric_node,pm_node,offline,leak,pending,
// th_status,metric_status(=th_status 하위호환 별칭),pm_status,
// temp,hum,pm25,pm10,pm1_0,last_seen,history,pm_history,leak_events}] }
// · state 어휘(우선순위): leak > pm(미세먼지) > alert(온습도) > offline > ok
// · 온습도/미세먼지는 같은 sensor_id 아래 metric_type('sht30' vs 'pms7003')로 분리 —
// temp/hum/th_status 는 sht30 행에서만, pm25/pm10/pm1_0/pm_status 는 pms7003 행에서만.
// =============================================================================
require_once __DIR__ . '/../config.php';
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'GET') {
http_response_code(405);
echo json_encode(['error' => 'method_not_allowed']);
exit;
}
// ── 레이트 리밋 (IP별 고정창 60초/30회 — 표시기 정상 폴링은 분당 2회) ─────────
// login.php 와 동일하게 php/var JSON + LOCK_EX. 파일 오류 시엔 차단하지 않는다
// (모니터링 표시기가 스로틀 저장소 문제로 죽는 것보다 서비스 지속이 우선).
$rl_file = __DIR__ . '/../var/status_rate.json';
$rl_ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$rl_now = time();
$rl_win = $rl_now - ($rl_now % 60);
$rl = @json_decode((string)@file_get_contents($rl_file), true);
$rl = is_array($rl) ? $rl : [];
$rec = $rl[$rl_ip] ?? ['n' => 0, 'win' => $rl_win];
if (($rec['win'] ?? 0) !== $rl_win) { $rec = ['n' => 0, 'win' => $rl_win]; }
$rec['n']++;
// 창이 바뀐 다른 IP 기록은 청소(파일 무한 성장 방지)
$rl = array_filter($rl, fn($r) => ($r['win'] ?? 0) === $rl_win);
$rl[$rl_ip] = $rec;
@file_put_contents($rl_file, json_encode($rl), LOCK_EX);
if ($rec['n'] > 30) {
http_response_code(429);
header('Retry-After: ' . (60 - ($rl_now % 60)));
echo json_encode(['error' => 'rate_limited']);
exit;
}
// ── 인증 (fail closed) ────────────────────────────────────────────────────────
if (!defined('API_KEY') || API_KEY === '' || API_KEY === 'change-this-api-key') {
http_response_code(503);
echo json_encode(['error' => 'server_not_configured']);
exit;
}
$status_secret = hash_hmac('sha256', 'status', API_KEY);
$given = (string)($_SERVER['HTTP_X_STATUS_KEY'] ?? '');
if ($given === '' || !hash_equals($status_secret, $given)) {
http_response_code(403);
echo json_encode(['error' => 'forbidden']);
exit;
}
// ── 데이터 수집 (대시보드 판정 규칙과 동일 기준) ─────────────────────────────
// DB 예외는 전부 잡아 generic 500 으로 — display_errors=On 호스팅에서도 내부정보 비노출.
try {
$db = get_db();
$status_rows = $db->query("SELECT * FROM leak_sensor_status ORDER BY sensor_id")->fetchAll();
$sensor_ids = array_map(fn($r) => (int)$r['sensor_id'], $status_rows);
// 최신 측정을 (sensor_id, metric_type) 단위로 가져온다 — 누수 노드(1·3)는 온습도(sht30)와
// 미세먼지(pms7003)를 같은 sensor_id 로 보고하므로, metric_type 무시하고 MAX(id) 로 뽑으면
// 두 행이 서로를 덮어써(sht30 행은 pm NULL, pms 행은 temp/hum NULL) 한쪽이 사라진다.
// → metric_type 별 MAX(id) 로 뽑아 sht30/pms 버킷에 각각 담는다. 레거시 metric_type='' 은 sht30.
$latest_sht30 = []; // sensor_id => 최신 sht30(온습도) 행
$latest_pms = []; // sensor_id => 최신 pms7003(미세먼지) 행
foreach ($db->query("
SELECT sm.*
FROM leak_sensor_metric sm
INNER JOIN (
SELECT sensor_id, metric_type, MAX(id) AS latest_id
FROM leak_sensor_metric GROUP BY sensor_id, metric_type
) latest ON latest.latest_id = sm.id
")->fetchAll() as $mr) {
$sid = (int)$mr['sensor_id'];
$mt = (string)($mr['metric_type'] ?? '');
if ($mt === 'pms7003') {
$latest_pms[$sid] = $mr;
} else {
// 'sht30' 또는 레거시 '' — 온습도로 취급. sht30·'' 가 둘 다 있으면 최신(id 큰) 것 유지.
if (!isset($latest_sht30[$sid]) || (int)$mr['id'] > (int)$latest_sht30[$sid]['id']) {
$latest_sht30[$sid] = $mr;
}
}
}
// ── 24시간 추세(시간당 평균, 최대 24포인트) — 센서별 쿼리로
// (sensor_id, measured_at) 복합 인덱스를 태워 풀스캔 방지(30초 폴링 엔드포인트) ──
$history = []; // sensor_id => [ ['t'=>'HH:MM','temp'=>float|null,'hum'=>float|null], ... ]
$pm_history = []; // sensor_id => [ ['t'=>'HH:MM','pm25'=>int], ... ] (pms7003 시간당 평균)
// AVG 는 컬럼별로 NULL 을 무시하므로, 같은 시간버킷에 온습도(sht30 행)·미세먼지(pms 행)가
// 섞여 있어도 서로를 오염시키지 않는다(sht30 행은 pm2_5=NULL, pms 행은 temp/hum=NULL).
// pm2_5 를 같은 쿼리에 얹어 추가 쿼리 없이 (sensor_id, measured_at) 인덱스를 그대로 태운다.
$hist_stmt = $db->prepare("
SELECT DATE_FORMAT(measured_at, '%Y-%m-%d %H:00:00') AS bucket,
AVG(temperature_c) AS temp,
AVG(humidity_percent) AS hum,
AVG(pm2_5) AS pm25
FROM leak_sensor_metric
WHERE sensor_id = :sid AND measured_at >= NOW() - INTERVAL 24 HOUR
GROUP BY bucket
ORDER BY bucket
LIMIT 25
");
foreach ($sensor_ids as $sid) {
$hist_stmt->execute([':sid' => $sid]);
foreach ($hist_stmt->fetchAll() as $h) {
$t = substr($h['bucket'], 11, 5); // HH:MM
$history[$sid][] = [
't' => $t,
'temp' => $h['temp'] !== null ? round((float)$h['temp'], 1) : null,
'hum' => $h['hum'] !== null ? round((float)$h['hum'], 0) : null,
];
if ($h['pm25'] !== null) { // 미세먼지 값이 있는 버킷만
$pm_history[$sid][] = ['t' => $t, 'pm25' => (int)round((float)$h['pm25'])];
}
}
}
// 누수 이벤트 타임라인 — 플래핑 센서가 응답을 무한정 키우지 않도록 상한(최신 우선)
$leak_events = []; // sensor_id => ['HH:MM', ...] — 최근 24h leak_alert 발생 시각
foreach ($db->query("
SELECT sensor_id, sensor_time
FROM leak_sensor_log
WHERE event_type = 'leak_alert' AND sensor_time >= NOW() - INTERVAL 24 HOUR
ORDER BY sensor_time DESC
LIMIT 200
")->fetchAll() as $e) {
$leak_events[(int)$e['sensor_id']][] = substr((string)$e['sensor_time'], 11, 5);
}
foreach ($leak_events as &$ev_list) { $ev_list = array_reverse($ev_list); } // 시간순 복원
unset($ev_list);
$now = time();
$sensors = [];
$offline_count = 0;
foreach ($status_rows as $s) {
$sid = (int)$s['sensor_id'];
$elapsed = $now - strtotime($s['last_seen'] ?? '2000-01-01');
$offline = ((int)($s['offline_alerted'] ?? 0) === 1) || ($elapsed >= heartbeat_timeout_for($sid));
if ($offline) { $offline_count++; }
// 누수 확정(디바운스) 기준 — 대시보드/SMS 와 동일. 컬럼 부재 구배포는 raw 폴백.
$raw_leak = (int)($s['is_leak'] ?? 0);
$leak = array_key_exists('leak_confirmed', $s) ? (int)$s['leak_confirmed'] : $raw_leak;
$pending = array_key_exists('leak_confirmed', $s)
&& (($s['leak_pending_since'] ?? null) !== null) && ($raw_leak !== $leak);
[$location, $name] = sensor_label($sid, $s['device_location'] ?? null, $s['sensor_name'] ?? null);
// 최신 측정을 metric_type 별로 분리 — 온습도(sht30 행)와 미세먼지(pms 행)가 같은
// sensor_id 아래 서로를 덮어쓰지 않게 한다. temp/hum/th_status 는 sht30 행에서만,
// pm25/pm10/pm1_0/pm_status 는 pms 행에서만 취한다.
$sht = $latest_sht30[$sid] ?? null;
$pms = $latest_pms[$sid] ?? null;
// 온습도 대표 상태(결로 포함) — sht30 행의 저장 metric_status.
$th_status = $sht['metric_status'] ?? 'normal';
// 미세먼지 상태 — pms 행의 저장 metric_status(정의상 pms 행은 temp/hum NULL 이라
// high_pm25/high_pm10/normal 만 나온다). 계약 보장을 위해 PM 값만 허용, 그 외는 normal.
$pm_status_raw = $pms['metric_status'] ?? 'normal';
$pm_status = in_array($pm_status_raw, ['high_pm25', 'high_pm10'], true) ? $pm_status_raw : 'normal';
$sensors[] = [
'id' => $sid,
'name' => $name,
'location' => $location,
'leak_node' => in_array($sid, LEAK_SENSOR_IDS, true),
'metric_node' => in_array($sid, METRIC_SENSOR_IDS, true),
'pm_node' => in_array($sid, PM_SENSOR_IDS, true),
'offline' => $offline,
'leak' => $leak,
'pending' => $pending,
'th_status' => $th_status, // 온습도(+결로) 상태(sht30 행)
'metric_status' => $th_status, // 하위호환 별칭(= th_status)
'pm_status' => $pm_status, // 미세먼지 상태(normal|high_pm25|high_pm10)
'temp' => isset($sht['temperature_c']) && $sht['temperature_c'] !== null ? (float)$sht['temperature_c'] : null,
'hum' => isset($sht['humidity_percent']) && $sht['humidity_percent'] !== null ? (float)$sht['humidity_percent'] : null,
'pm25' => isset($pms['pm2_5']) && $pms['pm2_5'] !== null ? (int)$pms['pm2_5'] : null,
'pm10' => isset($pms['pm10']) && $pms['pm10'] !== null ? (int)$pms['pm10'] : null,
'pm1_0' => isset($pms['pm1_0']) && $pms['pm1_0'] !== null ? (int)$pms['pm1_0'] : null,
'last_seen' => $s['last_seen'] ?? null,
'history' => $history[$sid] ?? [], // 24h 시간당 추세(온/습도)
'pm_history' => $pm_history[$sid] ?? [], // 24h 시간당 추세(PM2.5)
'leak_events' => $leak_events[$sid] ?? [], // 24h 누수 이벤트 시각
];
}
$unrecovered = 0;
if (table_exists_api($db, 'leak_incident')) {
$unrecovered = (int)$db->query("SELECT COUNT(*) FROM leak_incident WHERE recovered_at IS NULL")->fetchColumn();
}
$today_alerts = (int)$db->query("
SELECT COUNT(*) FROM leak_sensor_metric
WHERE metric_status <> 'normal' AND measured_at >= CURDATE()
")->fetchColumn()
+ (int)$db->query("
SELECT COUNT(*) FROM leak_sensor_log
WHERE event_type = 'leak_alert' AND sensor_time >= CURDATE()
")->fetchColumn();
// 시스템 종합 상태 — 표시기 배경색 판정용. 5단 우선순위: 누수 > 미세먼지 > 온습도 > 오프라인 > 정상
// (leak > pm > alert(온습도 th) > offline > ok). 오프라인 센서의 경보는 신선하지 않으므로 제외.
$leak_active = count(array_filter($sensors, fn($x) => $x['leak'] === 1 && !$x['offline']));
$pm_alert = count(array_filter($sensors, fn($x) => $x['pm_status'] !== 'normal' && !$x['offline']));
$th_alert = count(array_filter($sensors, fn($x) => $x['th_status'] !== 'normal' && !$x['offline']));
if ($leak_active > 0 || $unrecovered > 0) { $state = 'leak'; }
elseif ($pm_alert > 0) { $state = 'pm'; }
elseif ($th_alert > 0) { $state = 'alert'; }
elseif ($offline_count > 0) { $state = 'offline'; }
else { $state = 'ok'; }
echo json_encode([
'ts' => date('Y-m-d H:i:s'),
'system' => [
'state' => $state,
'online' => count($sensors) - $offline_count,
'total' => count($sensors),
'unrecovered' => $unrecovered,
'sms_balance' => latest_sms_balance($db),
'today_alerts' => $today_alerts,
],
'sensors' => $sensors,
], JSON_UNESCAPED_UNICODE);
} catch (Throwable $e) {
// 내부 오류 상세(쿼리/테이블명 등)는 응답에 싣지 않는다 — 로그로만.
error_log('[status_summary] ' . $e->getMessage());
http_response_code(500);
echo json_encode(['error' => 'internal']);
exit;
}
// 로컬 헬퍼 — dashboard_data.php(관리 화면 전용)를 끌어오지 않기 위한 최소 복제.
function table_exists_api(PDO $db, string $table): bool {
try {
$st = $db->prepare("
SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = :t
");
$st->execute([':t' => $table]);
return (int)$st->fetchColumn() > 0;
} catch (Throwable $e) {
return false;
}
}

237
php/assets/dash-v2.css Normal file
View file

@ -0,0 +1,237 @@
/* =============================================================================
* dash-v2.css — dashboard.php 전용 오버레이 (app.css 뒤에 로드)
* 목업 review/ui_mockup_v2.html 반영: 글래스 툴바 · 스프링 모션 · 세그먼트 탭 ·
* 결합 센서 카드(누수+온습도) · 카운트다운 링 · 카드형 테이블 헤더.
* app.css 는 7페이지 공용이므로 건드리지 않는다. 인쇄 규칙은 app.css 것을 계승.
* 폐쇄망: 외부 리소스 0.
* ============================================================================= */
:root {
--spring: cubic-bezier(.32,.72,.28,1); /* 감쇠 스프링 근사 */
}
/* ── 글래스 툴바: 반투명 재질 + sticky, 콘텐츠가 아래로 스크롤 ─────────────── */
header {
background: rgba(20,26,61,.78);
backdrop-filter: blur(18px) saturate(160%);
-webkit-backdrop-filter: blur(18px) saturate(160%);
}
.top-actions a {
transition: background .18s var(--spring), color .15s, border-color .15s, transform .15s var(--spring);
}
.top-actions a:active { transform: scale(.96); }
/* ── 새로고침 카운트다운 링 ─────────────────────────────────────────────────── */
.refresh { display: inline-flex; align-items: center; gap: 8px; }
.refresh-ring { width: 15px; height: 15px; flex: none; }
.refresh-ring circle {
fill: none; stroke: var(--primary-600); stroke-width: 2.5;
stroke-dasharray: 38; stroke-linecap: round;
transform: rotate(-90deg); transform-origin: center;
animation: dash-countdown 30s linear infinite;
}
@keyframes dash-countdown { from { stroke-dashoffset: 0; } to { stroke-dashoffset: 38; } }
.refresh.paused .refresh-ring circle { animation-play-state: paused; opacity: .35; }
.refresh-toggle { transition: transform .15s var(--spring), background .15s; }
.refresh-toggle:active { transform: scale(.93); }
/* ── 세그먼트 탭바(iOS segmented control) ───────────────────────────────────── */
.tabbar { border-radius: var(--r-pill); padding: 4px; gap: 3px; }
.tabbar .tab-link {
border-radius: var(--r-pill); padding: 8px 18px;
transition: background .22s var(--spring), color .15s, transform .15s var(--spring);
}
.tabbar .tab-link:active { transform: scale(.96); }
.tabbar .tab-link.active {
color: #fff;
background: linear-gradient(135deg, var(--primary), #8f6bff);
box-shadow: 0 4px 14px rgba(108,123,255,.35);
}
.tabbar .tab-link.active::after { content: none; }
.tabbar .tab-link.active .tab-badge.crit { background: rgba(255,255,255,.22); color: #fff; }
.tabbar .tab-print { border-radius: var(--r-pill); }
/* ── 인터랙션 공통: 즉각 반응(:active) + 부상(hover) 스프링 ─────────────────── */
.kpi, .sensor-card, .leak-status-card, .alert-strip, .quick-tile {
transition: transform .2s var(--spring), border-color .2s, box-shadow .2s, background .2s;
}
.kpi:hover, .sensor-card:hover, .leak-status-card:hover {
transform: translateY(-3px); box-shadow: var(--shadow-lg);
}
.kpi:active, .sensor-card:active, .alert-strip:active { transform: translateY(-1px) scale(.985); }
.alert-strip:hover { transform: translateX(3px); }
/* ── 결합 센서 카드 (누수+온습도) ────────────────────────────────────────────
상태 우선순위: 오프라인 > 누수 확정 > 확인중 > 온습도 경보 > 정상 */
.sensor-card { position: relative; overflow: hidden; }
.sensor-card::before {
content: ""; position: absolute; inset: 0 0 auto 0; height: 3px;
background: var(--normal); opacity: .85;
}
.sensor-card.alert::before { background: var(--leak); }
.sensor-card.leak::before { background: var(--chart-hum); }
.sensor-card.pending::before { background: var(--warn); }
.sensor-card.offline::before { background: var(--offline); }
.sensor-card.offline { opacity: .78; }
.sensor-card.leak {
border-color: rgba(63,199,230,.4);
background: linear-gradient(180deg, rgba(63,199,230,.10), transparent 60%), var(--surface);
animation: dash-leak-glow 1.8s ease-in-out infinite;
}
@keyframes dash-leak-glow {
50% { box-shadow: 0 0 26px rgba(63,199,230,.30); }
}
/* 유형 칩 2개(결합) 나란히 */
.sc-types { display: flex; gap: 4px; flex: none; }
/* 상태 라인: 점 + 텍스트 (badge-row 대체 — 기존 배지도 유지 호환) */
.status-line { display: flex; align-items: center; gap: 7px; font-size: .8125rem; font-weight: 700; margin-top: var(--sp-2); }
.status-line .sdot { width: 8px; height: 8px; border-radius: 50%; background: var(--normal); box-shadow: 0 0 8px var(--normal); flex: none; }
.status-line.ok { color: var(--normal-ink); }
.status-line.err { color: var(--leak-ink); }
.status-line.err .sdot { background: var(--leak); box-shadow: 0 0 8px var(--leak); animation: dash-pulse 1.2s ease-in-out infinite; }
.status-line.leakst { color: var(--cold-ink); }
.status-line.leakst .sdot { background: var(--chart-hum); box-shadow: 0 0 8px var(--chart-hum); animation: dash-pulse 1.2s ease-in-out infinite; }
.status-line.pend { color: var(--warn-ink); }
.status-line.pend .sdot { background: var(--warn); box-shadow: 0 0 8px var(--warn); }
.status-line.off { color: var(--offline-ink); }
.status-line.off .sdot { background: var(--offline); box-shadow: none; }
@keyframes dash-pulse { 50% { opacity: .4; } }
/* 온습도 큰 수치 2열 */
.vals { display: flex; gap: var(--sp-5); margin-top: var(--sp-3); }
.vals .v { flex: 1; min-width: 0; }
.vals .v .n {
font-size: 1.7rem; font-weight: 800; letter-spacing: -.03em; line-height: 1.08;
font-variant-numeric: tabular-nums; color: var(--ink);
}
.vals .v .n small { font-size: .85rem; color: var(--ink-2); font-weight: 600; letter-spacing: 0; }
.vals .v .l { font-size: .7rem; color: var(--ink-3); margin-top: 2px; }
.vals .v.temp .n { color: var(--chart-temp); }
.vals .v.hum .n { color: var(--chart-hum); }
.vals .v .n.hot { text-shadow: 0 0 14px rgba(255,138,92,.5); }
.vals .v .n.cold { color: var(--cold-ink); }
.vals .v .n.humid { text-shadow: 0 0 14px rgba(255,178,62,.5); }
.vals .v .n.dry { color: var(--dry-ink); }
/* 24h 스파크라인 */
.spark24 { width: 100%; height: 34px; display: block; margin-top: var(--sp-2); }
.spark24 polyline { fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
.spark24 .sp-t { stroke: var(--chart-temp); opacity: .85; }
.spark24 .sp-h { stroke: var(--chart-hum); opacity: .85; stroke-dasharray: 5 3; }
/* 누수 행(결합 카드 내부): 경과·SMS·사진 */
.leak-row {
display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap;
margin-top: var(--sp-3); padding-top: var(--sp-3); border-top: 1px solid var(--line-2);
}
.leak-row .leak-fact { font-size: .8125rem; color: var(--ink-2); font-variant-numeric: tabular-nums; }
.leak-row .leak-fact b { color: var(--ink); font-weight: 800; }
.leak-row .leak-thumbs { margin-left: auto; }
.leak-row .leak-thumbs img {
width: 46px; height: 36px; object-fit: cover;
transition: transform .18s var(--spring);
}
.leak-row .leak-thumbs a:hover img { transform: scale(1.5); }
/* 누수 카드 하단 물결(장식) */
.leak-wave { position: absolute; left: 0; right: 0; bottom: 0; height: 42px; opacity: .14; pointer-events: none; }
.leak-wave path { fill: var(--chart-hum); }
/* 카드 푸터: 최종확인 + 보고 주기 */
.sc-foot {
display: flex; justify-content: space-between; gap: var(--sp-2);
font-size: .72rem; color: var(--ink-3); margin-top: var(--sp-3);
font-variant-numeric: tabular-nums;
}
.sc-foot .stale { color: var(--leak-ink); font-weight: 700; }
/* ── 이력 테이블: 카드형 헤더(테이블 위 캡션 스트립) ────────────────────────── */
h2.table-title { margin-bottom: 0; font-size: .9375rem; }
.table-title {
display: flex; align-items: center; gap: var(--sp-2);
background: var(--surface-2); border: 1px solid var(--line); border-bottom: none;
border-radius: var(--r-lg) var(--r-lg) 0 0; padding: 12px 16px;
font-size: .9375rem; font-weight: 700;
}
.table-title .count-pill {
margin-left: auto; font-size: .7rem; font-weight: 700; color: var(--ink-2);
background: var(--surface); border: 1px solid var(--line);
padding: 2px 10px; border-radius: var(--r-pill); font-variant-numeric: tabular-nums;
}
.table-title + .table-wrap,
.table-title + details.mobile-detail .table-wrap { border-radius: 0 0 var(--r-lg) var(--r-lg); }
.table-title + .table-wrap table,
.table-title + details.mobile-detail table { border-radius: 0 0 var(--r-lg) var(--r-lg); border-top: none; }
.table-title .h2-sub { margin-left: auto; font-size: .72rem; font-weight: 600; color: var(--ink-3); font-variant-numeric: tabular-nums; }
/* ── 히어로 미세 조정: 큰 제목 음수 트래킹 ──────────────────────────────────── */
.status-hero h2 { letter-spacing: -.02em; }
.kpi .kpi-val, .card .val, .gauge-num b { letter-spacing: -.03em; }
/* ── 미세먼지(PM) 계통 색상·요소 (온습도 orange 와 구분되는 magenta/violet 계열) ──────
PMS7003 미세먼지 경보가 온습도 경보로 오독되지 않도록 별도 색 계통을 부여한다. PM 토큰은
app.css 에 없으므로 여기서 정의(dash-v2.css 는 app.css 뒤 로드 — 재정의 아님, additive). */
:root {
--pm: #d24fd6; /* 미세먼지 대표 액센트(도넛·칩·라인) */
--pm-ink: #eca3ea; /* 어두운 배경 위 텍스트용 밝은 변형 */
--pm-bg: rgba(210,79,214,.15);
--pm25: #e85fbf; /* PM2.5 계열(magenta-pink) */
--pm10: #a06bff; /* PM10 계열(violet) */
}
/* 유형 칩(미세먼지) — sc-type.metric(청록)·sc-type.leak(회색)과 구분 */
.sc-type.pm { background: var(--pm-bg); color: var(--pm-ink); }
/* 카드 상태(미세먼지 경보) — 상단 바 + 배경 그라디언트(온습도 alert 와 별개 색) */
.sensor-card.pm { border-color: rgba(210,79,214,.4); background: linear-gradient(180deg, var(--pm-bg), transparent 60%), var(--surface); }
.sensor-card.pm::before { background: var(--pm); }
/* 상태 라인(미세먼지 경보) */
.status-line.pmst { color: var(--pm-ink); }
.status-line.pmst .sdot { background: var(--pm); box-shadow: 0 0 8px var(--pm); animation: dash-pulse 1.2s ease-in-out infinite; }
/* 미세먼지 큰 수치 2열 — 온습도 vals 재사용, 색만 PM 계열. 위반 시 hot 글로우(magenta) */
.vals.pm-vals { margin-top: var(--sp-2); }
.vals .v.pm25 .n { color: var(--pm25); }
.vals .v.pm10 .n { color: var(--pm10); }
.pm-vals .v .n.hot { text-shadow: 0 0 14px rgba(210,79,214,.55); }
/* 상태 pill(미세먼지/결로) — 이력·측정 표의 상태 배지 */
.pill.high_pm25 { background: var(--pm-bg); color: var(--pm-ink); }
.pill.high_pm10 { background: var(--pm-bg); color: var(--pm-ink); }
.pill.condensation_risk { background: var(--warn-bg); color: var(--warn-ink); }
/* 추이 차트(미세먼지) — dashboard.php 인라인 <style> 에 PM 규칙이 없어 여기서 정의 */
.chart-svg-wrap .axis-pm { fill: var(--pm); }
.chart-svg-wrap .line-pm25 { stroke: var(--pm25); }
.chart-svg-wrap .line-pm10 { stroke: var(--pm10); }
.legend-swatch.pm25 { background: var(--pm25); }
.legend-swatch.pm10 { background: var(--pm10); }
/* 7일 경보 추이 라인(미세먼지) + 도넛 세그먼트·범례(미세먼지) */
.ov-trend .line-pm { stroke: var(--pm); }
.donut-ring circle.seg.cat-pm { stroke: var(--pm); }
.dl i.cat-pm { background: var(--pm); }
/* ── 접근성 ─────────────────────────────────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
.sensor-card.leak { animation: none; }
.status-line .sdot { animation: none !important; }
.refresh-ring circle { animation: none; }
.kpi, .sensor-card, .leak-status-card, .alert-strip, .quick-tile,
.tabbar .tab-link, .top-actions a, .refresh-toggle { transition: none; }
.kpi:hover, .sensor-card:hover, .leak-status-card:hover, .alert-strip:hover { transform: none; }
}
@media (prefers-reduced-transparency: reduce) {
header { background: var(--header-bg); backdrop-filter: none; -webkit-backdrop-filter: none; }
}
/* ── 인쇄: 장식·모션 요소 제거(app.css 강제 라이트 계승) ─────────────────────── */
@media print {
.leak-wave, .refresh-ring, .spark24 { display: none !important; }
.sensor-card, .sensor-card.leak { animation: none; box-shadow: none; }
.status-line .sdot { animation: none; box-shadow: none; border: 1px solid currentColor; }
.vals .v .n { text-shadow: none; }
}

View file

@ -104,6 +104,7 @@ $kpi_tiles = [
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>온습도·누수 통합 모니터링 대시보드 <?= htmlspecialchars(APP_VERSION) ?></title> <title>온습도·누수 통합 모니터링 대시보드 <?= htmlspecialchars(APP_VERSION) ?></title>
<link rel="stylesheet" href="assets/app.css"> <link rel="stylesheet" href="assets/app.css">
<link rel="stylesheet" href="assets/dash-v2.css">
<style> <style>
/* dashboard.php 전용 잔여 스타일 — 값은 var(--*) 토큰만(app.css 규칙 재정의 아님) */ /* dashboard.php 전용 잔여 스타일 — 값은 var(--*) 토큰만(app.css 규칙 재정의 아님) */
.h2-sub { font-weight: 400; color: var(--ink-2); font-size: .8125rem; } .h2-sub { font-weight: 400; color: var(--ink-2); font-size: .8125rem; }
@ -153,7 +154,7 @@ $kpi_tiles = [
<?php if ($current_tab === 'all'): ?> <?php if ($current_tab === 'all'): ?>
인쇄용 전체 보기 · 자동 새로고침 없음 인쇄용 전체 보기 · 자동 새로고침 없음
<?php else: ?> <?php else: ?>
<span class="refresh">30초 자동 새로고침<button type="button" id="refresh-toggle" class="refresh-toggle" aria-pressed="true" aria-label="자동 새로고침 일시정지">⏸ 일시정지</button></span> <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; ?> <?php endif; ?>
</div> </div>
</section> </section>
@ -373,6 +374,8 @@ $kpi_tiles = [
btn.textContent = on ? '⏸ 일시정지' : '▶ 재개'; btn.textContent = on ? '⏸ 일시정지' : '▶ 재개';
btn.setAttribute('aria-pressed', on ? 'true' : 'false'); btn.setAttribute('aria-pressed', on ? 'true' : 'false');
btn.setAttribute('aria-label', on ? '자동 새로고침 일시정지' : '자동 새로고침 재개'); btn.setAttribute('aria-label', on ? '자동 새로고침 일시정지' : '자동 새로고침 재개');
var wrap = document.getElementById('refresh-wrap');
if (wrap) { wrap.classList.toggle('paused', !on); }
} }
if (btn) { if (btn) {
btn.addEventListener('click', function () { btn.addEventListener('click', function () {

View file

@ -57,22 +57,31 @@ function dash_current_range(): string {
return isset(DASH_RANGES[$r]) ? $r : '3d'; return isset(DASH_RANGES[$r]) ? $r : '3d';
} }
// 내부 링크 빌더 — tab·range 를 병행 보존한다(기간 토글이 탭을 잃거나 그 반대 방지). // 현재 추이 대상 센서 ($_GET; 0=자동 선택). 유효성(수신 이력)은 load_tab_trend 에서 재확인.
// 기본값(live/3d)은 생략해 URL 을 짧게 유지. function dash_current_sensor(): int {
$s = isset($_GET['sensor']) && is_numeric($_GET['sensor']) ? (int)$_GET['sensor'] : 0;
return $s > 0 ? $s : 0;
}
// 내부 링크 빌더 — tab·range·sensor 를 병행 보존한다(토글이 서로의 선택을 잃지 않도록).
// 기본값(live/3d/자동)은 생략해 URL 을 짧게 유지.
function dash_url(array $over = [], string $frag = ''): string { function dash_url(array $over = [], string $frag = ''): string {
$tab = $over['tab'] ?? dash_current_tab(); $tab = $over['tab'] ?? dash_current_tab();
$range = $over['range'] ?? dash_current_range(); $range = $over['range'] ?? dash_current_range();
$sensor = $over['sensor'] ?? dash_current_sensor();
if (!isset(DASH_TABS[$tab]) && $tab !== 'all') { $tab = 'live'; } if (!isset(DASH_TABS[$tab]) && $tab !== 'all') { $tab = 'live'; }
if (!isset(DASH_RANGES[$range])) { $range = '3d'; } if (!isset(DASH_RANGES[$range])) { $range = '3d'; }
$params = []; $params = [];
if ($tab !== 'live') { $params['tab'] = $tab; } if ($tab !== 'live') { $params['tab'] = $tab; }
if ($range !== '3d') { $params['range'] = $range; } if ($range !== '3d') { $params['range'] = $range; }
if ((int)$sensor > 0) { $params['sensor'] = (int)$sensor; }
$qs = http_build_query($params); $qs = http_build_query($params);
return 'dashboard.php' . ($qs !== '' ? '?' . $qs : '') . ($frag !== '' ? '#' . $frag : ''); return 'dashboard.php' . ($qs !== '' ? '?' . $qs : '') . ($frag !== '' ? '#' . $frag : '');
} }
// ── 표시 헬퍼 ─────────────────────────────────────────────── // ── 표시 헬퍼 ───────────────────────────────────────────────
// metric_status: normal|high_temp|low_temp|high_humidity|low_humidity // metric_status: normal|high_temp|low_temp|high_humidity|low_humidity
// |condensation_risk|high_pm25|high_pm10 (결로·미세먼지는 추가 상태)
function metric_status_label(string $status): string { function metric_status_label(string $status): string {
$labels = [ $labels = [
'normal' => '정상', 'normal' => '정상',
@ -80,6 +89,9 @@ function metric_status_label(string $status): string {
'low_temp' => '저온', 'low_temp' => '저온',
'high_humidity' => '고습', 'high_humidity' => '고습',
'low_humidity' => '저습', 'low_humidity' => '저습',
'condensation_risk' => '결로주의',
'high_pm25' => '미세먼지(PM2.5)',
'high_pm10' => '미세먼지(PM10)',
]; ];
return $labels[$status] ?? $status; return $labels[$status] ?? $status;
} }
@ -145,24 +157,42 @@ function load_global_data(PDO $db, bool $fresh_ops = false): array {
// (1) 센서별 현재 상태 + 최신 측정 + 실시간 오프라인/경보 판정 // (1) 센서별 현재 상태 + 최신 측정 + 실시간 오프라인/경보 판정
$statuses = $db->query("SELECT * FROM leak_sensor_status ORDER BY sensor_id")->fetchAll(); $statuses = $db->query("SELECT * FROM leak_sensor_status ORDER BY sensor_id")->fetchAll();
$latest_metrics = []; // 최신 측정을 (sensor_id, metric_type) 단위로 가져온다. 누수 노드(1·3)는 SHT30 행
// (온습도, pm NULL)과 PMS7003 행(미세먼지, 온습도 NULL)이 같은 sensor_id 로 교대로
// 들어오므로, metric_type-agnostic 인 MAX(id) GROUP BY sensor_id 로는 서로를 덮어써
// 온습도 또는 미세먼지 한쪽이 사라진다. metric_type 을 GROUP BY 에 포함해 두 계통을
// 각각 최신값으로 버킷팅한다. 레거시 빈 metric_type('') 은 SHT30(온습도)으로 취급.
$latest_sht30 = []; // sensor_id => 최신 온습도(SHT30) 행
$latest_pms = []; // sensor_id => 최신 미세먼지(PMS7003) 행
$metric_rows = $db->query(" $metric_rows = $db->query("
SELECT sm.* SELECT sm.*
FROM leak_sensor_metric sm FROM leak_sensor_metric sm
INNER JOIN ( INNER JOIN (
SELECT sensor_id, MAX(id) AS latest_id SELECT sensor_id, metric_type, MAX(id) AS latest_id
FROM leak_sensor_metric FROM leak_sensor_metric
GROUP BY sensor_id GROUP BY sensor_id, metric_type
) latest ON latest.latest_id = sm.id ) latest ON latest.latest_id = sm.id
ORDER BY sm.sensor_id ORDER BY sm.sensor_id
")->fetchAll(); ")->fetchAll();
foreach ($metric_rows as $metric_row) { foreach ($metric_rows as $metric_row) {
$latest_metrics[(int)$metric_row['sensor_id']] = $metric_row; $mid = (int)$metric_row['sensor_id'];
$mtyp = (string)($metric_row['metric_type'] ?? '');
if ($mtyp === 'pms7003') {
$latest_pms[$mid] = $metric_row;
} else {
// 'sht30' 또는 레거시 '' → 온습도 계통. 동일 sid 에 둘 다 있으면 id 큰 쪽 유지.
if (!isset($latest_sht30[$mid]) || (int)$metric_row['id'] > (int)$latest_sht30[$mid]['id']) {
$latest_sht30[$mid] = $metric_row;
} }
}
}
// 온습도 계통을 대표 '최신 측정'으로 노출(temp/hum/metric_status 는 SHT30 행에서만).
$latest_metrics = $latest_sht30;
foreach ($statuses as &$s) { foreach ($statuses as &$s) {
$sid = (int)$s['sensor_id']; $sid = (int)$s['sensor_id'];
$s['latest_metric'] = $latest_metrics[$sid] ?? null; $s['latest_metric'] = $latest_sht30[$sid] ?? null; // 온습도(SHT30) 행
$s['latest_pm'] = $latest_pms[$sid] ?? null; // 미세먼지(PMS7003) 행
$last_seen_ts = strtotime($s['last_seen'] ?? '2000-01-01'); $last_seen_ts = strtotime($s['last_seen'] ?? '2000-01-01');
$elapsed = $now - $last_seen_ts; $elapsed = $now - $last_seen_ts;
@ -170,10 +200,16 @@ function load_global_data(PDO $db, bool $fresh_ops = false): array {
$s['is_offline'] = ($s['offline_alerted'] == 1) || ($elapsed >= heartbeat_timeout_for($sid)); $s['is_offline'] = ($s['offline_alerted'] == 1) || ($elapsed >= heartbeat_timeout_for($sid));
$s['offline_elapsed_min'] = (int)round($elapsed / 60); $s['offline_elapsed_min'] = (int)round($elapsed / 60);
// 온습도(th) 경보 상태 — SHT30 행 metric_status 만 본다(미세먼지 행에 오염되지 않게).
$status_code = $s['latest_metric']['metric_status'] ?? 'normal'; $status_code = $s['latest_metric']['metric_status'] ?? 'normal';
$s['metric_status'] = $status_code; $s['metric_status'] = $status_code;
$s['is_alerting'] = ($status_code !== 'normal'); $s['is_alerting'] = ($status_code !== 'normal');
// 미세먼지(pm) 경보 상태 — PMS7003 행 metric_status(normal|high_pm25|high_pm10) 만.
$pm_code = $s['latest_pm']['metric_status'] ?? 'normal';
$s['pm_status'] = $pm_code;
$s['is_pm_alerting'] = ($pm_code !== 'normal');
// 누수 확정 상태(디바운스): 컬럼이 있으면 leak_confirmed 를, 없으면 raw is_leak 를 // 누수 확정 상태(디바운스): 컬럼이 있으면 leak_confirmed 를, 없으면 raw is_leak 를
// 헤드라인/집계의 단일 기준으로 삼는다(SMS 는 확정 엣지에서만 나가므로 표시와 일치). // 헤드라인/집계의 단일 기준으로 삼는다(SMS 는 확정 엣지에서만 나가므로 표시와 일치).
// leak_pending: raw 가 확정과 어긋나 확인창 대기 중(카드에 '확인중' 배지). // leak_pending: raw 가 확정과 어긋나 확인창 대기 중(카드에 '확인중' 배지).
@ -193,10 +229,12 @@ function load_global_data(PDO $db, bool $fresh_ops = false): array {
$g['statuses'] = $statuses; $g['statuses'] = $statuses;
$g['latest_metrics'] = $latest_metrics; $g['latest_metrics'] = $latest_metrics;
// (2) 집계: 온라인/오프라인/온습도 경보/누수 활성 // (2) 집계: 온라인/오프라인/온습도 경보/미세먼지 경보/누수 활성
$g['sensor_total'] = count($statuses); $g['sensor_total'] = count($statuses);
$g['offline_count'] = count(array_filter($statuses, fn($s) => $s['is_offline'])); $g['offline_count'] = count(array_filter($statuses, fn($s) => $s['is_offline']));
$g['alert_count'] = count(array_filter($statuses, fn($s) => $s['is_alerting'] && !$s['is_offline'])); $g['alert_count'] = count(array_filter($statuses, fn($s) => $s['is_alerting'] && !$s['is_offline']));
// 미세먼지 경보 활성(오프라인 아님) — 온습도(alert_count)와 별도 집계.
$g['pm_alert_count'] = count(array_filter($statuses, fn($s) => !empty($s['is_pm_alerting']) && !$s['is_offline']));
$g['online_count'] = $g['sensor_total'] - $g['offline_count']; $g['online_count'] = $g['sensor_total'] - $g['offline_count'];
$g['uptime_pct'] = $g['sensor_total'] > 0 $g['uptime_pct'] = $g['sensor_total'] > 0
? (int)round($g['online_count'] / $g['sensor_total'] * 100) : 100; ? (int)round($g['online_count'] / $g['sensor_total'] * 100) : 100;
@ -290,6 +328,7 @@ function load_global_data(PDO $db, bool $fresh_ops = false): array {
AND sent_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR) AND sent_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR)
AND (message LIKE '" . SMS_TAG_HIGH_TEMP . "%' OR message LIKE '" . SMS_TAG_LOW_TEMP . "%' AND (message LIKE '" . SMS_TAG_HIGH_TEMP . "%' OR message LIKE '" . SMS_TAG_LOW_TEMP . "%'
OR message LIKE '" . SMS_TAG_HIGH_HUMIDITY . "%' OR message LIKE '" . SMS_TAG_LOW_HUMIDITY . "%' OR message LIKE '" . SMS_TAG_HIGH_HUMIDITY . "%' OR message LIKE '" . SMS_TAG_LOW_HUMIDITY . "%'
OR message LIKE '" . SMS_TAG_HIGH_PM . "%'
OR message LIKE '" . SMS_TAG_CONDENSATION . "%' OR message LIKE '" . SMS_TAG_DEVICE_OFFLINE . "%' OR message LIKE '" . SMS_TAG_CONDENSATION . "%' OR message LIKE '" . SMS_TAG_DEVICE_OFFLINE . "%'
OR message LIKE '" . SMS_TAG_LEAK_ALERT . "%' OR message LIKE '" . SMS_TAG_LEAK_RECOVERY . "%' OR message LIKE '" . SMS_TAG_LEAK_ALERT . "%' OR message LIKE '" . SMS_TAG_LEAK_RECOVERY . "%'
OR message LIKE '" . SMS_TAG_ESCALATION . "%') OR message LIKE '" . SMS_TAG_ESCALATION . "%')
@ -330,9 +369,12 @@ function load_global_data(PDO $db, bool $fresh_ops = false): array {
'rate_temp_10' => FORECAST_RATE_TEMP_C_PER_10MIN, 'rate_temp_10' => FORECAST_RATE_TEMP_C_PER_10MIN,
'rate_rh_10' => FORECAST_RATE_RH_PER_10MIN, 'rate_rh_10' => FORECAST_RATE_RH_PER_10MIN,
]; ];
// 온습도 회귀 표본은 SHT30 행만(미세먼지 PMS7003 행은 temp/hum NULL → 표본 오염 방지).
$recent_stmt = $db->prepare( $recent_stmt = $db->prepare(
"SELECT temperature_c, humidity_percent, UNIX_TIMESTAMP(measured_at) AS t "SELECT temperature_c, humidity_percent, UNIX_TIMESTAMP(measured_at) AS t
FROM leak_sensor_metric WHERE sensor_id = :sid ORDER BY id DESC LIMIT :lim" FROM leak_sensor_metric
WHERE sensor_id = :sid AND (metric_type = 'sht30' OR metric_type = '')
ORDER BY id DESC LIMIT :lim"
); );
foreach ($statuses as $s) { foreach ($statuses as $s) {
$sid = (int)$s['sensor_id']; $sid = (int)$s['sensor_id'];
@ -382,22 +424,32 @@ function load_global_data(PDO $db, bool $fresh_ops = false): array {
return $g; return $g;
} }
// 히어로 상태 판정. 우선순위: 누수 > 온습도 경보 > 오프라인 > 미복구 사고 > 중요 진단 > 정상. // 히어로 상태 판정. 우선순위: 누수 > 미세먼지 > 온습도 경보 > 오프라인 > 미복구 사고 > 중요 진단 > 정상.
function dash_hero_state(array $g): array { function dash_hero_state(array $g): array {
$pm_alert = (int)($g['pm_alert_count'] ?? 0);
$parts = []; $parts = [];
if ($g['leak_active_count'] > 0) { $parts[] = "누수 {$g['leak_active_count']}개"; } if ($g['leak_active_count'] > 0) { $parts[] = "누수 {$g['leak_active_count']}개"; }
if ($pm_alert > 0) { $parts[] = "미세먼지 {$pm_alert}개"; }
if ($g['alert_count'] > 0) { $parts[] = "임계 경보 {$g['alert_count']}개"; } if ($g['alert_count'] > 0) { $parts[] = "임계 경보 {$g['alert_count']}개"; }
if ($g['offline_count'] > 0) { $parts[] = "오프라인 {$g['offline_count']}개"; } if ($g['offline_count'] > 0) { $parts[] = "오프라인 {$g['offline_count']}개"; }
if ($g['unrecovered_count'] > 0) { $parts[] = "미복구 사고 {$g['unrecovered_count']}건"; } if ($g['unrecovered_count'] > 0) { $parts[] = "미복구 사고 {$g['unrecovered_count']}건"; }
if ($g['ops_critical_count'] > 0) { $parts[] = "중요 진단 {$g['ops_critical_count']}건"; } if ($g['ops_critical_count'] > 0) { $parts[] = "중요 진단 {$g['ops_critical_count']}건"; }
$detail = implode(' · ', $parts); $detail = implode(' · ', $parts);
// 누수(최우선) — 온습도 동반 시 기존 결합 문구 유지(추가적으로 미세먼지는 detail 에 표기).
if ($g['leak_active_count'] > 0 && $g['alert_count'] > 0) { if ($g['leak_active_count'] > 0 && $g['alert_count'] > 0) {
return ['danger', '누수·온습도 경보 발생', $detail]; return ['danger', '누수·온습도 경보 발생', $detail];
} }
if ($g['leak_active_count'] > 0) { if ($g['leak_active_count'] > 0) {
return ['danger', '누수 감지 발생', $detail]; return ['danger', '누수 감지 발생', $detail];
} }
// 미세먼지(누수 다음, 온습도보다 우선)
if ($pm_alert > 0 && $g['alert_count'] > 0) {
return ['danger', '미세먼지·온습도 경보 발생', $detail];
}
if ($pm_alert > 0) {
return ['danger', '미세먼지 경보 발생', $detail];
}
if ($g['alert_count'] > 0) { if ($g['alert_count'] > 0) {
return ['danger', '온습도 경보 발생', $detail]; return ['danger', '온습도 경보 발생', $detail];
} }
@ -410,7 +462,7 @@ function dash_hero_state(array $g): array {
if ($g['ops_critical_count'] > 0) { if ($g['ops_critical_count'] > 0) {
return ['warning', '운영 확인 필요', "중요 진단 {$g['ops_critical_count']}건을 확인하세요."]; return ['warning', '운영 확인 필요', "중요 진단 {$g['ops_critical_count']}건을 확인하세요."];
} }
return ['normal', '정상 감시 중', '현재 누수·온습도 경보와 중요 운영 경고가 없습니다.']; return ['normal', '정상 감시 중', '현재 누수·미세먼지·온습도 경보와 중요 운영 경고가 없습니다.'];
} }
// 히어로 상세 줄 — 경보/누수 중 센서별 "누가·어디서·얼마나(값)·언제부터·SMS 나갔는지". // 히어로 상세 줄 — 경보/누수 중 센서별 "누가·어디서·얼마나(값)·언제부터·SMS 나갔는지".
@ -431,9 +483,22 @@ function dash_hero_alert_lines(PDO $db, array $g): array {
WHERE sensor_id = :sid AND message LIKE '" . SMS_TAG_LEAK_ALERT . "%' WHERE sensor_id = :sid AND message LIKE '" . SMS_TAG_LEAK_ALERT . "%'
ORDER BY id DESC LIMIT 1 ORDER BY id DESC LIMIT 1
"); ");
// 미세먼지 경보 SMS(가장 최근) — [미세먼지경보] 태그.
$sms_pm_stmt = $db->prepare("
SELECT sent_at, status FROM leak_sms_log
WHERE sensor_id = :sid AND message LIKE '" . SMS_TAG_HIGH_PM . "%'
ORDER BY id DESC LIMIT 1
");
// 온습도 경보 시작('마지막 정상' 이후) — SHT30 행만(미세먼지 정상 행에 오염되지 않게).
$start_stmt = $db->prepare(" $start_stmt = $db->prepare("
SELECT MAX(measured_at) FROM leak_sensor_metric SELECT MAX(measured_at) FROM leak_sensor_metric
WHERE sensor_id = :sid AND metric_status = 'normal' WHERE sensor_id = :sid AND metric_status = 'normal'
AND (metric_type = 'sht30' OR metric_type = '')
");
// 미세먼지 경보 시작('마지막 정상 PM 측정' 이후) — PMS7003 행만.
$start_pm_stmt = $db->prepare("
SELECT MAX(measured_at) FROM leak_sensor_metric
WHERE sensor_id = :sid AND metric_status = 'normal' AND metric_type = 'pms7003'
"); ");
$fmt_since = function (?string $start_at) use ($now): string { $fmt_since = function (?string $start_at) use ($now): string {
@ -487,44 +552,153 @@ function dash_hero_alert_lines(PDO $db, array $g): array {
. ($bits ? ' (' . implode(' · ', $bits) . ')' : ''), . ($bits ? ' (' . implode(' · ', $bits) . ')' : ''),
]; ];
} }
// 미세먼지 경보 중(오프라인 아님) — PMS7003 행 기준, µg/m³ 표기
if (!empty($s['is_pm_alerting']) && !$s['is_offline']) {
$pm = $s['latest_pm'] ?? null;
$pm_code = $s['pm_status'] ?? 'normal';
// high_pm10 이면 PM10, 그 외(high_pm25)는 PM2.5 값을 대표로 표시.
$pm_val_raw = $pm_code === 'high_pm10' ? ($pm['pm10'] ?? null) : ($pm['pm2_5'] ?? null);
$val = ($pm_val_raw !== null) ? ((int)$pm_val_raw . 'µg/m³') : '-';
$start_pm_stmt->execute([':sid' => $sid]);
$last_normal = $start_pm_stmt->fetchColumn() ?: null;
$sms_pm_stmt->execute([':sid' => $sid]);
$bits = array_filter([$fmt_since($last_normal), $fmt_sms($sms_pm_stmt->fetch())]);
$lines[] = [
'severity' => 'danger',
'text' => "{$s['sensor_name']} · {$s['device_location']} — "
. metric_status_label($pm_code) . " {$val}"
. ($bits ? ' (' . implode(' · ', $bits) . ')' : ''),
];
}
} }
return $lines; return $lines;
} }
// ── live 탭: 전역 데이터만으로 충분(추가 쿼리 없음) ───────── // ── live 탭: 전역 데이터만으로 충분(추가 쿼리 없음) ─────────
function load_tab_live(PDO $db, array $g): array { function load_tab_live(PDO $db, array $g): array {
return []; $t = [];
// (1) 24h 스파크라인 — 온습도 센서별 최근 24시간 측정, 센서당 ≤48pt 다운샘플.
// 카드에서 인라인 SVG polyline 으로 그린다(외부 라이브러리 없음).
$t['live_sparks'] = [];
$metric_ids = array_map('intval', METRIC_SENSOR_IDS);
if (!empty($metric_ids)) {
$ph = implode(',', array_fill(0, count($metric_ids), '?'));
// 온습도 스파크는 SHT30 행만(미세먼지 PMS7003 행은 temp/hum NULL — 결측점 방지).
$sp_stmt = $db->prepare("
SELECT sensor_id, temperature_c, humidity_percent
FROM leak_sensor_metric
WHERE sensor_id IN ({$ph}) AND measured_at >= DATE_SUB(NOW(), INTERVAL 1 DAY)
AND (metric_type = 'sht30' OR metric_type = '')
ORDER BY sensor_id, id
");
$sp_stmt->execute($metric_ids);
$spark_rows = [];
foreach ($sp_stmt->fetchAll() as $sr) {
$spark_rows[(int)$sr['sensor_id']][] = [
'temp' => $sr['temperature_c'] !== null ? (float)$sr['temperature_c'] : null,
'hum' => $sr['humidity_percent'] !== null ? (float)$sr['humidity_percent'] : null,
];
}
foreach ($spark_rows as $sp_sid => $pts) {
$n = count($pts);
if ($n > 48) { // 균등 간격 다운샘플(마지막 점은 항상 포함)
$sampled = [];
for ($k = 0; $k < 48; $k++) {
$sampled[] = $pts[(int)floor($k * ($n - 1) / 47)];
}
$pts = $sampled;
}
$t['live_sparks'][$sp_sid] = $pts;
}
}
// (2) 활성(미복구) 누수 메타 — 센서별 최신 사고의 시작 시각·사진·경보 SMS 건수.
// 결합 카드의 누수 행(경과 시간 · SMS n건 · 사진 썸네일)에 사용.
$t['live_leak_meta'] = [];
if (table_exists($db, 'leak_incident')) {
$open_incs = $db->query("
SELECT sensor_id, started_at, log_id_alert
FROM leak_incident
WHERE recovered_at IS NULL
ORDER BY id DESC
")->fetchAll();
foreach ($open_incs as $inc) {
$l_sid = (int)$inc['sensor_id'];
if (isset($t['live_leak_meta'][$l_sid])) { continue; } // 센서당 최신 1건
$meta = [
'started_at' => $inc['started_at'],
'elapsed_min' => max(0, (int)round((time() - strtotime($inc['started_at'])) / 60)),
'photos' => [],
'sms_count' => 0,
];
if ($inc['log_id_alert'] !== null && table_exists($db, 'leak_photo')) {
$lp_stmt = $db->prepare("
SELECT id, captured_at FROM leak_photo
WHERE log_id = :lid ORDER BY id DESC LIMIT 2
");
$lp_stmt->execute([':lid' => (int)$inc['log_id_alert']]);
$meta['photos'] = $lp_stmt->fetchAll();
}
$sms_stmt = $db->prepare("
SELECT COUNT(*) FROM leak_sms_log
WHERE sensor_id = :sid AND status = 'success' AND sent_at >= :started
");
$sms_stmt->execute([':sid' => $l_sid, ':started' => $inc['started_at']]);
$meta['sms_count'] = (int)$sms_stmt->fetchColumn();
$t['live_leak_meta'][$l_sid] = $meta;
}
}
return $t;
} }
// ── trend 탭: 추이 차트 + 통계 위젯 ───────────────────────── // ── trend 탭: 추이 차트 + 통계 위젯 ─────────────────────────
function load_tab_trend(PDO $db, array $g): array { function load_tab_trend(PDO $db, array $g): array {
$t = []; $t = [];
// 추이 차트 대상 센서: METRIC_SENSOR_IDS 중 첫 수신 센서 우선, 없으면 첫 센서 // 추이 차트 대상 센서: 선택 가능한 metric 센서(수신 이력 보유) 목록을 만들고,
// $_GET['sensor'] 요청이 유효하면 그걸, 아니면 첫 metric 센서를 자동 선택한다.
$status_ids = array_map(fn($s) => (int)$s['sensor_id'], $g['statuses']); $status_ids = array_map(fn($s) => (int)$s['sensor_id'], $g['statuses']);
$chart_sensor_id = 0; $metric_ids = array_map('intval', METRIC_SENSOR_IDS);
foreach (METRIC_SENSOR_IDS as $mid) {
if (in_array((int)$mid, $status_ids, true)) { $chart_sensor_id = (int)$mid; break; } $chart_sensor_options = []; // 선택기 UI 용: [{id, name}]
foreach ($g['statuses'] as $s) {
$sid = (int)$s['sensor_id'];
if (in_array($sid, $metric_ids, true)) {
$chart_sensor_options[] = ['id' => $sid, 'name' => (string)($s['sensor_name'] ?? '')];
} }
if ($chart_sensor_id === 0) {
$chart_sensor_id = $status_ids[0] ?? 0;
} }
$option_ids = array_map(fn($o) => $o['id'], $chart_sensor_options);
$requested = dash_current_sensor();
if ($requested > 0 && in_array($requested, $option_ids, true)) {
$chart_sensor_id = $requested; // 사용자가 고른 센서
} else {
$chart_sensor_id = $option_ids[0] ?? ($status_ids[0] ?? 0); // 자동(첫 metric)
}
$chart_sensor_name = ''; $chart_sensor_name = '';
foreach ($g['statuses'] as $s) { foreach ($g['statuses'] as $s) {
if ((int)$s['sensor_id'] === $chart_sensor_id) { $chart_sensor_name = $s['sensor_name']; break; } if ((int)$s['sensor_id'] === $chart_sensor_id) { $chart_sensor_name = $s['sensor_name']; break; }
} }
$t['chart_sensor_id'] = $chart_sensor_id; $t['chart_sensor_id'] = $chart_sensor_id;
$t['chart_sensor_name'] = $chart_sensor_name; $t['chart_sensor_name'] = $chart_sensor_name;
$t['chart_sensor_options'] = $chart_sensor_options;
$t['chart_range'] = dash_current_range(); $t['chart_range'] = dash_current_range();
$range_days = DASH_RANGES[$t['chart_range']]; $range_days = DASH_RANGES[$t['chart_range']];
$chart_points = []; $chart_points = [];
$chart_pm_points = [];
if ($chart_sensor_id > 0) { if ($chart_sensor_id > 0) {
// 온습도 추이 — SHT30 행만(미세먼지 PMS7003 행은 temp/hum NULL → 축 왜곡 방지).
$chart_stmt = $db->prepare(" $chart_stmt = $db->prepare("
SELECT measured_at, temperature_c, humidity_percent SELECT measured_at, temperature_c, humidity_percent
FROM leak_sensor_metric FROM leak_sensor_metric
WHERE sensor_id = :sid AND measured_at >= DATE_SUB(NOW(), INTERVAL :days DAY) WHERE sensor_id = :sid AND measured_at >= DATE_SUB(NOW(), INTERVAL :days DAY)
AND (metric_type = 'sht30' OR metric_type = '')
ORDER BY id DESC ORDER BY id DESC
LIMIT 600 LIMIT 600
"); ");
@ -538,8 +712,31 @@ function load_tab_trend(PDO $db, array $g): array {
'hum' => $cr['humidity_percent'] !== null ? (float)$cr['humidity_percent'] : null, 'hum' => $cr['humidity_percent'] !== null ? (float)$cr['humidity_percent'] : null,
]; ];
} }
// 미세먼지 추이 — PMS7003 행만(PM2.5/PM10, 온습도와 별도 스케일). PM 센서에서만 데이터.
if (in_array($chart_sensor_id, array_map('intval', PM_SENSOR_IDS), true)) {
$chart_pm_stmt = $db->prepare("
SELECT measured_at, pm2_5, pm10
FROM leak_sensor_metric
WHERE sensor_id = :sid AND metric_type = 'pms7003'
AND measured_at >= DATE_SUB(NOW(), INTERVAL :days DAY)
ORDER BY id DESC
LIMIT 600
");
$chart_pm_stmt->bindValue(':sid', $chart_sensor_id, PDO::PARAM_INT);
$chart_pm_stmt->bindValue(':days', $range_days, PDO::PARAM_INT);
$chart_pm_stmt->execute();
foreach (array_reverse($chart_pm_stmt->fetchAll()) as $cr) { // 오래된→최신
$chart_pm_points[] = [
't' => $cr['measured_at'],
'pm25' => $cr['pm2_5'] !== null ? (int)$cr['pm2_5'] : null,
'pm10' => $cr['pm10'] !== null ? (int)$cr['pm10'] : null,
];
}
}
} }
$t['chart_points'] = $chart_points; $t['chart_points'] = $chart_points;
$t['chart_pm_points'] = $chart_pm_points;
// 기간 내 최고 온도(피크 마커) // 기간 내 최고 온도(피크 마커)
$peak_i = null; $peak_v = null; $peak_i = null; $peak_v = null;
@ -551,11 +748,21 @@ function load_tab_trend(PDO $db, array $g): array {
$t['peak_i'] = $peak_i; $t['peak_i'] = $peak_i;
$t['peak_v'] = $peak_v; $t['peak_v'] = $peak_v;
// 7일 일별: 온습도 경보 측정 vs 누수 감지 (라인차트 + 게이지 스파크 공용) // 7일 일별: 온습도 경보 측정 vs 미세먼지 경보 vs 누수 감지 (라인차트 + 게이지 스파크 공용)
// 온습도(th) 라인은 SHT30 행만 — 미세먼지 경보(high_pm25/10)가 '온습도 경보'로 오분류되지 않게.
$mtrend_map = $db->query(" $mtrend_map = $db->query("
SELECT DATE(measured_at) d, COUNT(*) c SELECT DATE(measured_at) d, COUNT(*) c
FROM leak_sensor_metric FROM leak_sensor_metric
WHERE measured_at >= DATE_SUB(CURDATE(), INTERVAL 6 DAY) AND metric_status <> 'normal' WHERE measured_at >= DATE_SUB(CURDATE(), INTERVAL 6 DAY) AND metric_status <> 'normal'
AND (metric_type = 'sht30' OR metric_type = '')
GROUP BY DATE(measured_at)
")->fetchAll(PDO::FETCH_KEY_PAIR);
// 미세먼지(pm) 라인 — PMS7003 행의 임계 초과 측정 건수(별도 카테고리).
$pmtrend_map = $db->query("
SELECT DATE(measured_at) d, COUNT(*) c
FROM leak_sensor_metric
WHERE measured_at >= DATE_SUB(CURDATE(), INTERVAL 6 DAY) AND metric_status <> 'normal'
AND metric_type = 'pms7003'
GROUP BY DATE(measured_at) GROUP BY DATE(measured_at)
")->fetchAll(PDO::FETCH_KEY_PAIR); ")->fetchAll(PDO::FETCH_KEY_PAIR);
$ltrend_map = $db->query(" $ltrend_map = $db->query("
@ -564,28 +771,32 @@ function load_tab_trend(PDO $db, array $g): array {
WHERE event_type = 'leak_alert' AND sensor_time >= DATE_SUB(CURDATE(), INTERVAL 6 DAY) WHERE event_type = 'leak_alert' AND sensor_time >= DATE_SUB(CURDATE(), INTERVAL 6 DAY)
GROUP BY DATE(sensor_time) GROUP BY DATE(sensor_time)
")->fetchAll(PDO::FETCH_KEY_PAIR); ")->fetchAll(PDO::FETCH_KEY_PAIR);
$trend_days = []; $trend_metric = []; $trend_leak = []; $spark_alerts = []; $trend_days = []; $trend_metric = []; $trend_pm = []; $trend_leak = []; $spark_alerts = [];
for ($i = 6; $i >= 0; $i--) { for ($i = 6; $i >= 0; $i--) {
$d = date('Y-m-d', strtotime("-$i day")); $d = date('Y-m-d', strtotime("-$i day"));
$tm = (int)($mtrend_map[$d] ?? 0); $tm = (int)($mtrend_map[$d] ?? 0);
$tp = (int)($pmtrend_map[$d] ?? 0);
$tl = (int)($ltrend_map[$d] ?? 0); $tl = (int)($ltrend_map[$d] ?? 0);
$trend_days[] = $d; $trend_days[] = $d;
$trend_metric[] = $tm; $trend_metric[] = $tm;
$trend_pm[] = $tp;
$trend_leak[] = $tl; $trend_leak[] = $tl;
$spark_alerts[] = $tm + $tl; $spark_alerts[] = $tm + $tp + $tl; // 게이지 스파크 총계엔 미세먼지도 포함
} }
$t['trend_days'] = $trend_days; $t['trend_days'] = $trend_days;
$t['trend_metric'] = $trend_metric; $t['trend_metric'] = $trend_metric;
$t['trend_pm'] = $trend_pm;
$t['trend_leak'] = $trend_leak; $t['trend_leak'] = $trend_leak;
$t['trend_max'] = max(1, max(array_merge($trend_metric, $trend_leak))); $t['trend_max'] = max(1, max(array_merge($trend_metric, $trend_pm, $trend_leak)));
$t['spark_alerts'] = $spark_alerts; $t['spark_alerts'] = $spark_alerts;
$spark_today = end($spark_alerts); reset($spark_alerts); $spark_today = end($spark_alerts); reset($spark_alerts);
if ($g['alert_count'] > 0 || $g['leak_active_count'] > 0) { $t['ov_badge'] = 'crit'; $t['ov_badge_txt'] = '경보'; } if ($g['alert_count'] > 0 || (int)($g['pm_alert_count'] ?? 0) > 0 || $g['leak_active_count'] > 0) { $t['ov_badge'] = 'crit'; $t['ov_badge_txt'] = '경보'; }
elseif ($g['offline_count'] > 0 || $spark_today > 0) { $t['ov_badge'] = 'warn'; $t['ov_badge_txt'] = '주의'; } elseif ($g['offline_count'] > 0 || $spark_today > 0) { $t['ov_badge'] = 'warn'; $t['ov_badge_txt'] = '주의'; }
else { $t['ov_badge'] = 'ok'; $t['ov_badge_txt'] = '정상'; } else { $t['ov_badge'] = 'ok'; $t['ov_badge_txt'] = '정상'; }
// 경보 종류 도넛 (30일) — 단위: 온습도=비정상 측정 건수, 누수=감지 이벤트 건수 // 경보 종류 도넛 (30일) — 단위: 온습도/미세먼지=비정상 측정 건수, 누수=감지 이벤트 건수
// (metric_status 는 계통별로 고유 값이라 온습도 vs 미세먼지가 자연히 분리된다.)
$mstatus_map = $db->query(" $mstatus_map = $db->query("
SELECT metric_status, COUNT(*) c SELECT metric_status, COUNT(*) c
FROM leak_sensor_metric FROM leak_sensor_metric
@ -596,9 +807,11 @@ function load_tab_trend(PDO $db, array $g): array {
SELECT COUNT(*) FROM leak_sensor_log SELECT COUNT(*) FROM leak_sensor_log
WHERE event_type = 'leak_alert' AND sensor_time >= DATE_SUB(NOW(), INTERVAL 30 DAY) WHERE event_type = 'leak_alert' AND sensor_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
")->fetchColumn(); ")->fetchColumn();
$pm_30d = (int)($mstatus_map['high_pm25'] ?? 0) + (int)($mstatus_map['high_pm10'] ?? 0);
$donut_segs = [ $donut_segs = [
['label' => '고온', 'val' => (int)($mstatus_map['high_temp'] ?? 0), 'cat' => 'hot'], ['label' => '고온', 'val' => (int)($mstatus_map['high_temp'] ?? 0), 'cat' => 'hot'],
['label' => '고습', 'val' => (int)($mstatus_map['high_humidity'] ?? 0), 'cat' => 'humid'], ['label' => '고습', 'val' => (int)($mstatus_map['high_humidity'] ?? 0), 'cat' => 'humid'],
['label' => '미세먼지', 'val' => $pm_30d, 'cat' => 'pm'],
['label' => '누수', 'val' => $leak_alert_30d, 'cat' => 'leak'], ['label' => '누수', 'val' => $leak_alert_30d, 'cat' => 'leak'],
['label' => '저온', 'val' => (int)($mstatus_map['low_temp'] ?? 0), 'cat' => 'cold'], ['label' => '저온', 'val' => (int)($mstatus_map['low_temp'] ?? 0), 'cat' => 'cold'],
['label' => '저습', 'val' => (int)($mstatus_map['low_humidity'] ?? 0), 'cat' => 'dry'], ['label' => '저습', 'val' => (int)($mstatus_map['low_humidity'] ?? 0), 'cat' => 'dry'],
@ -673,11 +886,12 @@ function load_tab_history(PDO $db, array $g): array {
} }
} }
// 최근 온습도 측정 30건 // 최근 온습도 측정 30건 — SHT30 행만(미세먼지 PMS7003 행은 아래 별도 표에서).
$t['recent_metrics'] = $db->query(" $t['recent_metrics'] = $db->query("
SELECT sensor_id, sensor_name, device_location, SELECT sensor_id, sensor_name, device_location,
temperature_c, humidity_percent, metric_status, measured_at temperature_c, humidity_percent, metric_status, measured_at
FROM leak_sensor_metric FROM leak_sensor_metric
WHERE metric_type = 'sht30' OR metric_type = ''
ORDER BY id DESC ORDER BY id DESC
LIMIT 30 LIMIT 30
")->fetchAll(); ")->fetchAll();
@ -686,6 +900,20 @@ function load_tab_history(PDO $db, array $g): array {
} }
unset($_r); unset($_r);
// 최근 미세먼지 측정 30건 — PMS7003 행만(PM1.0/PM2.5/PM10 µg/m³ + 임계 상태).
$t['recent_pm'] = $db->query("
SELECT sensor_id, sensor_name, device_location,
pm1_0, pm2_5, pm10, metric_status, measured_at
FROM leak_sensor_metric
WHERE metric_type = 'pms7003'
ORDER BY id DESC
LIMIT 30
")->fetchAll();
foreach ($t['recent_pm'] as &$_r) {
[$_r['device_location'], $_r['sensor_name']] = sensor_label((int)$_r['sensor_id'], $_r['device_location'] ?? null, $_r['sensor_name'] ?? null);
}
unset($_r);
// 센서 이벤트 로그 30건 // 센서 이벤트 로그 30건
$t['logs'] = $db->query(" $t['logs'] = $db->query("
SELECT id, sensor_id, sensor_name, device_location, event_type, is_leak, sensor_time SELECT id, sensor_id, sensor_name, device_location, event_type, is_leak, sensor_time
@ -732,11 +960,13 @@ function load_tab_history(PDO $db, array $g): array {
FROM leak_sms_log FROM leak_sms_log
WHERE sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) WHERE sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
")->fetch(); ")->fetch();
// 온습도 경보 SMS 합계엔 미세먼지(high_pm)를 넣지 않는다 — 별도 타일로 노출(계통 분리).
$alert_total = (int)($row['high_temp'] ?? 0) + (int)($row['low_temp'] ?? 0) $alert_total = (int)($row['high_temp'] ?? 0) + (int)($row['low_temp'] ?? 0)
+ (int)($row['high_humidity'] ?? 0) + (int)($row['low_humidity'] ?? 0); + (int)($row['high_humidity'] ?? 0) + (int)($row['low_humidity'] ?? 0);
$t['sms_stats'] = [ $t['sms_stats'] = [
'total' => (int)($row['total'] ?? 0), 'total' => (int)($row['total'] ?? 0),
'alert_total' => $alert_total, 'alert_total' => $alert_total,
'high_pm' => (int)($row['high_pm'] ?? 0),
'leak_alert' => (int)($row['leak_alert'] ?? 0), 'leak_alert' => (int)($row['leak_alert'] ?? 0),
'recovery' => (int)($row['recovery'] ?? 0) + (int)($row['leak_recovery'] ?? 0) + (int)($row['device_online'] ?? 0), 'recovery' => (int)($row['recovery'] ?? 0) + (int)($row['leak_recovery'] ?? 0) + (int)($row['device_online'] ?? 0),
'offline' => (int)($row['device_offline'] ?? 0), 'offline' => (int)($row['device_offline'] ?? 0),

View file

@ -9,7 +9,7 @@ if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
<div class="zone-label"><b>운영 이력</b> <span class="en">History</span></div> <div class="zone-label"><b>운영 이력</b> <span class="en">History</span></div>
<!-- 누수 사고 이력 + 사진 --> <!-- 누수 사고 이력 + 사진 -->
<h2>누수 사고 이력 (최근 20건)</h2> <h2 class="table-title">누수 사고 이력 <span class="h2-sub">최근 20건</span></h2>
<details class="mobile-detail" id="d-incidents" data-mobile-collapsed open> <details class="mobile-detail" id="d-incidents" data-mobile-collapsed open>
<summary>누수 사고 이력 <span>최근 20건</span></summary> <summary>누수 사고 이력 <span>최근 20건</span></summary>
<div class="table-wrap"> <div class="table-wrap">
@ -64,7 +64,7 @@ if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
</details> </details>
<!-- 최근 온습도 측정 이력 --> <!-- 최근 온습도 측정 이력 -->
<h2 id="metrics-section">최근 온습도 측정 (30건)</h2> <h2 id="metrics-section" class="table-title">최근 온습도 측정 <span class="h2-sub">30건 기준</span></h2>
<details class="mobile-detail" id="d-metrics" data-mobile-collapsed open> <details class="mobile-detail" id="d-metrics" data-mobile-collapsed open>
<summary>최근 온습도 측정 <span>30건 기준</span></summary> <summary>최근 온습도 측정 <span>30건 기준</span></summary>
<div class="table-wrap"> <div class="table-wrap">
@ -97,8 +97,43 @@ if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
</div> </div>
</details> </details>
<!-- 최근 미세먼지 측정 이력 (PMS7003 — 온습도와 별도 계통) -->
<h2 id="pm-section" class="table-title">최근 미세먼지 측정 <span class="h2-sub">30건 기준</span></h2>
<details class="mobile-detail" id="d-pm" data-mobile-collapsed open>
<summary>최근 미세먼지 측정 <span>30건 기준</span></summary>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>측정시각</th><th>센서</th><th class="col-hide-xs">위치</th>
<th class="col-hide-xs">PM1.0</th><th>PM2.5</th><th>PM10</th><th>상태</th>
</tr>
</thead>
<tbody>
<?php foreach (($recent_pm ?? []) as $row):
$status_code = $row['metric_status'] ?? 'normal';
$is_alert = $status_code !== 'normal';
?>
<tr class="<?= $is_alert ? 'alert-row' : '' ?>">
<td><?= htmlspecialchars($row['measured_at']) ?></td>
<td><?= htmlspecialchars($row['sensor_name']) ?></td>
<td class="col-hide-xs"><?= htmlspecialchars($row['device_location']) ?></td>
<td class="col-hide-xs"><?= $row['pm1_0'] !== null ? (int)$row['pm1_0'] . 'µg/m³' : '-' ?></td>
<td><?= $row['pm2_5'] !== null ? (int)$row['pm2_5'] . 'µg/m³' : '-' ?></td>
<td><?= $row['pm10'] !== null ? (int)$row['pm10'] . 'µg/m³' : '-' ?></td>
<td><span class="pill <?= htmlspecialchars($status_code) ?>"><?= htmlspecialchars(metric_status_label($status_code)) ?></span></td>
</tr>
<?php endforeach; ?>
<?php if (empty($recent_pm)): ?>
<tr><td colspan="7" class="empty-cell">아직 미세먼지 측정 데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</details>
<!-- 센서 이벤트 로그 (startup/periodic/leak_alert/leak_recovery) --> <!-- 센서 이벤트 로그 (startup/periodic/leak_alert/leak_recovery) -->
<h2 id="logs-section">센서 이벤트 로그 (30건)</h2> <h2 id="logs-section" class="table-title">센서 이벤트 로그 <span class="h2-sub">30건 기준</span></h2>
<details class="mobile-detail" id="d-logs" data-mobile-collapsed open> <details class="mobile-detail" id="d-logs" data-mobile-collapsed open>
<summary>센서 이벤트 로그 <span>30건 기준</span></summary> <summary>센서 이벤트 로그 <span>30건 기준</span></summary>
<div class="table-wrap"> <div class="table-wrap">
@ -142,7 +177,7 @@ if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
</details> </details>
<!-- SMS 발송 로그 --> <!-- SMS 발송 로그 -->
<h2 id="sms-section">SMS 발송 이력 (최근 20건)</h2> <h2 id="sms-section" class="table-title">SMS 발송 이력 <span class="h2-sub">최근 20건</span></h2>
<details class="mobile-detail" id="d-sms" data-mobile-collapsed open> <details class="mobile-detail" id="d-sms" data-mobile-collapsed open>
<summary>SMS 발송 이력 <span>최근 20건</span></summary> <summary>SMS 발송 이력 <span>최근 20건</span></summary>
<div class="table-wrap"> <div class="table-wrap">
@ -212,6 +247,10 @@ if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
<span>온습도 경보 SMS</span> <span>온습도 경보 SMS</span>
<strong class="<?= $sms_stats['alert_total'] > 0 ? 'warn-color' : '' ?>"><?= (int)$sms_stats['alert_total'] ?>건</strong> <strong class="<?= $sms_stats['alert_total'] > 0 ? 'warn-color' : '' ?>"><?= (int)$sms_stats['alert_total'] ?>건</strong>
</div> </div>
<div class="report-stat">
<span>미세먼지 경보 SMS</span>
<strong class="<?= ($sms_stats['high_pm'] ?? 0) > 0 ? 'warn-color' : '' ?>"><?= (int)($sms_stats['high_pm'] ?? 0) ?>건</strong>
</div>
<div class="report-stat"> <div class="report-stat">
<span>누수감지 SMS</span> <span>누수감지 SMS</span>
<strong class="<?= $sms_stats['leak_alert'] > 0 ? 'err-color' : '' ?>"><?= (int)$sms_stats['leak_alert'] ?>건</strong> <strong class="<?= $sms_stats['leak_alert'] > 0 ? 'err-color' : '' ?>"><?= (int)$sms_stats['leak_alert'] ?>건</strong>

View file

@ -1,15 +1,25 @@
<?php <?php
// tabs/live.php — [실시간 현황] 탭. dashboard.php 셸에서 include 된다. // tabs/live.php — [실시간 현황] 탭. dashboard.php 셸에서 include 된다.
// 사용 변수: load_global_data() 결과(extract) — $metric_statuses, $leak_statuses, // 사용 변수: load_global_data() 결과 — $metric_statuses, $leak_statuses,
// $unrecovered_incidents, $forecasts, $sms_* , $today_* 등. // $unrecovered_incidents, $forecasts, $sms_*, $today_* 등
// + load_tab_live() 결과 — $live_sparks, $live_leak_meta.
if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단 if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
// 온습도·누수 센서를 하나의 그리드로 통합한다(센서 수가 적어도 한 행을 균등하게 // ── 센서 id 별 결합 카드 구성 ────────────────────────────────────────────────
// 채우도록 — 섹션마다 카드가 1개씩 흩어지는 것 방지). sensor_id 순서, 유형은 칩으로 구분. // 1·3번(누수+온습도 결합 노드)은 한 장의 카드에 두 계통을 함께 표시한다.
// (구버전은 metric 카드가 leak 카드를 가려 누수 상태가 실시간 화면에서 빠졌음)
$sensor_cards = []; $sensor_cards = [];
foreach ($metric_statuses as $sid => $s) { $sensor_cards[(int)$sid] = ['type' => 'metric', 's' => $s]; } foreach ($metric_statuses as $sid => $s) {
$sensor_cards[(int)$sid] = ['metric' => true, 'leak' => false, 's' => $s];
}
foreach ($leak_statuses as $sid => $s) { foreach ($leak_statuses as $sid => $s) {
if (!isset($sensor_cards[(int)$sid])) { $sensor_cards[(int)$sid] = ['type' => 'leak', 's' => $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); ksort($sensor_cards);
?> ?>
@ -19,88 +29,181 @@ ksort($sensor_cards);
<h2 id="sensors-section">센서 현황 <span class="h2-sub">· <?= count($sensor_cards) ?>개소</span></h2> <h2 id="sensors-section">센서 현황 <span class="h2-sub">· <?= count($sensor_cards) ?>개소</span></h2>
<div class="sensor-grid" id="leak-section"> <div class="sensor-grid" id="leak-section">
<?php foreach ($sensor_cards as $sid => $card): <?php foreach ($sensor_cards as $sid => $card):
$type = $card['type']; $is_metric = $card['metric'];
$is_leak_node = $card['leak'];
$s = $card['s']; $s = $card['s'];
$type_label = $type === 'metric' ? '온습도' : '누수'; // 보고 주기 표기 — 누수 노드(결합)는 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 // ── 미수신 placeholder ──
if ($s === null): ?> if ($s === null): ?>
<div class="sensor-card"> <div class="sensor-card offline">
<div class="sc-head"> <div class="sc-head">
<strong><?= (int)$sid ?>번 센서</strong> <strong><?= (int)$sid ?>번 센서</strong>
<span class="sc-type <?= $type ?>"><?= $type_label ?></span> <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>
<div class="badge-row"><span class="badge offline">데이터 없음</span></div> <div class="status-line off"><span class="sdot"></span>데이터 없음</div>
<div class="metric-time">아직 수신된 <?= $type_label ?> 데이터가 없습니다.</div> <div class="metric-time">아직 수신된 데이터가 없습니다.</div>
<div class="sc-foot"><span>수신 대기</span><span><?= $cycle_label ?></span></div>
</div> </div>
<?php continue; endif; <?php continue; endif;
// 상태 우선순위: 오프라인 > (온습도 경보 | 누수) > 정상 // ── 상태 판정: 오프라인 > 누수 확정 > 확인중(디바운스) > 미세먼지 경보 > 온습도 경보 > 정상 ──
if ($type === 'metric') { $metric_code = $s['metric_status'] ?? 'normal';
$status_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']) { if ($s['is_offline']) {
$card_class = 'offline'; $badge_class = 'offline'; $card_class = 'offline'; $line_class = 'off';
$badge_text = $s['is_alerting'] ? ('오프라인 (' . metric_status_label($status_code) . ' 중 끊김)') : '오프라인'; $line_text = '오프라인';
} elseif ($s['is_alerting']) { if ($is_leaking) { $line_text .= ' (누수 중 끊김)'; }
$card_class = 'alert'; $badge_class = 'alert'; elseif ($has_pm_alert) { $line_text .= ' (' . metric_status_label($pm_code) . ' 중 끊김)'; }
$badge_text = metric_status_label($status_code) . ' 경보!'; elseif ($has_m_alert) { $line_text .= ' (' . metric_status_label($metric_code) . ' 중 끊김)'; }
} else {
$card_class = ''; $badge_class = 'ok'; $badge_text = '정상';
}
} else {
// 디바운스 확정 상태 기준(SMS 와 일치). leak_pending: 확인창 대기 중(블립 평가).
$is_leaking = (int)($s['leak_confirmed'] ?? $s['is_leak'] ?? 0) === 1;
$is_pending = !empty($s['leak_pending']);
if ($s['is_offline']) {
$card_class = 'offline'; $badge_class = 'offline';
$badge_text = $is_leaking ? '오프라인 (누수 중 끊김)' : '오프라인';
} elseif ($is_leaking) { } elseif ($is_leaking) {
$card_class = 'leak'; $badge_class = 'leak'; $badge_text = '누수'; $card_class = 'leak'; $line_class = 'leakst';
$line_text = '누수 감지';
} elseif ($is_pending) { } elseif ($is_pending) {
// raw 감지됐으나 확인창 미충족 → 확정 전(SMS 아직 안 나감)임을 명시 $card_class = 'pending'; $line_class = 'pend';
$card_class = 'pending'; $badge_class = 'pending'; $badge_text = '확인중(디바운스)'; $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 { } else {
$card_class = ''; $badge_class = 'ok'; $badge_text = '정상'; $card_class = ''; $line_class = 'ok';
} $line_text = $is_leak_node ? '정상 (건조)' : '정상';
} }
?> ?>
<div class="sensor-card <?= $type === 'leak' ? 'leak-status-card ' : '' ?><?= $card_class ?>"> <div class="sensor-card <?= $card_class ?>">
<div class="sc-head"> <div class="sc-head">
<strong><?= htmlspecialchars($s['sensor_name']) ?></strong> <strong><?= htmlspecialchars($s['sensor_name']) ?></strong>
<span class="sc-type <?= $type ?>"><?= $type_label ?></span> <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>
<small><?= htmlspecialchars($s['device_location']) ?> / <?= htmlspecialchars($s['device_id']) ?></small> <small><?= htmlspecialchars($s['device_location']) ?> / <?= htmlspecialchars($s['device_id']) ?></small>
<div class="badge-row"> <div class="status-line <?= $line_class ?>"><span class="sdot"></span><?= htmlspecialchars($line_text) ?></div>
<span class="badge <?= $badge_class ?>"><?= htmlspecialchars($badge_text) ?></span>
</div>
<?php if ($type === 'metric'): <?php
$status_code = $s['metric_status'] ?? 'normal'; // ── 누수 행: 확정 누수 시 경과·SMS·사진 (결합/단독 공통) ──
$temp_chip_class = ''; $hum_chip_class = ''; if ($is_leaking):
if ($status_code === 'high_temp') { $temp_chip_class = 'hot'; } $lm = $live_leak_meta[$sid] ?? null; ?>
elseif ($status_code === 'low_temp') { $temp_chip_class = 'cold'; } <div class="leak-row">
elseif ($status_code === 'high_humidity') { $hum_chip_class = 'humid'; } <?php if ($lm): ?>
elseif ($status_code === 'low_humidity') { $hum_chip_class = 'dry'; } <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'])): if (!empty($s['latest_metric'])):
$metric = $s['latest_metric']; ?> $metric = $s['latest_metric']; ?>
<div class="metric-line"> <div class="vals">
<span class="metric-chip <?= $temp_chip_class ?>">온도 <?= htmlspecialchars(metric_display($metric['temperature_c'], '°C')) ?></span> <div class="v temp">
<span class="metric-chip <?= $hum_chip_class ?>">습도 <?= htmlspecialchars(metric_display($metric['humidity_percent'], '%')) ?></span> <div class="n <?= $temp_cls ?>"><?= htmlspecialchars(metric_display($metric['temperature_c'], '')) ?><small>°C</small></div>
<div class="l">온도</div>
</div> </div>
<div class="metric-time">측정: <?= htmlspecialchars($metric['measured_at']) ?></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: ?> <?php else: ?>
<div class="metric-time">측정값 없음</div> <div class="metric-time">측정값 없음</div>
<?php endif; ?> <?php endif; ?>
<?php endif; ?> <?php endif; ?>
<div class="metric-time<?= $s['is_offline'] ? ' stale' : '' ?>"> <?php
최종확인: <?= htmlspecialchars($s['last_seen']) ?><?php if ($s['is_offline']): ?> (<?= $s['offline_elapsed_min'] ?>분 전)<?php endif; ?> // ── 미세먼지 블록: 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>
<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 <?php
// 온습도 카드 예보줄: 온라인 + 예보 있으면 임계 여유 상시 표시(over>near>safe) // ── 예보 칩: 온라인 + 예보 있으면 임계 여유 상시 표시(over>near>safe) ──
if ($type === 'metric'): if ($is_metric):
$fc = (!$s['is_offline'] && isset($forecasts[$sid])) ? $forecasts[$sid] : null; $fc = (!$s['is_offline'] && isset($forecasts[$sid])) ? $forecasts[$sid] : null;
if ($fc): if ($fc):
$chips = []; $chips = [];
@ -128,6 +231,19 @@ ksort($sensor_cards);
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php endif; endif; ?> <?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> </div>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($sensor_cards)): ?> <?php if (empty($sensor_cards)): ?>

View file

@ -21,6 +21,13 @@ if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
<span><i class="legend-swatch temp"></i> 온도(°C)</span> <span><i class="legend-swatch temp"></i> 온도(°C)</span>
<span><i class="legend-swatch hum"></i> 습도(%)</span> <span><i class="legend-swatch hum"></i> 습도(%)</span>
</div> </div>
<?php if (count($chart_sensor_options ?? []) > 1): ?>
<div class="range-toggle" role="group" aria-label="추이 센서 선택">
<?php foreach ($chart_sensor_options as $opt): ?>
<a href="<?= htmlspecialchars(dash_url(['sensor' => $opt['id']], 'chart-section')) ?>"<?= $chart_sensor_id === $opt['id'] ? ' aria-current="true"' : '' ?>><?= htmlspecialchars($opt['name'] !== '' ? $opt['name'] : ('센서 ' . $opt['id'])) ?></a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="range-toggle" role="group" aria-label="추이 기간 선택"> <div class="range-toggle" role="group" aria-label="추이 기간 선택">
<?php foreach (['today' => '오늘', '3d' => '3일', '7d' => '7일'] as $rk => $rlabel): ?> <?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> <a href="<?= htmlspecialchars(dash_url(['range' => $rk], 'chart-section')) ?>"<?= $chart_range === $rk ? ' aria-current="true"' : '' ?>><?= htmlspecialchars($rlabel) ?></a>
@ -208,6 +215,122 @@ if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
<?php endif; ?> <?php endif; ?>
</div> </div>
<!-- 미세먼지 추이 차트 (PM2.5/PM10 µg/m³ — 온습도와 별도 스케일; PM 센서에서만 표시) -->
<?php if (!empty($chart_pm_points)): ?>
<h2 id="pm-chart-section">미세먼지 추이
<span class="h2-sub">· <?= htmlspecialchars($chart_sensor_name !== '' ? $chart_sensor_name : ('센서 ' . $chart_sensor_id)) ?> 최근 <?= count($chart_pm_points) ?>개</span>
</h2>
<div class="chart-panel">
<div class="chart-head">
<div class="chart-legend" style="margin:0">
<span><i class="legend-swatch pm25"></i> PM2.5(µg/m³)</span>
<span><i class="legend-swatch pm10"></i> PM10(µg/m³)</span>
</div>
</div>
<?php if (count($chart_pm_points) >= 2): ?>
<div class="chart-svg-wrap">
<?php
// ── 인라인 SVG (PM2.5/PM10 2계열, 공통 µg/m³ 스케일) ──────────
$pmc_w = 920; $pmc_h = 240;
$pmp_l = 44; $pmp_r = 20; $pmp_t = 16; $pmp_b = 34;
$pmplot_w = $pmc_w - $pmp_l - $pmp_r;
$pmplot_h = $pmc_h - $pmp_t - $pmp_b;
$pmn = count($chart_pm_points);
// 스케일: 0..(측정 최댓값·임계값 중 큰 값 + 여유). 임계선이 항상 보이게 임계값 포함.
$pm_all = [(float)METRIC_PM25_HIGH, (float)METRIC_PM10_HIGH];
foreach ($chart_pm_points as $p) {
if ($p['pm25'] !== null) { $pm_all[] = (float)$p['pm25']; }
if ($p['pm10'] !== null) { $pm_all[] = (float)$p['pm10']; }
}
$pm_min = 0.0;
$pm_max = max($pm_all) * 1.1;
if ($pm_max <= 0) { $pm_max = 1.0; }
$pmx = function (int $i) use ($pmp_l, $pmplot_w, $pmn): float {
if ($pmn <= 1) { return $pmp_l; }
return $pmp_l + ($pmplot_w * $i / ($pmn - 1));
};
$pmy = function (?float $v) use ($pmp_t, $pmplot_h, $pm_min, $pm_max): ?float {
if ($v === null) { return null; }
$r = ($pm_max - $pm_min) ?: 1;
return $pmp_t + $pmplot_h * (1 - (($v - $pm_min) / $r));
};
$pm_build = function (string $key) use ($chart_pm_points, $pmx, $pmy): array {
$segments = []; $cur = [];
foreach ($chart_pm_points as $i => $p) {
$val = $p[$key];
if ($val === null) { if (count($cur) >= 2) { $segments[] = $cur; } $cur = []; continue; }
$cur[] = sprintf('%.1f,%.1f', $pmx($i), $pmy((float)$val));
}
if (count($cur) >= 2) { $segments[] = $cur; }
return $segments;
};
$pm25_segs = $pm_build('pm25');
$pm10_segs = $pm_build('pm10');
$pm_fmt = fn($v) => rtrim(rtrim(number_format($v, 1, '.', ''), '0'), '.');
?>
<svg viewBox="0 0 <?= $pmc_w ?> <?= $pmc_h ?>" width="100%" height="<?= $pmc_h ?>"
preserveAspectRatio="xMidYMid meet" role="img"
aria-label="미세먼지 추이 라인 차트" class="chart-svg">
<rect x="<?= $pmp_l ?>" y="<?= $pmp_t ?>" width="<?= $pmplot_w ?>" height="<?= $pmplot_h ?>" class="plot-bg"></rect>
<?php for ($g = 0; $g <= 4; $g++):
$gy = $pmp_t + $pmplot_h * $g / 4;
$gv = $pm_max - ($pm_max - $pm_min) * $g / 4; ?>
<line x1="<?= $pmp_l ?>" y1="<?= sprintf('%.1f', $gy) ?>"
x2="<?= $pmp_l + $pmplot_w ?>" y2="<?= sprintf('%.1f', $gy) ?>"
class="grid-line" stroke-dasharray="3,3"></line>
<text x="<?= $pmp_l - 6 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>"
text-anchor="end" font-size="10" class="axis-pm"><?= htmlspecialchars($pm_fmt($gv)) ?></text>
<?php endfor; ?>
<!-- X축 시간 라벨 (시작/중간/끝) -->
<?php
$pm_lbl_idx = $pmn > 1 ? [0, intdiv($pmn - 1, 2), $pmn - 1] : [0];
foreach ($pm_lbl_idx as $li):
$anchor = $li === 0 ? 'start' : ($li === $pmn - 1 ? 'end' : 'middle');
$tlabel = substr($chart_pm_points[$li]['t'], 5, 11); ?>
<text x="<?= sprintf('%.1f', $pmx($li)) ?>" y="<?= $pmc_h - 12 ?>"
text-anchor="<?= $anchor ?>" font-size="10" class="axis-x"><?= htmlspecialchars($tlabel) ?></text>
<?php endforeach; ?>
<!-- 임계선 오버레이 (PM2.5/PM10) -->
<?php
$pm_thr = [
[(float)METRIC_PM25_HIGH, 'PM2.5', 'line-pm25'],
[(float)METRIC_PM10_HIGH, 'PM10', 'line-pm10'],
];
foreach ($pm_thr as [$cval, $lbl, $cls]):
$ty = $pmy($cval);
if ($ty === null || $ty < $pmp_t || $ty > $pmp_t + $pmplot_h) { continue; } ?>
<line x1="<?= $pmp_l ?>" y1="<?= sprintf('%.1f', $ty) ?>"
x2="<?= $pmp_l + $pmplot_w ?>" y2="<?= sprintf('%.1f', $ty) ?>"
class="<?= $cls ?>" stroke-width="1" stroke-dasharray="2,4" opacity="0.85"></line>
<text x="<?= $pmp_l + 4 ?>" y="<?= sprintf('%.1f', $ty - 3) ?>"
text-anchor="start" font-size="9" class="axis-pm"><?= htmlspecialchars($lbl . ' ' . $pm_fmt($cval) . 'µg/m³') ?></text>
<?php endforeach; ?>
<!-- PM10 라인 (파선) -->
<?php foreach ($pm10_segs as $seg): ?>
<polyline points="<?= htmlspecialchars(implode(' ', $seg)) ?>"
class="line-pm10" fill="none" stroke-width="1.8" stroke-dasharray="6,3"
stroke-linejoin="round" stroke-linecap="round"></polyline>
<?php endforeach; ?>
<!-- PM2.5 라인 -->
<?php foreach ($pm25_segs as $seg): ?>
<polyline points="<?= htmlspecialchars(implode(' ', $seg)) ?>"
class="line-pm25" fill="none" stroke-width="1.8"
stroke-linejoin="round" stroke-linecap="round"></polyline>
<?php endforeach; ?>
</svg>
</div>
<p class="caption donut-note">임계 — PM2.5 <?= htmlspecialchars($pm_fmt((float)METRIC_PM25_HIGH)) ?> · PM10 <?= htmlspecialchars($pm_fmt((float)METRIC_PM10_HIGH)) ?> µg/m³ 초과 시 경보</p>
<?php else: ?>
<div class="chart-empty">미세먼지 추이를 표시할 측정 데이터가 충분하지 않습니다 (2개 이상 필요).</div>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- 통계 위젯 (7일/30일 — 시간축 라벨을 각 타일 부제에 명기) --> <!-- 통계 위젯 (7일/30일 — 시간축 라벨을 각 타일 부제에 명기) -->
<h2>경보·가동 통계</h2> <h2>경보·가동 통계</h2>
<div class="overview trend-widgets"> <div class="overview trend-widgets">
@ -217,6 +340,7 @@ if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
<h3>경보 발생 추이 <span class="sub">최근 7일 · 유형별</span></h3> <h3>경보 발생 추이 <span class="sub">최근 7일 · 유형별</span></h3>
<div class="mini-legend"> <div class="mini-legend">
<span><i style="background:var(--warn)"></i>온습도 경보</span> <span><i style="background:var(--warn)"></i>온습도 경보</span>
<span><i style="background:var(--pm)"></i>미세먼지 경보</span>
<span><i style="background:var(--leak)"></i>누수 감지</span> <span><i style="background:var(--leak)"></i>누수 감지</span>
</div> </div>
<?php <?php
@ -240,6 +364,9 @@ if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
<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> <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; ?> <?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_leak)) ?>" class="line-alert" fill="none" stroke-width="2" stroke-dasharray="6,3" stroke-linejoin="round" stroke-linecap="round"></polyline>
<?php if (!empty($trend_pm) && max($trend_pm) > 0): ?>
<polyline points="<?= htmlspecialchars($t_line($trend_pm)) ?>" class="line-pm" fill="none" stroke-width="2" stroke-dasharray="2,3" stroke-linejoin="round" stroke-linecap="round"></polyline>
<?php endif; ?>
<polyline points="<?= htmlspecialchars($t_line($trend_metric)) ?>" class="line-metric" fill="none" stroke-width="2" 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): ?> <?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> <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>
@ -279,7 +406,7 @@ if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
</div> </div>
<p class="caption donut-note">단위 — 온습도: 비정상 측정 건수 · 누수: 감지 이벤트 건수</p> <p class="caption donut-note">단위 — 온습도·미세먼지: 비정상 측정 건수 · 누수: 감지 이벤트 건수</p>
<?php else: ?> <?php else: ?>
<div class="donut-empty">최근 30일 경보 없음</div> <div class="donut-empty">최근 30일 경보 없음</div>
<?php endif; ?> <?php endif; ?>