From 83f7dae6e8e24b9d08292ab757e45f5f6b6b7f0b Mon Sep 17 00:00:00 2001 From: cw2007 Date: Sat, 11 Jul 2026 11:52:31 +0900 Subject: [PATCH] =?UTF-8?q?fix(dashboard):=20=EC=84=BC=EC=84=9C=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EC=A0=95=EB=A0=AC=20=E2=80=94=20=EC=9C=A0?= =?UTF-8?q?=ED=98=95=20=ED=86=B5=ED=95=A9=20=EA=B7=B8=EB=A6=AC=EB=93=9C?= =?UTF-8?q?=EB=A1=9C=20sparse/=EC=A4=84=20=EC=95=88=EB=A7=9E=EC=9D=8C=20?= =?UTF-8?q?=ED=95=B4=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실제 렌더(MockPDO 하네스 + 브라우저 스크린샷)로 확인한 레이아웃 결함 수정: - 온습도/누수 센서를 별도 섹션(각 1~2개 카드)으로 나눠 auto-fill 그리드에 띄우던 것을 단일 '센서 현황' 그리드로 통합. sensor_id 순, 유형은 카드 우상단 칩(온습도/누수)으로 구분. auto-fill→auto-fit 로 바꿔 카드가 행을 균등하게 채우고 높이가 정렬됨(센서 1~2개여도 '덩그러니 빈 행' 사라짐). - 추이·통계 위젯: '경보 최다 센서' 리더보드를 span 12(가로 리더보드)로 바꿔 3타일 다음 행에 타일 하나만 남던 문제 해소. - 센서 카드에 sc-head/sc-type 스타일 추가, 예보줄을 카드 하단 고정(margin-top:auto). app.css 는 기존 규칙 유지 + 추가/조정만(공용 7페이지 영향 없음: .sensor-grid· .trend-widgets·.sc-* 는 대시보드 전용 셀렉터). --- php/assets/app.css | 27 +++++- php/tabs/live.php | 211 ++++++++++++++++++++------------------------- 2 files changed, 116 insertions(+), 122 deletions(-) diff --git a/php/assets/app.css b/php/assets/app.css index 38674d0..c8cb82c 100644 --- a/php/assets/app.css +++ b/php/assets/app.css @@ -87,7 +87,7 @@ header h1::before { content: ""; width: 10px; height: 10px; border-radius: 50%; .ops-grid { display: grid; grid-template-columns: minmax(0,1.5fr) minmax(300px,.9fr); gap: var(--sp-4); align-items: start; margin-bottom: var(--sp-6); } .cards { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: var(--sp-4); margin-bottom: var(--sp-4); } .cards.flush { margin-bottom: var(--sp-6); } -.sensor-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px,1fr)); gap: var(--sp-4); margin-bottom: var(--sp-6); } +.sensor-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px,1fr)); gap: var(--sp-4); margin-bottom: var(--sp-6); } .panel, .card, .chart-panel, .leak-status-card, .empty-note { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg); box-shadow: var(--shadow); } .panel { padding: var(--sp-5); } @@ -569,6 +569,17 @@ tbody tr:hover td { background: var(--surface-2); } /* ── 예보 칩: 여유(safe) 상시 표시용 중립 스타일 ────────────────────────────── */ .forecast-chip.safe { background: var(--surface-2); color: var(--ink-2); } +/* ── 통합 센서 카드: 헤더(이름+유형칩) / 카드 내부 세로 리듬 정렬 ─────────────── */ +.sensor-card { display: flex; flex-direction: column; } +.sc-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); } +.sc-head strong { font-size: 1rem; font-weight: 700; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.sc-type { flex: none; font-size: .68rem; font-weight: 800; letter-spacing: .03em; padding: 2px 8px; border-radius: var(--r-pill); background: var(--surface-2); color: var(--ink-2); border: 1px solid var(--line); } +.sc-type.metric { background: rgba(63,199,230,.13); color: var(--cold-ink); } +.sc-type.leak { background: var(--offline-bg); color: var(--offline-ink); } +.sensor-card small { display: block; margin-top: 2px; } +/* 예보줄을 카드 하단에 고정(카드 높이가 그리드 행 기준으로 늘어나도 자연스럽게) */ +.sensor-card .forecast { margin-top: auto; } + /* ── 범주형 차트 클래스 (도넛 세그먼트 stroke / 범례 스와치 background) ─────── */ .donut-ring circle.seg.cat-hot { stroke: var(--cat-hot); } .donut-ring circle.seg.cat-humid { stroke: var(--cat-humid); } @@ -582,9 +593,19 @@ tbody tr:hover td { background: var(--surface-2); } .dl i.cat-dry { background: var(--cat-dry); } .donut-note { margin-top: var(--sp-2); } -/* ── trend 탭 위젯 그리드 (기존 .overview 12열 위에서 3타일/행 균등 배치) ────── */ +/* ── trend 탭 위젯 그리드 (기존 .overview 12열 위에서 배치) ────────────────── + 1행: 경보 추이·도넛·게이지 각 span 4 (=12). 2행: 리더보드 span 12 (가로 리더보드 + — 타일 하나만 다음 행에 덩그러니 남는 것 방지). */ .trend-widgets > .ov-tile { grid-column: span 4; } -@media (max-width: 1080px) { .trend-widgets > .ov-tile { grid-column: span 6; } } +.trend-widgets > .ov-board { grid-column: span 12; } +.trend-widgets > .ov-board .board { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); } +@media (max-width: 1080px) { + .trend-widgets > .ov-tile { grid-column: span 6; } + .trend-widgets > .ov-board { grid-column: span 12; } +} +@media (max-width: 620px) { + .trend-widgets > .ov-tile, .trend-widgets > .ov-board { grid-column: span 12; } +} /* ── system 탭 패널 그리드 ──────────────────────────────────────────────────── */ .system-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: var(--sp-4); align-items: start; } diff --git a/php/tabs/live.php b/php/tabs/live.php index 81ac7ce..ac1b667 100644 --- a/php/tabs/live.php +++ b/php/tabs/live.php @@ -3,97 +3,117 @@ // 사용 변수: load_global_data() 결과(extract) — $metric_statuses, $leak_statuses, // $unrecovered_incidents, $forecasts, $sms_* , $today_* 등. if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단 + +// 온습도·누수 센서를 하나의 그리드로 통합한다(센서 수가 적어도 한 행을 균등하게 +// 채우도록 — 섹션마다 카드가 1개씩 흩어지는 것 방지). sensor_id 순서, 유형은 칩으로 구분. +$sensor_cards = []; +foreach ($metric_statuses as $sid => $s) { $sensor_cards[(int)$sid] = ['type' => 'metric', 's' => $s]; } +foreach ($leak_statuses as $sid => $s) { + if (!isset($sensor_cards[(int)$sid])) { $sensor_cards[(int)$sid] = ['type' => 'leak', 's' => $s]; } +} +ksort($sensor_cards); ?>
실시간 상태 Live
- -

온습도 센서

-
- $s): - // 미수신 placeholder: 기대 온습도 센서(METRIC_SENSOR_IDS)가 아직 보고 전이면 - // '데이터 없음' 카드로 항상 노출한다(기대 센서가 화면에서 조용히 빠지지 않게). +

센서 현황 · 개소

+
+ $card): + $type = $card['type']; + $s = $card['s']; + $type_label = $type === 'metric' ? '온습도' : '누수'; + + // 미수신 placeholder if ($s === null): ?>
- 번 센서 (온습도)
+
+ 번 센서 + +
데이터 없음
-
아직 수신된 온습도 데이터가 없습니다.
+
아직 수신된 데이터가 없습니다.
임계 경보 > 정상 - $status_code = $s['metric_status'] ?? 'normal'; - if ($s['is_offline']) { - $card_class = 'offline'; - $badge_class = 'offline'; - $badge_text = $s['is_alerting'] ? ('오프라인 (' . metric_status_label($status_code) . ' 중 끊김)') : '오프라인'; - } elseif ($s['is_alerting']) { - $card_class = 'alert'; - $badge_class = 'alert'; - $badge_text = metric_status_label($status_code) . ' 경보!'; - } else { - $card_class = ''; - $badge_class = 'ok'; - $badge_text = '정상'; - } - $temp_chip_class = ''; - $hum_chip_class = ''; - if ($status_code === 'high_temp') { $temp_chip_class = 'hot'; } - elseif ($status_code === 'low_temp') { $temp_chip_class = 'cold'; } - elseif ($status_code === 'high_humidity') { $hum_chip_class = 'humid'; } - elseif ($status_code === 'low_humidity') { $hum_chip_class = 'dry'; } + // 상태 우선순위: 오프라인 > (온습도 경보 | 누수) > 정상 + if ($type === 'metric') { + $status_code = $s['metric_status'] ?? 'normal'; + if ($s['is_offline']) { + $card_class = 'offline'; $badge_class = 'offline'; + $badge_text = $s['is_alerting'] ? ('오프라인 (' . metric_status_label($status_code) . ' 중 끊김)') : '오프라인'; + } elseif ($s['is_alerting']) { + $card_class = 'alert'; $badge_class = 'alert'; + $badge_text = metric_status_label($status_code) . ' 경보!'; + } else { + $card_class = ''; $badge_class = 'ok'; $badge_text = '정상'; + } + } else { + $is_leaking = (int)($s['is_leak'] ?? 0) === 1; + if ($s['is_offline']) { + $card_class = 'offline'; $badge_class = 'offline'; + $badge_text = $is_leaking ? '오프라인 (누수 중 끊김)' : '오프라인'; + } elseif ($is_leaking) { + $card_class = 'leak'; $badge_class = 'leak'; $badge_text = '누수'; + } else { + $card_class = ''; $badge_class = 'ok'; $badge_text = '정상'; + } + } ?> -
-
- /
-
- - - +
+
+ +
- + / +
+ +
+ +
- 온도 - 습도 + 온도 + 습도
측정:
측정값 없음
+ +
- 최종확인: - - (분 전) - + 최종확인: (분 전)
+ $h['state'], 'text' => $h['label'] . ' 여유 ' . $h['margin'] . $h['unit']]; - } - foreach (['temp', 'hum'] as $vk) { - $e = $fc['trend'][$vk]['eta'] ?? null; - if ($e) { $chips[] = ['state' => 'near', 'text' => '↗ ~' . (int)$e['min'] . '분 뒤 ' . metric_status_label($e['key'])]; } - } - foreach ($fc['rate'] as $r) { - $chips[] = ['state' => 'near', 'text' => ($r['spike'] === 'rise' ? '급상승 ' : '급락 ') . ($r['per10'] > 0 ? '+' : '') . $r['per10'] . '/10분']; - } - if (!empty($fc['dew']['risk'])) { - $chips[] = ['state' => 'near', 'text' => '결로주의 (마진 ' . $fc['dew']['margin'] . '℃)']; - } - // 위험한 것 먼저: over > near > safe - $chip_rank = ['over' => 0, 'near' => 1, 'safe' => 2]; - usort($chips, fn($a, $b) => ($chip_rank[$a['state']] ?? 3) <=> ($chip_rank[$b['state']] ?? 3)); + // 온습도 카드 예보줄: 온라인 + 예보 있으면 임계 여유 상시 표시(over>near>safe) + if ($type === '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)); ?>
@@ -102,61 +122,14 @@ if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단 예보: 임계 여유 충분
- +
- +

아직 수신된 센서 데이터가 없습니다.

- -

누수 감지

-
- $leak_status): - // 누수 센서 상태 표시: 오프라인 > 누수 > 정상 우선순위 - if ($leak_status === null) { - $leak_card_class = ''; - $leak_badge_class = 'offline'; - $leak_badge_text = '데이터 없음'; - } elseif ($leak_status['is_offline']) { - $leak_card_class = 'offline'; - $leak_badge_class = 'offline'; - $leak_badge_text = ((int)($leak_status['is_leak'] ?? 0) === 1) ? '오프라인 (누수 중 끊김)' : '오프라인'; - } elseif ((int)($leak_status['is_leak'] ?? 0) === 1) { - $leak_card_class = 'leak'; - $leak_badge_class = 'leak'; - $leak_badge_text = '누수'; - } else { - $leak_card_class = ''; - $leak_badge_class = 'ok'; - $leak_badge_text = '정상'; - } - ?> -
- - 번 센서 (누수)
-
- -
-
아직 수신된 누수 센서 데이터가 없습니다.
- -
- /
-
- -
-
- 최종확인: - - (분 전) - -
- -
- -
-

미복구 누수 사고 · 건