POSA_LEAKSMS/php/dashboard.php
cw2007 e392d6475b feat: 대시보드 관제 개요 벤토 재편 + 샘플 대시보드 콘텐츠 위젯 5종
샘플 대시보드(고객지원/방문분석) 콘텐츠를 누수·온습도 도메인으로 이식하고,
평면 나열이던 섹션을 관계·위치가 드러나는 4개 라벨 존으로 재구성한다.

콘텐츠 위젯(전부 기존 DB 집계, 외부 라이브러리 0 · 인라인 SVG · 폐쇄망 안전):
- 가동률 게이지 + 7일 경보 스파크라인 + 상태 배지
- 듀얼 KPI(경보 센서 / 오프라인)
- 경보 종류 도넛(30일, $summary 재사용)
- 경보 최다 센서 리더보드(30일 GROUP BY)
- 경보 발생 vs 복구 7일 이벤트율 라인차트
- 온습도 추이: 기간 토글(오늘/3일/7일, ?range 링크) + 피크 마커

정보구조:
- 4개 존(관제 개요 / 실시간 상태 / 추세 분석 / 운영 이력) — eyebrow 라벨 + 하드라인
- 관제 개요 = 12칼럼 벤토 그리드(기존 ops-grid 대체, 자가진단 편입)
- 중복 카드 제거(등록센서/최근온습도/현재시각 → 게이지·히어로·요약 흡수)

검증: php -l 통과, 스텁DB 전체 렌더 exit 0(경고 0)·태그 균형·빈데이터 우아,
채운데이터 프리뷰 스크린샷 확인. 신규 CSS는 네임스페이스 셀렉터만(타 페이지 영향 0).
2026-07-06 10:58:39 +09:00

1546 lines
75 KiB
PHP

<?php
// =============================================================================
// dashboard.php - SHT30 온습도 모니터링 대시보드
// cafe24 웹호스팅에서 직접 브라우저로 접근하는 관리 페이지
//
// 온습도 임계 경보(고온/저온/고습/저습)와 장비 오프라인/복구를 모니터링한다.
// 임계 판정과 SMS
// 발송은 서버(api/sensor_data.php + config.php METRIC_* 상수)에서 수행하고,
// 이 대시보드는 그 결과(leak_sensor_metric/leak_sensor_status/leak_sms_log)를 표시한다.
// =============================================================================
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/admin_security.php';
require_once __DIR__ . '/cron_heartbeat.php';
require_once __DIR__ . '/ops_checks.php';
// ── 세션 인증 ─────────────────────────────────────────────
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_secure', 1);
ini_set('session.cookie_samesite', 'Strict');
ini_set('session.use_strict_mode', 1);
session_start();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// 로그아웃 처리
if (isset($_GET['logout'])) {
admin_audit_log('logout', $_SESSION['username'] ?? ADMIN_USER, true);
session_destroy();
header('Location: login.php');
exit;
}
// 세션 확인
if (empty($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
header('Location: login.php');
exit;
}
// 세션 타임아웃(유휴 + 절대 수명) 강제 — 모든 인증 페이지 공통 헬퍼(admin_security.php).
// 유휴 타이머 갱신도 헬퍼가 처리한다.
admin_enforce_session_timeout();
// ── Heartbeat: 오프라인 센서 체크 (HEARTBEAT_CHECK_INTERVAL_SEC 쓰로틀) ──────
check_offline_sensors_throttled();
$db = get_db();
// ── 온습도 경보 상태 라벨/표시 헬퍼 ─────────────────────────
// metric_status: normal|high_temp|low_temp|high_humidity|low_humidity
function metric_status_label(string $status): string {
$labels = [
'normal' => '정상',
'high_temp' => '고온',
'low_temp' => '저온',
'high_humidity' => '고습',
'low_humidity' => '저습',
];
return $labels[$status] ?? $status;
}
// 측정값을 사람이 읽는 짧은 문자열로 (소수점 1자리)
function metric_display(?string $value, string $unit): string {
if ($value === null || $value === '') {
return '-';
}
return number_format((float)$value, 1) . $unit;
}
// 테이블 존재 확인(누수 미통합 서버에서 leak_incident/leak_photo 부재 시 graceful degrade).
function table_exists(PDO $db, string $table): bool {
try {
$stmt = $db->prepare("
SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table
");
$stmt->execute([':table' => $table]);
return (int)$stmt->fetchColumn() > 0;
} catch (Throwable $e) {
return false;
}
}
// ── 센서별 현재 상태 + 최신 측정값 ──────────────────────────
$statuses = $db->query("SELECT * FROM leak_sensor_status ORDER BY sensor_id")->fetchAll();
// 센서별 최신 leak_sensor_metric (최대 id 기준)
$latest_metrics = [];
$metric_rows = $db->query("
SELECT sm.*
FROM leak_sensor_metric sm
INNER JOIN (
SELECT sensor_id, MAX(id) AS latest_id
FROM leak_sensor_metric
GROUP BY sensor_id
) latest ON latest.latest_id = sm.id
ORDER BY sm.sensor_id
")->fetchAll();
foreach ($metric_rows as $metric_row) {
$latest_metrics[(int)$metric_row['sensor_id']] = $metric_row;
}
// 각 센서에 대해 실시간 오프라인/경보 상태 계산
$now = time();
foreach ($statuses as &$s) {
$sid = (int)$s['sensor_id'];
$metric = $latest_metrics[$sid] ?? null;
$s['latest_metric'] = $metric;
$last_seen_ts = strtotime($s['last_seen'] ?? '2000-01-01');
$elapsed = $now - $last_seen_ts;
// offline_alerted 플래그가 1이거나, last_seen이 HEARTBEAT_TIMEOUT_SEC 이상 경과했으면 오프라인
$s['is_offline'] = ($s['offline_alerted'] == 1) || ($elapsed >= HEARTBEAT_TIMEOUT_SEC);
$s['offline_elapsed_min'] = (int)round($elapsed / 60);
$status_code = $metric['metric_status'] ?? 'normal';
$s['metric_status'] = $status_code;
$s['is_alerting'] = ($status_code !== 'normal');
}
unset($s); // 참조 해제
// ── 온습도 추이 차트용: 한 센서의 최근 100개 측정 ───────────
// 기본 sensor_id=2, 없으면 첫 센서
$chart_sensor_ids = array_map(fn($s) => (int)$s['sensor_id'], $statuses);
$chart_sensor_id = in_array(2, $chart_sensor_ids, true)
? 2
: ($chart_sensor_ids[0] ?? 0);
$chart_sensor_name = '';
foreach ($statuses as $s) {
if ((int)$s['sensor_id'] === $chart_sensor_id) {
$chart_sensor_name = $s['sensor_name'];
break;
}
}
// 추이 차트 기간(오늘/3일/7일) — 링크 토글(폐쇄망: 외부 라이브러리 없음, 서버 렌더)
$RANGE_DAYS = ['today' => 1, '3d' => 3, '7d' => 7];
$chart_range = (isset($_GET['range']) && isset($RANGE_DAYS[$_GET['range']])) ? $_GET['range'] : '3d';
$range_days = $RANGE_DAYS[$chart_range];
$chart_points = [];
if ($chart_sensor_id > 0) {
$chart_stmt = $db->prepare("
SELECT measured_at, temperature_c, humidity_percent
FROM leak_sensor_metric
WHERE sensor_id = :sid AND measured_at >= DATE_SUB(NOW(), INTERVAL :days DAY)
ORDER BY id DESC
LIMIT 600
");
$chart_stmt->bindValue(':sid', $chart_sensor_id, PDO::PARAM_INT);
$chart_stmt->bindValue(':days', $range_days, PDO::PARAM_INT);
$chart_stmt->execute();
$chart_rows = $chart_stmt->fetchAll();
// 오래된 → 최신 순으로 (그래프 좌→우)
$chart_rows = array_reverse($chart_rows);
foreach ($chart_rows as $cr) {
$chart_points[] = [
't' => $cr['measured_at'],
'temp' => $cr['temperature_c'] !== null ? (float)$cr['temperature_c'] : null,
'hum' => $cr['humidity_percent'] !== null ? (float)$cr['humidity_percent'] : null,
];
}
}
// ── 최근 측정 이력 (leak_sensor_metric 30건) ─────────────────────
$recent_metrics = $db->query("
SELECT sensor_id, sensor_name, device_location,
temperature_c, humidity_percent, metric_status, measured_at
FROM leak_sensor_metric
ORDER BY id DESC
LIMIT 30
")->fetchAll();
// ── 최근 센서 이벤트 로그 (leak_sensor_log: startup/periodic/leak_alert/leak_recovery) ──
$logs = $db->query("
SELECT id, sensor_name, device_location, event_type, is_leak, sensor_time
FROM leak_sensor_log
ORDER BY id DESC
LIMIT 30
")->fetchAll();
// ── 최근 SMS 발송 이력 ─────────────────────────────────────
$sms_logs = $db->query("
SELECT * FROM leak_sms_log
ORDER BY id DESC
LIMIT 20
")->fetchAll();
// ── 30일 운영 요약 ─────────────────────────────────────────
// 종류별 임계 경보 SMS 건수 (leak_sms_log message prefix 기준)
$sms_summary_rows = $db->query("
SELECT
" . sms_summary_select_sql() . "
FROM leak_sms_log
WHERE sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
")->fetch();
$summary = [
'high_temp' => (int)($sms_summary_rows['high_temp'] ?? 0),
'low_temp' => (int)($sms_summary_rows['low_temp'] ?? 0),
'high_humidity' => (int)($sms_summary_rows['high_humidity'] ?? 0),
'low_humidity' => (int)($sms_summary_rows['low_humidity'] ?? 0),
'recovery' => (int)($sms_summary_rows['recovery'] ?? 0),
'offline' => (int)($sms_summary_rows['device_offline'] ?? 0),
'device_online' => (int)($sms_summary_rows['device_online'] ?? 0),
'leak_alert' => (int)($sms_summary_rows['leak_alert'] ?? 0),
'leak_recovery' => (int)($sms_summary_rows['leak_recovery'] ?? 0),
'sms_fail' => (int)($sms_summary_rows['sms_fail'] ?? 0),
];
$summary['alert_total'] = $summary['high_temp'] + $summary['low_temp']
+ $summary['high_humidity'] + $summary['low_humidity'];
// 30일 평균 온습도 + 최근 온습도(차트 센서 기준 최신값)
$metric_avg = $db->query("
SELECT AVG(temperature_c) AS avg_temp, AVG(humidity_percent) AS avg_hum
FROM leak_sensor_metric
WHERE measured_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
")->fetch();
$avg_temp = $metric_avg['avg_temp'] !== null ? round((float)$metric_avg['avg_temp'], 1) : null;
$avg_hum = $metric_avg['avg_hum'] !== null ? round((float)$metric_avg['avg_hum'], 1) : null;
$latest_overall = !empty($chart_points) ? end($chart_points) : null;
$latest_temp = $latest_overall['temp'] ?? null;
$latest_hum = $latest_overall['hum'] ?? null;
// ── 운영 자가진단 (ops_checks.php) ─────────────────────────
$ops_checks = collect_ops_checks();
$ops_failed = ops_failed_count($ops_checks);
$ops_critical_count = count(array_filter(
$ops_checks,
fn($c) => !$c['ok'] && $c['severity'] === 'critical'
));
$ops_warn_count = count(array_filter(
$ops_checks,
fn($c) => !$c['ok'] && $c['severity'] !== 'critical'
));
// 컴팩트 표시: 실패(확인 필요) 항목만 카드로 노출하고 정상 항목은 접는다(패널 길이 억제).
$ops_issues = array_values(array_filter($ops_checks, fn($c) => !$c['ok']));
$ops_ok = array_values(array_filter($ops_checks, fn($c) => $c['ok']));
usort($ops_issues, fn($a, $b) => ($a['severity'] === 'critical' ? 0 : 1) <=> ($b['severity'] === 'critical' ? 0 : 1));
$ops_ok_count = count($ops_ok);
$ops_pill = $ops_critical_count > 0 ? 'crit' : ($ops_warn_count > 0 ? 'warn' : 'ok');
$ops_pill_text = $ops_failed > 0 ? '확인 필요' : '정상';
// ── 상단 시스템 상태 집계 ──────────────────────────────────
$offline_count = count(array_filter($statuses, fn($s) => $s['is_offline']));
$alert_count = count(array_filter($statuses, fn($s) => $s['is_alerting'] && !$s['is_offline']));
$normal_count = count($statuses) - $offline_count - $alert_count;
// ── 경보 SMS 발송 실패 / 수집 거부 사유 (신뢰성·관측성) ─────
// 최근 1시간 내 경보/오프라인/에스컬레이션 SMS 가 실패했는가(미수신 가능 경고).
$recent_alert_fail = (int)$db->query("
SELECT COUNT(*) FROM leak_sms_log
WHERE status = 'fail'
AND sent_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR)
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_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_ESCALATION . "%')
")->fetchColumn();
// 최근 24시간(오늘) 수집 거부 사유별 집계(왜 데이터가 안 들어오는가).
$ingest_rejections = read_ingest_rejections(1);
$rejection_total = array_sum($ingest_rejections);
// ── 누수 모듈 현재 상태 + 사고/사진 ───────────────────
// 누수 센서(LEAK_SENSOR_IDS, config.php — 기본 [1])의 leak_sensor_status.is_leak 로
// 누수/정상을 표시한다. 오프라인 판정은 위 $statuses 계산과 동일한 로직을
// 따른다(행이 없으면 '데이터 없음'). 신규 누수 지점은 config.local.php 의
// 'LEAK_SENSOR_IDS' => [1, 3] 오버라이드로 카드가 추가된다.
$leak_statuses = []; // sensor_id => leak_sensor_status 행 (미수신이면 null)
foreach (LEAK_SENSOR_IDS as $leak_sid) {
$leak_statuses[$leak_sid] = null;
}
foreach ($statuses as $s) {
$sid = (int)$s['sensor_id'];
if (array_key_exists($sid, $leak_statuses)) {
$leak_statuses[$sid] = $s;
}
}
// 온습도(SHT30) 센서 카드 목록: 기대 온습도 센서(METRIC_SENSOR_IDS)는 미수신도
// placeholder(null)로 항상 표시한다(누수 카드와 동일 원칙 — 최초 기동 전 SHT30 이
// 화면에서 조용히 빠지지 않게). 실제 온습도 데이터가 있는 센서도 포함하되, 순수
// 누수 센서(측정값 없음)는 아래 "누수 감지" 섹션에서만 표시한다(중복 방지).
$metric_statuses = [];
foreach (METRIC_SENSOR_IDS as $mid) {
$metric_statuses[(int)$mid] = null;
}
foreach ($statuses as $s) {
$sid = (int)$s['sensor_id'];
if (!empty($s['latest_metric']) || array_key_exists($sid, $metric_statuses)) {
$metric_statuses[$sid] = $s;
}
}
ksort($metric_statuses);
// 최근 누수 사고(에피소드) 20건 + 각 사고의 사진(leak_photo) — 테이블 없으면 graceful.
$leak_incidents = [];
$leak_photos_by_log = []; // log_id => [ ['id'=>..,'log_id'=>..,'captured_at'=>..], ... ]
if (table_exists($db, 'leak_incident')) {
$leak_incidents = $db->query("
SELECT id, sensor_name, device_location, started_at, recovered_at, log_id_alert
FROM leak_incident
ORDER BY id DESC
LIMIT 20
")->fetchAll();
if (!empty($leak_incidents) && table_exists($db, 'leak_photo')) {
$photo_stmt = $db->prepare("
SELECT id, log_id, captured_at
FROM leak_photo
WHERE log_id = :lid
ORDER BY id ASC
");
foreach ($leak_incidents as $inc) {
$lid = $inc['log_id_alert'] !== null ? (int)$inc['log_id_alert'] : 0;
if ($lid < 1 || isset($leak_photos_by_log[$lid])) {
continue;
}
$photo_stmt->execute([':lid' => $lid]);
$leak_photos_by_log[$lid] = $photo_stmt->fetchAll();
}
}
}
if ($alert_count > 0) {
$system_state = 'danger';
$system_title = '온습도 경보 발생';
$system_detail = "임계 경보 센서 {$alert_count}개, 오프라인 {$offline_count}개";
} elseif ($offline_count > 0) {
$system_state = 'warning';
$system_title = '장비 오프라인 확인 필요';
$system_detail = "오프라인 {$offline_count}개, 중요 진단 {$ops_critical_count}건";
} elseif ($ops_critical_count > 0) {
$system_state = 'warning';
$system_title = '운영 확인 필요';
$system_detail = "중요 진단 {$ops_critical_count}건을 확인하세요.";
} else {
$system_state = 'normal';
$system_title = '정상 감시 중';
$system_detail = '현재 온습도 경보와 중요 운영 경고가 없습니다.';
}
// ── 예보·대응 신호 (forecast.php — 순수계산) ──────────────────────────────
// 기존 측정 데이터만으로 임계 여유·추세 ETA·이슬점·변화율·신선도를 산출한다.
// $statuses 는 모든 센서(누수 노드 포함)를 담으므로 한 번만 순회한다(중복 없음).
require_once __DIR__ . '/forecast.php';
$forecasts = []; // sensor_id => forecast_metric() 결과 (카드 예보줄에 사용)
$risk_items_all = []; // 대응 섹션용 정규화 위험 목록
if (FORECAST_ENABLED) {
$fc_cfg = [
'temp_high' => METRIC_TEMP_HIGH_C,
'temp_low' => METRIC_TEMP_LOW_C,
'rh_high' => METRIC_RH_HIGH,
'rh_low' => (METRIC_RH_LOW >= 0 ? METRIC_RH_LOW : null), // -1 = 비활성
'dew_margin'=> METRIC_DEWPOINT_MARGIN_C, // 0 이하 = 결로 판정 스킵
'warn_temp' => FORECAST_WARN_MARGIN_TEMP_C,
'warn_rh' => FORECAST_WARN_MARGIN_RH,
'horizon_min'=> FORECAST_HORIZON_MIN,
'min_points'=> FORECAST_MIN_POINTS,
'min_r2' => FORECAST_MIN_R2,
'rate_temp_10'=> FORECAST_RATE_TEMP_C_PER_10MIN,
'rate_rh_10' => FORECAST_RATE_RH_PER_10MIN,
];
$now_ts = time();
$recent_stmt = $db->prepare(
"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"
);
foreach ($statuses as $s) {
$sid = (int)$s['sensor_id'];
$metric = $s['latest_metric'] ?? null;
$temp = ($metric && $metric['temperature_c'] !== null) ? (float)$metric['temperature_c'] : null;
$hum = ($metric && $metric['humidity_percent'] !== null) ? (float)$metric['humidity_percent'] : null;
$recent = [];
if ($temp !== null || $hum !== null) {
$recent_stmt->bindValue(':sid', $sid, PDO::PARAM_INT);
$recent_stmt->bindValue(':lim', (int)FORECAST_RECENT_POINTS, PDO::PARAM_INT);
$recent_stmt->execute();
foreach (array_reverse($recent_stmt->fetchAll()) as $rr) { // 오래된→최신
$recent[] = [
't' => (int)$rr['t'],
'temp' => $rr['temperature_c'] !== null ? (float)$rr['temperature_c'] : null,
'hum' => $rr['humidity_percent'] !== null ? (float)$rr['humidity_percent'] : null,
];
}
}
$fc = forecast_metric($temp, $hum, $recent, $fc_cfg);
$forecasts[$sid] = $fc;
// 신선도 — offline 은 대시보드 기존 판정($s['is_offline'])을 단일 진실원으로 사용
$fr = forecast_freshness(strtotime($s['last_seen'] ?? '') ?: null, $now_ts, HEARTBEAT_TIMEOUT_SEC);
$fr['offline'] = (bool)$s['is_offline'];
if ($fr['offline']) { $fr['imminent'] = false; }
$risk_items_all = array_merge($risk_items_all, risk_items(
['sensor_id' => $sid, 'name' => $s['sensor_name'], 'location' => $s['device_location'],
'is_leak' => (int)($s['is_leak'] ?? 0)],
$fc, $fr
));
}
// critical 먼저, 그다음 warn
usort($risk_items_all, fn($a, $b) => ($a['severity'] === 'critical' ? 0 : 1) <=> ($b['severity'] === 'critical' ? 0 : 1));
}
// ── 개요 벤토 위젯 데이터 (샘플 대시보드 콘텐츠 반영: 게이지·리더보드·이벤트율·도넛) ──
// 경보성/복구성 SMS 태그 묶음 — 태그 상수에서만 파생(집계 일원화). 상수는 신뢰값이므로
// $recent_alert_fail 쿼리와 동일하게 LIKE 절에 직접 삽입한다(사용자 입력 아님).
$OV_ALERT_TAGS = [
SMS_TAG_HIGH_TEMP, SMS_TAG_LOW_TEMP, SMS_TAG_HIGH_HUMIDITY, SMS_TAG_LOW_HUMIDITY,
SMS_TAG_CONDENSATION, SMS_TAG_DEVICE_OFFLINE, SMS_TAG_LEAK_ALERT, SMS_TAG_ESCALATION,
];
$OV_RECOVER_TAGS = [SMS_TAG_RECOVERY, SMS_TAG_DEVICE_ONLINE, SMS_TAG_LEAK_RECOVERY];
$ov_like = function (array $tags): string {
return '(' . implode(' OR ', array_map(
fn($t) => "message LIKE '" . $t . "%'", $tags
)) . ')';
};
// (1) 가동률 게이지 — 온라인 센서 비율 + 7일 일별 경보건수 스파크라인 + 상태 배지
$sensor_total = count($statuses);
$online_count = $sensor_total - $offline_count;
$uptime_pct = $sensor_total > 0 ? (int)round($online_count / $sensor_total * 100) : 100;
$spark_map = $db->query("
SELECT DATE(sent_at) d, COUNT(*) c
FROM leak_sms_log
WHERE sent_at >= DATE_SUB(CURDATE(), INTERVAL 6 DAY) AND " . $ov_like($OV_ALERT_TAGS) . "
GROUP BY DATE(sent_at)
")->fetchAll(PDO::FETCH_KEY_PAIR);
$spark_alerts = [];
for ($i = 6; $i >= 0; $i--) {
$spark_alerts[] = (int)($spark_map[date('Y-m-d', strtotime("-$i day"))] ?? 0);
}
$spark_today = end($spark_alerts);
reset($spark_alerts);
if ($alert_count > 0) { $ov_badge = 'crit'; $ov_badge_txt = '경보'; }
elseif ($offline_count > 0 || $spark_today > 0) { $ov_badge = 'warn'; $ov_badge_txt = '주의'; }
else { $ov_badge = 'ok'; $ov_badge_txt = '정상'; }
// (2) 경보 최다 센서 리더보드 (30일)
$board_rows = $db->query("
SELECT sensor_name, COUNT(*) c
FROM leak_sms_log
WHERE sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) AND " . $ov_like($OV_ALERT_TAGS) . "
GROUP BY sensor_name
ORDER BY c DESC, sensor_name ASC
LIMIT 6
")->fetchAll();
$board_max = 0;
foreach ($board_rows as $br) { $board_max = max($board_max, (int)$br['c']); }
// (3) 경보 발생 vs 복구 (7일 일별) — 이벤트율 라인차트
$trend_map = [];
foreach ($db->query("
SELECT DATE(sent_at) d,
SUM(" . $ov_like($OV_ALERT_TAGS) . ") alerts,
SUM(" . $ov_like($OV_RECOVER_TAGS) . ") recovers
FROM leak_sms_log
WHERE sent_at >= DATE_SUB(CURDATE(), INTERVAL 6 DAY)
GROUP BY DATE(sent_at)
")->fetchAll() as $tr) {
$trend_map[$tr['d']] = $tr;
}
$trend_days = []; $trend_alerts = []; $trend_recovers = [];
for ($i = 6; $i >= 0; $i--) {
$d = date('Y-m-d', strtotime("-$i day"));
$trend_days[] = $d;
$trend_alerts[] = (int)($trend_map[$d]['alerts'] ?? 0);
$trend_recovers[] = (int)($trend_map[$d]['recovers'] ?? 0);
}
$trend_max = max(1, max(array_merge($trend_alerts, $trend_recovers)));
// (4) 경보 종류 도넛 — $summary(30일) 재사용(추가 쿼리 없음)
$donut_segs = [
['label' => '고온', 'val' => (int)$summary['high_temp'], 'color' => '#ff8a5c'],
['label' => '고습', 'val' => (int)$summary['high_humidity'], 'color' => '#ffb23e'],
['label' => '누수', 'val' => (int)$summary['leak_alert'], 'color' => '#ff5470'],
['label' => '저온', 'val' => (int)$summary['low_temp'], 'color' => '#5fd6ee'],
['label' => '저습', 'val' => (int)$summary['low_humidity'], 'color' => '#b0b8ff'],
];
usort($donut_segs, fn($a, $b) => $b['val'] <=> $a['val']);
$donut_total = array_sum(array_column($donut_segs, 'val'));
// (5) 추이 차트 피크(기간 내 최고 온도) — $chart_points 재사용
$peak_i = null; $peak_v = null;
foreach ($chart_points as $ci => $cp) {
if ($cp['temp'] !== null && ($peak_v === null || $cp['temp'] > $peak_v)) {
$peak_v = (float)$cp['temp']; $peak_i = $ci;
}
}
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>온습도·누수 통합 모니터링 대시보드 <?= htmlspecialchars(APP_VERSION) ?></title>
<link rel="stylesheet" href="assets/app.css">
<style>
/* dashboard.php 전용 잔여 스타일 — 값은 var(--*) 토큰만(app.css 규칙 재정의 아님) */
/* 요약 카드 값: 상태 색(글리프 포함)이 .card .val 위에서 이기도록 스코프 */
.card .val.ok-color { color: var(--normal-ink); }
.card .val.err-color { color: var(--leak-ink); }
.card .val.warn-color { color: var(--warn-ink); }
.val.compact { font-size: 1.4rem; }
.val.clock { font-size: 1.1rem; }
.cards.flush { margin-bottom: 0; }
/* 패널 보조 캡션 */
.diag-summary { color: var(--ink-2); font-size: .8125rem; margin-bottom: var(--sp-3); }
.panel-note { color: var(--ink-2); font-size: .8125rem; margin-top: var(--sp-3); }
.h2-sub { font-weight: 400; color: var(--ink-2); font-size: .8125rem; }
/* 자동 새로고침 토글(소형) — .refresh 안에서 인쇄 시 함께 숨김. 토큰만 사용 */
.refresh-toggle {
font: inherit; font-size: .8125rem; line-height: 1.2; cursor: pointer;
margin-left: var(--sp-2); padding: 2px 10px;
background: var(--surface); color: var(--ink-2);
border: 1px solid var(--line); border-radius: var(--r-pill);
}
.refresh-toggle:hover { background: var(--surface-2); }
/* 카드 배지 상단 간격 / 오프라인(경과) 강조 / 빈 표 셀 */
.badge-row { margin-top: var(--sp-3); }
.stale { color: var(--leak-ink); font-weight: 700; }
.empty-cell { text-align: center; color: var(--ink-3); }
/* 온습도 추이 SVG — app.css 미포함 요소 토큰화(라인=차트 토큰) */
/* 패널 폭을 채우도록 viewBox 유지한 채 100% 스케일. 좁은 화면에서만 최소폭(가로스크롤) */
.chart-svg { width: 100%; height: auto; display: block; }
@media (max-width: 560px) { .chart-svg { min-width: 480px; } }
.chart-svg-wrap .plot-bg { fill: var(--surface-2); stroke: var(--line); }
.chart-svg-wrap .grid-line { stroke: var(--chart-grid); }
.chart-svg-wrap .axis-temp { fill: var(--chart-temp); }
.chart-svg-wrap .axis-hum { fill: var(--chart-hum); }
.chart-svg-wrap .axis-x { fill: var(--ink-3); }
.chart-svg-wrap .line-temp { stroke: var(--chart-temp); }
.chart-svg-wrap .line-hum { stroke: var(--chart-hum); }
</style>
<!-- 자동 새로고침(토글·스크롤/펼침 복원)은 body 하단 스크립트에서 처리(WCAG 2.2.1) -->
</head>
<body>
<header>
<h1>온습도·누수 통합 모니터링 대시보드 <?= htmlspecialchars(APP_VERSION) ?></h1>
<div class="top-actions">
<a href="setup_wizard.php">설치 점검</a>
<a href="monthly_report.php">월간 보고</a>
<a href="security_evidence.php">보안 증적</a>
<a href="?logout">로그아웃</a>
</div>
</header>
<main class="container">
<section class="status-hero <?= htmlspecialchars($system_state) ?>">
<div>
<h2><?= htmlspecialchars($system_title) ?></h2>
<p><?= htmlspecialchars($system_detail) ?></p>
</div>
<div class="status-clock">
<?= date('Y-m-d H:i:s') ?><br>
30초 자동 새로고침
</div>
</section>
<?php if ($recent_alert_fail > 0): ?>
<section class="status-hero danger">
<div>
<h2>경보 SMS 발송 실패</h2>
<p>최근 1시간 내 경보/오프라인 SMS <?= (int)$recent_alert_fail ?>건 발송 실패 — 미수신 가능. Cafe24 SMS 잔액·계정·발신번호를 확인하세요.</p>
</div>
</section>
<?php endif; ?>
<?php if ($rejection_total > 0): ?>
<section class="status-hero warning">
<div>
<h2>수집 거부 <?= (int)$rejection_total ?>건 (최근 24시간)</h2>
<p><?php
$reason_ko = [
'signature' => '서명불일치(API키/펌웨어 점검)',
'timestamp' => '시각오차(SNTP/RTC 점검)',
'key_default' => '기본 API키(미설정)',
'body_too_large' => '본문과대',
'invalid_json' => 'JSON오류',
'missing_field' => '필드누락',
'sensor_id_range' => 'sensor_id 범위',
];
$parts = [];
foreach ($ingest_rejections as $r => $c) {
$parts[] = htmlspecialchars($reason_ko[$r] ?? $r) . ' ' . (int)$c;
}
echo implode(' · ', $parts);
?></p>
</div>
</section>
<?php endif; ?>
<?php if (FORECAST_ENABLED && !empty($risk_items_all)): ?>
<section class="response-panel" aria-label="대응 필요 항목">
<h2>대응 필요 <span class="response-count"><?= count($risk_items_all) ?>건</span></h2>
<?php foreach ($risk_items_all as $risk):
$pb = RESPONSE_PLAYBOOKS[$risk['condition']] ?? null;
$sev_pill = $risk['severity'] === 'critical' ? 'alert' : 'offline';
$sev_text = $risk['severity'] === 'critical' ? '경보' : '주의';
?>
<div class="response-item <?= htmlspecialchars($risk['severity']) ?>">
<div class="response-head">
<span class="pill <?= $sev_pill ?>"><?= $sev_text ?></span>
<strong><?= htmlspecialchars($risk['name']) ?><?= $risk['location'] ? ' · ' . htmlspecialchars($risk['location']) : '' ?></strong>
<span class="response-detail"><?= htmlspecialchars($risk['detail']) ?></span>
</div>
<?php if ($pb && !empty($pb['steps'])): ?>
<div class="playbook">
<div class="playbook-title"><?= htmlspecialchars($pb['title'] ?? '권장 조치') ?> — 권장 조치</div>
<ol class="playbook-steps">
<?php foreach ($pb['steps'] as $step): ?><li><?= htmlspecialchars($step) ?></li><?php endforeach; ?>
</ol>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
<?php if (!empty(RESPONSE_CONTACTS)): ?>
<div class="response-contacts">
<span class="contacts-label">담당자</span>
<?php foreach (RESPONSE_CONTACTS as $ct):
$ph = preg_replace('/[^0-9+]/', '', (string)($ct['phone'] ?? '')); ?>
<span class="contact">
<?= htmlspecialchars(trim(($ct['role'] ?? '') . ' ' . ($ct['name'] ?? ''))) ?><?php if ($ph !== ''): ?> <a href="tel:<?= htmlspecialchars($ph) ?>"><?= htmlspecialchars((string)$ct['phone']) ?></a><?php endif; ?>
</span>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php endif; ?>
<section class="mobile-quick" aria-label="모바일 빠른 조회">
<div class="quick-grid">
<a class="quick-tile <?= $alert_count > 0 ? 'danger' : 'ok' ?>" href="#sensors-section">
<span>경보 센서</span>
<strong><?= $alert_count ?>개</strong>
</a>
<a class="quick-tile <?= $offline_count > 0 ? 'warn' : 'ok' ?>" href="#sensors-section">
<span>오프라인</span>
<strong><?= $offline_count ?>개</strong>
</a>
<a class="quick-tile <?= $summary['alert_total'] > 0 ? 'warn' : 'ok' ?>" href="#summary-section">
<span>30일 경보 SMS</span>
<strong><?= $summary['alert_total'] ?>건</strong>
</a>
<a class="quick-tile <?= $ops_critical_count > 0 ? 'warn' : 'ok' ?>" href="#diagnostics-section">
<span>중요 진단</span>
<strong><?= $ops_critical_count ?>건</strong>
</a>
</div>
<nav class="quick-links" aria-label="빠른 이동">
<a href="#sensors-section">센서 상태</a>
<a href="#leak-section">누수 감지</a>
<a href="#chart-section">온습도 추이</a>
<a href="#summary-section">운영 요약</a>
<a href="#metrics-section">측정 이력</a>
<a href="#logs-section">이벤트 로그</a>
<a href="#sms-section">SMS</a>
</nav>
</section>
<div class="zone" style="margin-top:var(--sp-6)">
<div class="zone-label"><b>관제 개요</b> <span class="en">Overview</span></div>
<div class="overview">
<!-- (1) 가동률 게이지 + 7일 경보 스파크라인 + 상태 배지 -->
<div class="ov-tile ov-gauge">
<h3>가동률 <span class="sub">온라인 센서</span></h3>
<div class="gauge-body">
<div class="gauge-ring <?= $ov_badge ?>">
<svg viewBox="0 0 120 120" role="img" aria-label="가동률 <?= $uptime_pct ?>%">
<g transform="rotate(-90 60 60)">
<circle class="g-track" cx="60" cy="60" r="52" pathLength="100"></circle>
<circle class="g-val" cx="60" cy="60" r="52" pathLength="100" stroke-dasharray="<?= $uptime_pct ?> 100"></circle>
</g>
</svg>
<div class="gauge-num"><b><?= $uptime_pct ?>%</b><small><?= $online_count ?> / <?= $sensor_total ?> 온라인</small></div>
</div>
<div class="gauge-foot">
<span class="lbl">7일 경보</span>
<?php
$sp_n = count($spark_alerts);
$sp_max = max(1, max($spark_alerts));
$sp_pts = [];
foreach ($spark_alerts as $spi => $spv) {
$spx = $sp_n > 1 ? $spi * (84 / ($sp_n - 1)) : 42;
$spy = 4 + (1 - $spv / $sp_max) * 22;
$sp_pts[] = sprintf('%.1f,%.1f', $spx, $spy);
}
$sp_last = explode(',', end($sp_pts));
?>
<svg class="spark" viewBox="0 0 84 30" preserveAspectRatio="none" aria-hidden="true">
<polyline points="<?= htmlspecialchars(implode(' ', $sp_pts)) ?>"></polyline>
<circle class="sp-dot" cx="<?= htmlspecialchars($sp_last[0]) ?>" cy="<?= htmlspecialchars($sp_last[1]) ?>" r="2"></circle>
</svg>
<span class="g-badge <?= $ov_badge ?>"><?= htmlspecialchars($ov_badge_txt) ?></span>
</div>
</div>
</div>
<!-- (2) 듀얼 KPI: 경보 센서 / 오프라인 -->
<div class="ov-kpis">
<div class="ov-kpi <?= $alert_count > 0 ? 'err' : 'ok' ?>">
<span class="k-lbl">경보 센서</span>
<span class="k-val"><?= $alert_count ?><u>개</u></span>
</div>
<div class="ov-kpi <?= $offline_count > 0 ? 'warn' : 'ok' ?>">
<span class="k-lbl">오프라인</span>
<span class="k-val"><?= $offline_count ?><u>개</u></span>
</div>
</div>
<!-- (3) 경보 종류 도넛 (30일, $summary 재사용) -->
<div class="ov-tile ov-donut">
<h3>경보 종류 <span class="sub">최근 30일</span></h3>
<?php if ($donut_total > 0): ?>
<div class="donut-body">
<div class="donut-ring">
<svg viewBox="0 0 120 120" role="img" aria-label="경보 종류 구성 총 <?= $donut_total ?>건">
<circle class="d-track" cx="60" cy="60" r="47" pathLength="100"></circle>
<g transform="rotate(-90 60 60)">
<?php $d_acc = 0; foreach ($donut_segs as $seg):
if ((int)$seg['val'] <= 0) { continue; }
$d_pct = $seg['val'] / $donut_total * 100; ?>
<circle class="seg" cx="60" cy="60" r="47" pathLength="100"
stroke="<?= htmlspecialchars($seg['color']) ?>"
stroke-dasharray="<?= sprintf('%.2f', $d_pct) ?> 100"
stroke-dashoffset="<?= sprintf('%.2f', -$d_acc) ?>"></circle>
<?php $d_acc += $d_pct; endforeach; ?>
</g>
</svg>
<div class="donut-num"><b><?= $donut_total ?></b><small>건</small></div>
</div>
<div class="donut-legend">
<?php foreach ($donut_segs as $seg): if ((int)$seg['val'] <= 0) { continue; } ?>
<span class="dl"><i style="background:<?= htmlspecialchars($seg['color']) ?>"></i><?= htmlspecialchars($seg['label']) ?><span class="dl-c"><?= (int)$seg['val'] ?></span></span>
<?php endforeach; ?>
</div>
</div>
<?php else: ?>
<div class="donut-empty">최근 30일 경보 없음</div>
<?php endif; ?>
</div>
<!-- (4) 경보 최다 센서 리더보드 (30일) -->
<div class="ov-tile ov-board">
<h3>경보 최다 센서 <span class="sub">30일 · TOP <?= count($board_rows) ?: 6 ?></span></h3>
<?php if (!empty($board_rows)): ?>
<div class="board">
<?php foreach ($board_rows as $b_rank => $br):
$b_w = $board_max > 0 ? (int)round((int)$br['c'] / $board_max * 100) : 0; ?>
<div class="board-row">
<span class="board-rank"><?= $b_rank + 1 ?></span>
<div class="board-main">
<div class="board-name"><?= htmlspecialchars($br['sensor_name']) ?></div>
<div class="board-bar"><span style="width:<?= $b_w ?>%"></span></div>
</div>
<span class="board-cnt"><?= (int)$br['c'] ?><small>건</small></span>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="board-empty">최근 30일 경보 발생 센서 없음</div>
<?php endif; ?>
</div>
<!-- (5) 경보 발생 vs 복구 (7일 일별 이벤트율) -->
<div class="ov-tile ov-trend">
<h3>경보 발생 vs 복구 <span class="sub">최근 7일 · 일별</span></h3>
<div class="mini-legend">
<span><i style="background:var(--leak)"></i>경보 발생</span>
<span><i style="background:var(--normal)"></i>복구</span>
</div>
<?php
$tw = 640; $th = 200; $tpl = 40; $tpr = 16; $tpt = 16; $tpb = 34;
$tplot_w = $tw - $tpl - $tpr; $tplot_h = $th - $tpt - $tpb; $tn = count($trend_days);
$t_x = function ($i) use ($tpl, $tplot_w, $tn) { return $tn > 1 ? $tpl + $tplot_w * $i / ($tn - 1) : $tpl; };
$t_y = function ($v) use ($tpt, $tplot_h, $trend_max) { return $tpt + $tplot_h * (1 - $v / $trend_max); };
$t_line = function (array $vals) use ($t_x, $t_y) {
$pts = [];
foreach ($vals as $i => $v) { $pts[] = sprintf('%.1f,%.1f', $t_x($i), $t_y($v)); }
return implode(' ', $pts);
};
$t_peak_i = 0;
foreach ($trend_alerts as $i => $v) { if ($v > $trend_alerts[$t_peak_i]) { $t_peak_i = $i; } }
?>
<svg class="trend-svg" viewBox="0 0 <?= $tw ?> <?= $th ?>" preserveAspectRatio="xMidYMid meet" role="img" aria-label="경보 발생 vs 복구 7일 추이">
<rect x="<?= $tpl ?>" y="<?= $tpt ?>" width="<?= $tplot_w ?>" height="<?= $tplot_h ?>" class="plot-bg"></rect>
<?php for ($g = 0; $g <= 2; $g++):
$gy = $tpt + $tplot_h * $g / 2; $gv = $trend_max - $trend_max * $g / 2; ?>
<line x1="<?= $tpl ?>" y1="<?= sprintf('%.1f', $gy) ?>" x2="<?= $tpl + $tplot_w ?>" y2="<?= sprintf('%.1f', $gy) ?>" class="grid-line" stroke-dasharray="3,3"></line>
<text x="<?= $tpl - 6 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>" text-anchor="end" font-size="10" class="ax"><?= htmlspecialchars(rtrim(rtrim(number_format($gv, 1, '.', ''), '0'), '.')) ?></text>
<?php endfor; ?>
<polyline points="<?= htmlspecialchars($t_line($trend_recovers)) ?>" class="line-recover" fill="none" stroke-width="2" stroke-dasharray="6,3" stroke-linejoin="round" stroke-linecap="round"></polyline>
<polyline points="<?= htmlspecialchars($t_line($trend_alerts)) ?>" class="line-alert" fill="none" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"></polyline>
<?php if ($trend_alerts[$t_peak_i] > 0): ?>
<circle cx="<?= sprintf('%.1f', $t_x($t_peak_i)) ?>" cy="<?= sprintf('%.1f', $t_y($trend_alerts[$t_peak_i])) ?>" r="3.2" class="dot-alert"></circle>
<?php endif; ?>
<?php
$t_lbl_idx = $tn > 1 ? [0, intdiv($tn - 1, 2), $tn - 1] : [0];
foreach ($t_lbl_idx as $li):
$anchor = $li === 0 ? 'start' : ($li === $tn - 1 ? 'end' : 'middle'); ?>
<text x="<?= sprintf('%.1f', $t_x($li)) ?>" y="<?= $th - 12 ?>" text-anchor="<?= $anchor ?>" font-size="9" class="ax"><?= htmlspecialchars(substr($trend_days[$li], 5)) ?></text>
<?php endforeach; ?>
</svg>
</div>
<aside class="panel ov-diag" id="diagnostics-section">
<div class="diag-head">
<h2>운영 자가진단</h2>
<span class="diag-pill <?= $ops_pill ?>"><?= htmlspecialchars($ops_pill_text) ?></span>
</div>
<div class="diag-counts">
<span class="dc ok">정상<b><?= $ops_ok_count ?></b></span>
<span class="dc warn">주의<b><?= $ops_warn_count ?></b></span>
<span class="dc crit">중요<b><?= $ops_critical_count ?></b></span>
</div>
<?php if (!empty($ops_issues)): ?>
<ul class="diag-issues">
<?php foreach ($ops_issues as $check):
// 색 외 채널: 심각도를 텍스트로도 구분(critical=중요, warn=주의)
$sev_text = $check['severity'] === 'critical' ? '확인 필요 · 중요' : '확인 필요 · 주의';
$issue_detail = $check['detail']
. ($check['fix'] !== '' ? ' — ' . $check['fix'] : '');
?>
<li class="diag-issue <?= htmlspecialchars($check['severity']) ?>">
<i class="diag-dot"></i>
<div>
<strong><?= htmlspecialchars($check['label']) ?> · <?= htmlspecialchars($sev_text) ?></strong>
<span><?= htmlspecialchars($issue_detail) ?></span>
</div>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if (!empty($ops_ok)): ?>
<details class="diag-ok">
<summary>정상 항목 <?= $ops_ok_count ?>건 보기</summary>
<ul class="diag-ok-grid">
<?php foreach ($ops_ok as $check): ?>
<li><i></i><span><?= htmlspecialchars($check['label']) ?></span></li>
<?php endforeach; ?>
</ul>
</details>
<?php endif; ?>
<p class="panel-note">보안대책서 첨부용 점검표는 상단의 보안 증적 화면에서 내려받을 수 있습니다.</p>
</aside>
</div>
</div>
<div class="zone">
<div class="zone-label"><b>실시간 상태</b> <span class="en">Live</span></div>
<!-- 센서 상태 -->
<h2 id="sensors-section">센서 현재 상태 <span class="refresh">30초 자동 새로고침<button type="button" id="refresh-toggle" class="refresh-toggle" aria-pressed="true" aria-label="자동 새로고침 일시정지">⏸ 일시정지</button></span></h2>
<div class="sensor-grid">
<?php foreach ($metric_statuses as $metric_sid => $s):
// 미수신 placeholder: 기대 온습도 센서(METRIC_SENSOR_IDS)가 아직 보고 전이면
// '데이터 없음' 카드로 항상 노출한다(누수 카드와 동일 — 화면에서 사라지지 않게).
if ($s === null): ?>
<div class="sensor-card">
<strong><?= (int)$metric_sid ?>번 센서 (온습도)</strong><br>
<div class="badge-row"><span class="badge offline">데이터 없음</span></div>
<div class="metric-time">아직 수신된 온습도 데이터가 없습니다.</div>
</div>
<?php continue; endif;
// 상태 우선순위: 오프라인 > 임계 경보 > 정상
$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'; }
?>
<div class="sensor-card <?= $card_class ?>">
<strong><?= htmlspecialchars($s['sensor_name']) ?></strong><br>
<small><?= htmlspecialchars($s['device_location']) ?> / <?= htmlspecialchars($s['device_id']) ?></small><br>
<div class="badge-row">
<span class="badge <?= $badge_class ?>">
<?= htmlspecialchars($badge_text) ?>
</span>
</div>
<?php if (!empty($s['latest_metric'])):
$metric = $s['latest_metric'];
$metric_temp = metric_display($metric['temperature_c'], '°C');
$metric_humidity = metric_display($metric['humidity_percent'], '%');
?>
<div class="metric-line">
<span class="metric-chip <?= $temp_chip_class ?>">온도 <?= htmlspecialchars($metric_temp) ?></span>
<span class="metric-chip <?= $hum_chip_class ?>">습도 <?= htmlspecialchars($metric_humidity) ?></span>
</div>
<div class="metric-time">측정: <?= htmlspecialchars($metric['measured_at']) ?></div>
<?php else: ?>
<div class="metric-time">측정값 없음</div>
<?php endif; ?>
<div class="metric-time<?= $s['is_offline'] ? ' stale' : '' ?>">
최종확인: <?= htmlspecialchars($s['last_seen']) ?>
<?php if ($s['is_offline']): ?>
(<?= $s['offline_elapsed_min'] ?>분 전)
<?php endif; ?>
</div>
<?php
// 카드 예보줄(상시): 온라인 + 예보 있으면 near/over 여유·ETA·결로·급변만 노출(혼잡 방지)
$sid_fc = (int)$s['sensor_id'];
$fc = (!$s['is_offline'] && isset($forecasts[$sid_fc])) ? $forecasts[$sid_fc] : null;
if ($fc):
$chips = [];
foreach ($fc['headroom'] as $h) {
if ($h['state'] === 'safe') { continue; } // 근접/초과만 강조
$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'] . '℃)'];
}
?>
<div class="forecast">
<?php if ($chips): foreach ($chips as $c): ?>
<span class="forecast-chip <?= $c['state'] ?>"><?= htmlspecialchars($c['text']) ?></span>
<?php endforeach; else: ?>
<span class="forecast-note">예보: 임계 여유 충분</span>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
<?php if (empty($metric_statuses)): ?>
<p class="empty-note">아직 수신된 센서 데이터가 없습니다.</p>
<?php endif; ?>
</div>
<!-- 누수 모듈 상태 + 사고 이력 + 사진 -->
<h2 id="leak-section">누수 감지</h2>
<div class="sensor-grid">
<?php foreach ($leak_statuses as $leak_sid => $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 = '정상';
}
?>
<div class="sensor-card leak-status-card <?= $leak_card_class ?>">
<?php if ($leak_status === null): ?>
<strong><?= (int)$leak_sid ?>번 센서 (누수)</strong><br>
<div class="badge-row">
<span class="badge <?= $leak_badge_class ?>"><?= htmlspecialchars($leak_badge_text) ?></span>
</div>
<div class="metric-time">아직 수신된 누수 센서 데이터가 없습니다.</div>
<?php else: ?>
<strong><?= htmlspecialchars($leak_status['sensor_name']) ?></strong><br>
<small><?= htmlspecialchars($leak_status['device_location']) ?> / <?= htmlspecialchars($leak_status['device_id']) ?></small><br>
<div class="badge-row">
<span class="badge <?= $leak_badge_class ?>"><?= htmlspecialchars($leak_badge_text) ?></span>
</div>
<div class="metric-time<?= $leak_status['is_offline'] ? ' stale' : '' ?>">
최종확인: <?= htmlspecialchars($leak_status['last_seen']) ?>
<?php if ($leak_status['is_offline']): ?>
(<?= $leak_status['offline_elapsed_min'] ?>분 전)
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<details class="mobile-detail" data-mobile-collapsed open>
<summary>누수 사고 이력 <span>최근 20건</span></summary>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="col-hide-xs">#</th><th>센서</th><th class="col-hide-xs">위치</th>
<th>감지 시각</th><th>복구 시각</th><th>사진</th>
</tr>
</thead>
<tbody>
<?php foreach ($leak_incidents as $inc):
$is_open = ($inc['recovered_at'] === null);
$lid = $inc['log_id_alert'] !== null ? (int)$inc['log_id_alert'] : 0;
$photos = $leak_photos_by_log[$lid] ?? [];
?>
<tr class="<?= $is_open ? 'alert-row' : '' ?>">
<td class="col-hide-xs"><?= (int)$inc['id'] ?></td>
<td><?= htmlspecialchars($inc['sensor_name']) ?></td>
<td class="col-hide-xs"><?= htmlspecialchars($inc['device_location']) ?></td>
<td><?= htmlspecialchars($inc['started_at']) ?></td>
<td>
<?php if ($is_open): ?>
<span class="pill leak">미복구</span>
<?php else: ?>
<?= htmlspecialchars($inc['recovered_at']) ?>
<?php endif; ?>
</td>
<td>
<?php if (!empty($photos)): ?>
<div class="leak-thumbs">
<?php foreach ($photos as $ph): ?>
<a href="photo.php?id=<?= (int)$ph['id'] ?>" target="_blank" rel="noopener">
<img src="photo.php?id=<?= (int)$ph['id'] ?>"
alt="누수 사진 <?= htmlspecialchars($ph['captured_at']) ?>"
loading="lazy">
</a>
<?php endforeach; ?>
</div>
<?php else: ?>
<span class="caption">-</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($leak_incidents)): ?>
<tr><td colspan="6" class="empty-cell">아직 누수 사고 이력이 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</details>
</div>
<div class="zone">
<div class="zone-label"><b>추세 분석</b> <span class="en">Trend</span></div>
<!-- 온습도 추이 차트 (인라인 SVG, 외부 라이브러리 없음) -->
<h2 id="chart-section">온습도 추이
<?php if ($chart_sensor_id > 0): ?>
<span class="h2-sub">
· <?= htmlspecialchars($chart_sensor_name !== '' ? $chart_sensor_name : ('센서 ' . $chart_sensor_id)) ?> 최근 <?= count($chart_points) ?>개
</span>
<?php endif; ?>
</h2>
<div class="chart-panel">
<div class="chart-head">
<div class="chart-legend" style="margin:0">
<span><i class="legend-swatch temp"></i> 온도(°C)</span>
<span><i class="legend-swatch hum"></i> 습도(%)</span>
</div>
<div class="range-toggle" role="group" aria-label="추이 기간 선택">
<?php foreach (['today' => '오늘', '3d' => '3일', '7d' => '7일'] as $rk => $rlabel): ?>
<a href="?range=<?= $rk ?>#chart-section"<?= $chart_range === $rk ? ' aria-current="true"' : '' ?>><?= htmlspecialchars($rlabel) ?></a>
<?php endforeach; ?>
</div>
</div>
<?php if (count($chart_points) >= 2): ?>
<div class="chart-svg-wrap">
<?php
// ── 인라인 SVG 라인 차트 (온도/습도 2계열) ──────────────
$chart_w = 920; $chart_h = 280;
$pad_l = 44; $pad_r = 44; $pad_t = 16; $pad_b = 34;
$plot_w = $chart_w - $pad_l - $pad_r;
$plot_h = $chart_h - $pad_t - $pad_b;
$n = count($chart_points);
$temps = array_values(array_filter(array_map(fn($p) => $p['temp'], $chart_points), fn($v) => $v !== null));
$hums = array_values(array_filter(array_map(fn($p) => $p['hum'], $chart_points), fn($v) => $v !== null));
// 온도 축 범위 (여유 패딩)
$t_min = !empty($temps) ? min($temps) : 0;
$t_max = !empty($temps) ? max($temps) : 1;
if ($t_min === $t_max) { $t_min -= 1; $t_max += 1; }
$t_pad = ($t_max - $t_min) * 0.1;
$t_min -= $t_pad; $t_max += $t_pad;
// 습도 축 범위
$h_min = !empty($hums) ? min($hums) : 0;
$h_max = !empty($hums) ? max($hums) : 1;
if ($h_min === $h_max) { $h_min -= 1; $h_max += 1; }
$h_pad = ($h_max - $h_min) * 0.1;
$h_min -= $h_pad; $h_max += $h_pad;
$x_at = function (int $i) use ($pad_l, $plot_w, $n): float {
if ($n <= 1) { return $pad_l; }
return $pad_l + ($plot_w * $i / ($n - 1));
};
$y_temp = function (?float $v) use ($pad_t, $plot_h, $t_min, $t_max): ?float {
if ($v === null) { return null; }
$r = ($t_max - $t_min) ?: 1;
return $pad_t + $plot_h * (1 - (($v - $t_min) / $r));
};
$y_hum = function (?float $v) use ($pad_t, $plot_h, $h_min, $h_max): ?float {
if ($v === null) { return null; }
$r = ($h_max - $h_min) ?: 1;
return $pad_t + $plot_h * (1 - (($v - $h_min) / $r));
};
// 결측(null)은 구간을 끊어 polyline 들을 만든다
$build_segments = function (string $key, callable $y_fn) use ($chart_points, $x_at): array {
$segments = [];
$current = [];
foreach ($chart_points as $i => $p) {
$val = $p[$key];
if ($val === null) {
if (count($current) >= 2) { $segments[] = $current; }
$current = [];
continue;
}
$current[] = sprintf('%.1f,%.1f', $x_at($i), $y_fn((float)$val));
}
if (count($current) >= 2) { $segments[] = $current; }
return $segments;
};
$temp_segments = $build_segments('temp', $y_temp);
$hum_segments = $build_segments('hum', $y_hum);
// 각 계열의 마지막 유효 지점(끝점 마커 + 최신값 라벨용)
$last_temp_i = null; $last_temp_v = null;
$last_hum_i = null; $last_hum_v = null;
foreach ($chart_points as $i => $p) {
if ($p['temp'] !== null) { $last_temp_i = $i; $last_temp_v = (float)$p['temp']; }
if ($p['hum'] !== null) { $last_hum_i = $i; $last_hum_v = (float)$p['hum']; }
}
$fmt = fn($v) => rtrim(rtrim(number_format($v, 1, '.', ''), '0'), '.');
?>
<svg viewBox="0 0 <?= $chart_w ?> <?= $chart_h ?>" width="100%" height="<?= $chart_h ?>"
preserveAspectRatio="xMidYMid meet" role="img"
aria-label="온습도 추이 라인 차트" class="chart-svg">
<!-- 플롯 배경/축 -->
<rect x="<?= $pad_l ?>" y="<?= $pad_t ?>" width="<?= $plot_w ?>" height="<?= $plot_h ?>"
class="plot-bg"></rect>
<?php
// 가로 그리드 + 좌(온도)/우(습도) 눈금 라벨 (5단계)
for ($g = 0; $g <= 4; $g++):
$gy = $pad_t + $plot_h * $g / 4;
$tv = $t_max - ($t_max - $t_min) * $g / 4;
$hv = $h_max - ($h_max - $h_min) * $g / 4;
?>
<line x1="<?= $pad_l ?>" y1="<?= sprintf('%.1f', $gy) ?>"
x2="<?= $pad_l + $plot_w ?>" y2="<?= sprintf('%.1f', $gy) ?>"
class="grid-line" stroke-dasharray="3,3"></line>
<text x="<?= $pad_l - 6 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>"
text-anchor="end" font-size="10" class="axis-temp"><?= htmlspecialchars($fmt($tv)) ?></text>
<text x="<?= $pad_l + $plot_w + 6 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>"
text-anchor="start" font-size="10" class="axis-hum"><?= htmlspecialchars($fmt($hv)) ?></text>
<?php endfor; ?>
<!-- X축 시간 라벨 (시작/중간/끝) -->
<?php
$label_idx = $n > 1 ? [0, intdiv($n - 1, 2), $n - 1] : [0];
foreach ($label_idx as $li):
$lx = $x_at($li);
$anchor = $li === 0 ? 'start' : ($li === $n - 1 ? 'end' : 'middle');
$tlabel = substr($chart_points[$li]['t'], 5, 11); // MM-DD HH:MM
?>
<text x="<?= sprintf('%.1f', $lx) ?>" y="<?= $chart_h - 12 ?>"
text-anchor="<?= $anchor ?>" font-size="10" class="axis-x"><?= htmlspecialchars($tlabel) ?></text>
<?php endforeach; ?>
<!-- 임계선 오버레이 (폴리라인과 동일한 y 스케일; -1/미정의는 생략, 스케일 밖도 생략) -->
<?php
// [임계값, y 계산 클로저, 축(temp|hum), 라벨, 단위]
$threshold_defs = [
[METRIC_TEMP_HIGH_C, $y_temp, 'temp', '고온', '℃'],
[METRIC_TEMP_LOW_C, $y_temp, 'temp', '저온', '℃'],
[METRIC_RH_HIGH, $y_hum, 'hum', '고습', '%'],
[METRIC_RH_LOW, $y_hum, 'hum', '저습', '%'],
];
foreach ($threshold_defs as [$cval, $yfn, $axis, $tlabel, $unit]):
if ($cval === null || (float)$cval === -1.0) { continue; } // 비활성
$ty = $yfn((float)$cval);
if ($ty === null || $ty < $pad_t || $ty > $pad_t + $plot_h) { continue; } // 스케일 밖
$thr_line_cls = $axis === 'temp' ? 'line-temp' : 'line-hum';
$thr_label_cls = $axis === 'temp' ? 'axis-temp' : 'axis-hum';
$thr_lx = $axis === 'temp' ? ($pad_l + 4) : ($pad_l + $plot_w - 4);
$thr_anchor = $axis === 'temp' ? 'start' : 'end';
?>
<line x1="<?= $pad_l ?>" y1="<?= sprintf('%.1f', $ty) ?>"
x2="<?= $pad_l + $plot_w ?>" y2="<?= sprintf('%.1f', $ty) ?>"
class="<?= $thr_line_cls ?>" stroke-width="1" stroke-dasharray="2,4" opacity="0.85"></line>
<text x="<?= sprintf('%.1f', $thr_lx) ?>" y="<?= sprintf('%.1f', $ty - 3) ?>"
text-anchor="<?= $thr_anchor ?>" font-size="9"
class="<?= $thr_label_cls ?>"><?= htmlspecialchars($tlabel . ' ' . $fmt((float)$cval) . $unit) ?></text>
<?php endforeach; ?>
<!-- 습도 라인 (파선 — 흑백 인쇄 시 온도선과 형태로 구분) -->
<?php foreach ($hum_segments as $seg): ?>
<polyline points="<?= htmlspecialchars(implode(' ', $seg)) ?>"
class="line-hum" fill="none" stroke-width="1.8" stroke-dasharray="6,3"
stroke-linejoin="round" stroke-linecap="round"></polyline>
<?php endforeach; ?>
<!-- 온도 라인 -->
<?php foreach ($temp_segments as $seg): ?>
<polyline points="<?= htmlspecialchars(implode(' ', $seg)) ?>"
class="line-temp" fill="none" stroke-width="1.8"
stroke-linejoin="round" stroke-linecap="round"></polyline>
<?php endforeach; ?>
<!-- 각 계열 마지막 점: 채운 원 + 최신값 라벨(폴리라인과 동일 스케일) -->
<?php if ($last_hum_i !== null):
$hx = $x_at($last_hum_i); $hy = $y_hum($last_hum_v);
?>
<circle cx="<?= sprintf('%.1f', $hx) ?>" cy="<?= sprintf('%.1f', $hy) ?>" r="3" class="axis-hum"></circle>
<text x="<?= sprintf('%.1f', $hx - 5) ?>" y="<?= sprintf('%.1f', $hy - 5) ?>"
text-anchor="end" font-size="10" class="axis-hum"><?= htmlspecialchars($fmt($last_hum_v) . '%') ?></text>
<?php endif; ?>
<?php if ($last_temp_i !== null):
$tx = $x_at($last_temp_i); $tyv = $y_temp($last_temp_v);
?>
<circle cx="<?= sprintf('%.1f', $tx) ?>" cy="<?= sprintf('%.1f', $tyv) ?>" r="3" class="axis-temp"></circle>
<text x="<?= sprintf('%.1f', $tx - 5) ?>" y="<?= sprintf('%.1f', $tyv - 5) ?>"
text-anchor="end" font-size="10" class="axis-temp"><?= htmlspecialchars($fmt($last_temp_v) . '℃') ?></text>
<?php endif; ?>
<!-- 피크(기간 내 최고 온도) 마커 + 플래그 라벨 -->
<?php if ($peak_i !== null):
$pk_x = $x_at($peak_i); $pk_y = $y_temp($peak_v);
$pk_fw = 66;
$pk_fx = min(max($pk_x - $pk_fw / 2, $pad_l), $pad_l + $plot_w - $pk_fw);
?>
<line x1="<?= sprintf('%.1f', $pk_x) ?>" y1="<?= sprintf('%.1f', $pk_y) ?>"
x2="<?= sprintf('%.1f', $pk_x) ?>" y2="<?= $pad_t ?>" class="grid-line"></line>
<rect x="<?= sprintf('%.1f', $pk_fx) ?>" y="<?= $pad_t - 15 ?>" width="<?= $pk_fw ?>" height="14" rx="4" class="peak-flag"></rect>
<text x="<?= sprintf('%.1f', $pk_fx + $pk_fw / 2) ?>" y="<?= $pad_t - 4 ?>"
text-anchor="middle" font-size="10" class="peak-label"><?= htmlspecialchars('피크 ' . $fmt($peak_v) . '℃') ?></text>
<circle cx="<?= sprintf('%.1f', $pk_x) ?>" cy="<?= sprintf('%.1f', $pk_y) ?>" r="4" class="peak-marker"></circle>
<?php endif; ?>
</svg>
</div>
<?php else: ?>
<div class="chart-empty">추이를 표시할 측정 데이터가 충분하지 않습니다 (2개 이상 필요).</div>
<?php endif; ?>
</div>
</div>
<div class="zone">
<div class="zone-label"><b>운영 이력</b> <span class="en">History</span></div>
<section class="panel" id="summary-section">
<h2>30일 운영 요약</h2>
<div class="report-grid">
<div class="report-stat">
<span>고온 경보 SMS</span>
<strong><?= $summary['high_temp'] ?>건</strong>
</div>
<div class="report-stat">
<span>저온 경보 SMS</span>
<strong><?= $summary['low_temp'] ?>건</strong>
</div>
<div class="report-stat">
<span>고습 경보 SMS</span>
<strong><?= $summary['high_humidity'] ?>건</strong>
</div>
<div class="report-stat">
<span>저습 경보 SMS</span>
<strong><?= $summary['low_humidity'] ?>건</strong>
</div>
<div class="report-stat">
<span>정상 복귀 SMS</span>
<strong><?= $summary['recovery'] ?>건</strong>
</div>
<div class="report-stat">
<span>오프라인 SMS</span>
<strong><?= $summary['offline'] ?>건</strong>
</div>
<div class="report-stat">
<span>장비 복구 SMS</span>
<strong><?= $summary['device_online'] ?>건</strong>
</div>
<div class="report-stat">
<span>누수감지 SMS</span>
<strong class="<?= $summary['leak_alert'] > 0 ? 'err-color' : '' ?>"><?= $summary['leak_alert'] ?>건</strong>
</div>
<div class="report-stat">
<span>누수복구 SMS</span>
<strong><?= $summary['leak_recovery'] ?>건</strong>
</div>
<div class="report-stat">
<span>SMS 실패</span>
<strong class="<?= $summary['sms_fail'] > 0 ? 'err-color' : '' ?>"><?= $summary['sms_fail'] ?>건</strong>
</div>
<div class="report-stat">
<span>평균 온도</span>
<strong><?= $avg_temp !== null ? htmlspecialchars((string)$avg_temp) . '°C' : '-' ?></strong>
</div>
<div class="report-stat">
<span>평균 습도</span>
<strong><?= $avg_hum !== null ? htmlspecialchars((string)$avg_hum) . '%' : '-' ?></strong>
</div>
<div class="report-stat">
<span>최근 온도</span>
<strong><?= htmlspecialchars(metric_display($latest_temp !== null ? (string)$latest_temp : null, '°C')) ?></strong>
</div>
<div class="report-stat">
<span>최근 습도</span>
<strong><?= htmlspecialchars(metric_display($latest_hum !== null ? (string)$latest_hum : null, '%')) ?></strong>
</div>
</div>
</section>
<!-- 최근 온습도 측정 이력 -->
<h2 id="metrics-section">최근 온습도 측정 (30건)</h2>
<details class="mobile-detail" 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>온도</th><th>습도</th><th>상태</th>
</tr>
</thead>
<tbody>
<?php foreach ($recent_metrics 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><?= htmlspecialchars(metric_display($row['temperature_c'], '°C')) ?></td>
<td><?= htmlspecialchars(metric_display($row['humidity_percent'], '%')) ?></td>
<td><span class="pill <?= htmlspecialchars($status_code) ?>"><?= htmlspecialchars(metric_status_label($status_code)) ?></span></td>
</tr>
<?php endforeach; ?>
<?php if (empty($recent_metrics)): ?>
<tr><td colspan="6" class="empty-cell">아직 온습도 측정 데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</details>
<!-- 센서 이벤트 로그 (startup/periodic) -->
<h2 id="logs-section">센서 이벤트 로그 (30건)</h2>
<details class="mobile-detail" data-mobile-collapsed open>
<summary>센서 이벤트 로그 <span>30건 기준</span></summary>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="col-hide-xs">#</th><th>시각</th><th>센서</th>
<th class="col-hide-xs">위치</th><th>이벤트</th>
</tr>
</thead>
<tbody>
<?php foreach ($logs as $row):
$event_labels = [
'startup' => '장비시작',
'periodic' => '정기보고',
'leak_alert' => '누수감지',
'leak_recovery' => '누수복구',
];
$evt = $row['event_type'];
$is_leak_evt = ($evt === 'leak_alert' || $evt === 'leak_recovery' || (int)($row['is_leak'] ?? 0) === 1);
?>
<tr class="<?= $evt === 'leak_alert' ? 'alert-row' : '' ?>">
<td class="col-hide-xs"><?= (int)$row['id'] ?></td>
<td><?= htmlspecialchars($row['sensor_time']) ?></td>
<td><?= htmlspecialchars($row['sensor_name']) ?></td>
<td class="col-hide-xs"><?= htmlspecialchars($row['device_location']) ?></td>
<td>
<?php if ($evt === 'leak_alert' || $evt === 'leak_recovery'): ?>
<span class="pill <?= htmlspecialchars($evt) ?>"><?= htmlspecialchars($event_labels[$evt]) ?></span>
<?php else: ?>
<?= htmlspecialchars($event_labels[$evt] ?? $evt) ?>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($logs)): ?>
<tr><td colspan="5" class="empty-cell">아직 센서 이벤트가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</details>
<!-- SMS 발송 로그 -->
<h2 id="sms-section">SMS 발송 이력 (최근 20건)</h2>
<details class="mobile-detail" data-mobile-collapsed open>
<summary>SMS 발송 이력 <span>최근 20건</span></summary>
<div class="table-wrap">
<table>
<thead>
<tr><th class="col-hide-xs">#</th><th>발송시각</th><th>센서</th><th>수신자</th><th>상태</th><th class="col-hide-xs">결과</th></tr>
</thead>
<tbody>
<?php foreach ($sms_logs as $row): ?>
<tr>
<td class="col-hide-xs"><?= (int)$row['id'] ?></td>
<td><?= htmlspecialchars($row['sent_at']) ?></td>
<td><?= htmlspecialchars($row['sensor_name']) ?></td>
<td><?= htmlspecialchars(mask_phone($row['receivers'] ?? '')) ?></td>
<td>
<?php if ($row['status'] === 'success'): ?>
<span class="ok-color">성공</span>
<?php else: ?>
<span class="err-color">실패</span>
<?php endif; ?>
</td>
<td class="col-hide-xs caption"><?= htmlspecialchars($row['result_message']) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($sms_logs)): ?>
<tr><td colspan="6" class="empty-cell">아직 SMS 발송 이력이 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</details>
</div>
</main>
<script>
(function () {
'use strict';
var REFRESH_MS = 30000;
var LS_REFRESH = 'leakDash.autoRefresh'; // 'on' | 'off' (기본 on)
var SS_SCROLL = 'leakDash.scrollY'; // reload 직전 저장
var SS_DETAILS = 'leakDash.detailsOpen'; // 열린 mobile-detail 인덱스 JSON
var details = Array.prototype.slice.call(
document.querySelectorAll('details.mobile-detail')
);
// ── mobile-detail 펼침 상태 저장/복원 ──────────────────────
function saveDetailsState() {
try {
var openIdx = [];
details.forEach(function (d, i) { if (d.open) { openIdx.push(i); } });
sessionStorage.setItem(SS_DETAILS, JSON.stringify(openIdx));
} catch (e) { /* storage 불가 시 무시 */ }
}
function restoreDetailsState() {
try {
var raw = sessionStorage.getItem(SS_DETAILS);
if (raw === null) { return; }
var openIdx = JSON.parse(raw);
if (!Array.isArray(openIdx)) { return; }
details.forEach(function (d, i) {
if (openIdx.indexOf(i) !== -1) { d.setAttribute('open', ''); }
else { d.removeAttribute('open'); }
});
} catch (e) { /* 무시 */ }
}
details.forEach(function (d) { d.addEventListener('toggle', saveDetailsState); });
// ── 모바일 기본 접힘(최초 방문만) ──────────────────────────
function applyMobileDisclosureState() {
var shouldCollapse = window.matchMedia('(max-width: 768px)').matches;
document.querySelectorAll('details[data-mobile-collapsed]').forEach(function (detail) {
if (shouldCollapse && !detail.dataset.mobileInitialized) {
detail.removeAttribute('open');
detail.dataset.mobileInitialized = '1';
}
if (!shouldCollapse) {
detail.setAttribute('open', '');
delete detail.dataset.mobileInitialized;
}
});
}
applyMobileDisclosureState();
window.addEventListener('resize', applyMobileDisclosureState);
// 사용자가 이전에 고른 펼침 상태가 기본값보다 우선
restoreDetailsState();
// ── 스크롤 위치 복원(펼침 반영 후 레이아웃 안정 시점) ──────
try {
var savedY = sessionStorage.getItem(SS_SCROLL);
if (savedY !== null) {
window.scrollTo(0, parseInt(savedY, 10) || 0);
sessionStorage.removeItem(SS_SCROLL);
}
} catch (e) { /* 무시 */ }
// ── 자동 새로고침 토글(WCAG 2.2.1: 끌 수 있어야 함) ────────
var timer = null;
var btn = document.getElementById('refresh-toggle');
function isOn() {
try { return localStorage.getItem(LS_REFRESH) !== 'off'; } // 기본 on
catch (e) { return true; }
}
function scheduleReload() {
if (timer !== null) { window.clearTimeout(timer); timer = null; }
if (!isOn()) { return; } // off면 예약 안 함
timer = window.setTimeout(function () {
try { sessionStorage.setItem(SS_SCROLL, String(window.scrollY)); } catch (e) {}
saveDetailsState();
location.reload();
}, REFRESH_MS);
}
function renderBtn() {
if (!btn) { return; }
var on = isOn();
btn.textContent = on ? '⏸ 일시정지' : '▶ 재개';
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
btn.setAttribute('aria-label', on ? '자동 새로고침 일시정지' : '자동 새로고침 재개');
}
if (btn) {
btn.addEventListener('click', function () {
try { localStorage.setItem(LS_REFRESH, isOn() ? 'off' : 'on'); } catch (e) {}
renderBtn();
scheduleReload();
});
}
renderBtn();
scheduleReload();
})();
</script>
</body>
</html>