기존 측정 데이터만으로 "경보가 터지기 전"을 보여주고, 위험 시 무엇을 할지 안내한다. 새 센서·ML·DB 스키마 변경 없음(폐쇄망 정합). 신규 php/forecast.php — 순수 계산 함수(DB·출력 없음, 단위테스트 용이): - dew_point_c(Magnus 이슬점), linear_trend(최소자승 회귀), threshold_eta_min (임계 도달 예상 분), headroom_state(여유 3단계), forecast_metric/freshness, risk_items(대응 섹션용 정규화 위험 목록). tests/forecast_test.php — 31 검사(이슬점 알려진값·회귀·ETA·여유·신선도·위험 엣지). config.php — FORECAST_*(지평/최소점/근접여유/급변 기준) + RESPONSE_PLAYBOOKS (상태별 대응 체크리스트) + RESPONSE_CONTACTS. 전부 config.local.php 오버라이드. dashboard.php — 센서별 최근점으로 예보 계산 → - 카드 예보줄(상시): 임계 여유·추세 ETA·이슬점 결로·변화율(근접/초과만 강조). - 대응 필요 섹션(위험 시만): 조건·ETA + 상태별 권장 조치 체크리스트 + 담당자. 기존 offline 판정을 단일 진실원으로 사용, 누수 노드 중복 없음. app.css — .forecast-chip(near/over 상태 3중 인코딩)·.response-panel·.playbook· .contact (토큰 기반, 라이트/다크 자동). run_tests.sh 에 forecast_test 등록. 검증: forecast_test 31/0, php -l clean(7페이지+forecast), 헤드리스 렌더 확인.
1260 lines
59 KiB
PHP
1260 lines
59 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;
|
|
}
|
|
}
|
|
|
|
$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
|
|
ORDER BY id DESC
|
|
LIMIT 100
|
|
");
|
|
$chart_stmt->execute([':sid' => $chart_sensor_id]);
|
|
$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'
|
|
));
|
|
|
|
// ── 상단 시스템 상태 집계 ──────────────────────────────────
|
|
$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;
|
|
}
|
|
}
|
|
|
|
// 최근 누수 사고(에피소드) 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));
|
|
}
|
|
?>
|
|
<!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="ops-grid">
|
|
<div>
|
|
<!-- 요약 카드 -->
|
|
<div class="cards">
|
|
<div class="card">
|
|
<div>등록 센서</div>
|
|
<div class="val"><?= count($statuses) ?>개</div>
|
|
</div>
|
|
<div class="card">
|
|
<div>경보 센서</div>
|
|
<div class="val <?= $alert_count > 0 ? 'err-color' : 'ok-color' ?>">
|
|
<?= $alert_count ?>개
|
|
</div>
|
|
</div>
|
|
<div class="card">
|
|
<div>오프라인</div>
|
|
<div class="val <?= $offline_count > 0 ? 'warn-color' : 'ok-color' ?>">
|
|
<?= $offline_count ?>개
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="cards flush">
|
|
<div class="card">
|
|
<div>최근 온도</div>
|
|
<div class="val compact"><?= htmlspecialchars(metric_display($latest_temp !== null ? (string)$latest_temp : null, '°C')) ?></div>
|
|
</div>
|
|
<div class="card">
|
|
<div>최근 습도</div>
|
|
<div class="val compact"><?= htmlspecialchars(metric_display($latest_hum !== null ? (string)$latest_hum : null, '%')) ?></div>
|
|
</div>
|
|
<div class="card">
|
|
<div>현재 시각</div>
|
|
<div class="val clock"><?= date('Y-m-d H:i:s') ?></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<aside class="panel" id="diagnostics-section">
|
|
<h2>운영 자가진단</h2>
|
|
<div class="diag-summary">
|
|
실패 <?= $ops_failed ?>건 · 중요 <?= $ops_critical_count ?>건 · 주의 <?= $ops_warn_count ?>건
|
|
</div>
|
|
<div class="diag-list">
|
|
<?php foreach ($ops_checks as $check):
|
|
$diag_class = $check['ok'] ? 'ok' : $check['severity'];
|
|
// 색 외 채널: 심각도를 텍스트로도 구분(critical=중요, warn=주의)
|
|
$diag_status_text = $check['ok']
|
|
? '정상'
|
|
: ($check['severity'] === 'critical' ? '확인 필요 · 중요' : '확인 필요 · 주의');
|
|
?>
|
|
<div class="diag-item <?= htmlspecialchars($diag_class) ?>">
|
|
<i class="diag-dot"></i>
|
|
<div>
|
|
<strong><?= htmlspecialchars($check['label']) ?> · <?= htmlspecialchars($diag_status_text) ?></strong>
|
|
<span><?= htmlspecialchars($check['detail']) ?></span>
|
|
</div>
|
|
</div>
|
|
<?php endforeach; ?>
|
|
</div>
|
|
<p class="panel-note">보안대책서 첨부용 점검표는 상단의 보안 증적 화면에서 내려받을 수 있습니다.</p>
|
|
</aside>
|
|
</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 ($statuses as $s):
|
|
// 상태 우선순위: 오프라인 > 임계 경보 > 정상
|
|
$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($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>
|
|
|
|
<!-- 온습도 추이 차트 (인라인 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">
|
|
<?php if (count($chart_points) >= 2): ?>
|
|
<div class="chart-legend">
|
|
<span><i class="legend-swatch temp"></i> 온도(°C)</span>
|
|
<span><i class="legend-swatch hum"></i> 습도(%)</span>
|
|
</div>
|
|
<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; ?>
|
|
</svg>
|
|
</div>
|
|
<?php else: ?>
|
|
<div class="chart-empty">추이를 표시할 측정 데이터가 충분하지 않습니다 (2개 이상 필요).</div>
|
|
<?php endif; ?>
|
|
</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>
|
|
|
|
</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>
|