POSA_LEAKSMS/php/dashboard.php
유창욱 90f121e14c chore: import codebase with security hardening
SHT30 온습도 모니터링 시스템 전체 소스(서버 PHP, STM32 펌웨어, SQL, 테스트).
전체 코드리뷰에서 도출된 보안 하드닝 10건 반영:
- 요청 서명 HMAC-SHA256 전환(펌웨어 sig.c/서버 config.php/호스트 패리티 동시)
- 재전송 방어 + 기본 API_KEY fail-closed + 디바이스 문자열 정제(api/sensor_data.php)
- 오프라인 SMS 중복 발송 경합 제거(cron_heartbeat.php, 원자적 선점)
- CSV 수식 주입 방지(monthly_report.php), 감사로그 회전 락(retention_cleanup.php)
- 브루트포스 카운터 원자화(login.php), 예시 TOTP 비밀키 무효화, 마이그레이션 멱등화

_backup/(하드코딩 실 비밀값 포함)·config.local.php·런타임 상태는 .gitignore 제외.
2026-06-20 09:37:40 +09:00

897 lines
43 KiB
PHP

<?php
// =============================================================================
// dashboard.php - SHT30 온습도 모니터링 대시보드
// cafe24 웹호스팅에서 직접 브라우저로 접근하는 관리 페이지
//
// 온습도 임계 경보(고온/저온/고습/저습)와 장비 오프라인/복구를 모니터링한다.
// 임계 판정과 SMS
// 발송은 서버(api/sensor_data.php + config.php METRIC_* 상수)에서 수행하고,
// 이 대시보드는 그 결과(sensor_metric/sensor_status/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;
}
// 세션 타임아웃 확인
if (isset($_SESSION['login_time']) && (time() - $_SESSION['login_time']) > SESSION_TIMEOUT) {
session_destroy();
header('Location: login.php?expired');
exit;
}
// 활동 시 로그인 시간 갱신
$_SESSION['login_time'] = time();
// ── 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;
}
// ── 센서별 현재 상태 + 최신 측정값 ──────────────────────────
$statuses = $db->query("SELECT * FROM sensor_status ORDER BY sensor_id")->fetchAll();
// 센서별 최신 sensor_metric (최대 id 기준)
$latest_metrics = [];
$metric_rows = $db->query("
SELECT sm.*
FROM sensor_metric sm
INNER JOIN (
SELECT sensor_id, MAX(id) AS latest_id
FROM 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 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,
];
}
}
// ── 최근 측정 이력 (sensor_metric 30건) ─────────────────────
$recent_metrics = $db->query("
SELECT sensor_id, sensor_name, device_location,
temperature_c, humidity_percent, metric_status, measured_at
FROM sensor_metric
ORDER BY id DESC
LIMIT 30
")->fetchAll();
// ── 최근 센서 이벤트 로그 (sensor_log: startup/periodic) ────
$logs = $db->query("
SELECT id, sensor_name, device_location, event_type, sensor_time
FROM sensor_log
ORDER BY id DESC
LIMIT 30
")->fetchAll();
// ── 최근 SMS 발송 이력 ─────────────────────────────────────
$sms_logs = $db->query("
SELECT * FROM sms_log
ORDER BY id DESC
LIMIT 20
")->fetchAll();
// ── 30일 운영 요약 ─────────────────────────────────────────
// 종류별 임계 경보 SMS 건수 (sms_log message prefix 기준)
$sms_summary_rows = $db->query("
SELECT
SUM(message LIKE '[고온경보]%') AS high_temp,
SUM(message LIKE '[저온경보]%') AS low_temp,
SUM(message LIKE '[고습경보]%') AS high_humidity,
SUM(message LIKE '[저습경보]%') AS low_humidity,
SUM(message LIKE '[정상복귀]%') AS recovery,
SUM(message LIKE '[장비 오프라인]%') AS offline,
SUM(message LIKE '[장비 복구]%') AS device_online,
SUM(status = 'fail') AS sms_fail
FROM 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['offline'] ?? 0),
'device_online' => (int)($sms_summary_rows['device_online'] ?? 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 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;
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 = '현재 온습도 경보와 중요 운영 경고가 없습니다.';
}
?>
<!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>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Noto Sans KR', sans-serif; background: #f0f4f8; color: #333; }
header { background: #1a56db; color: #fff; padding: 16px 24px; }
header h1 { font-size: 1.3rem; }
.top-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.top-actions a { color:#fff; font-size:.85rem; text-decoration:none; background:rgba(255,255,255,.15); padding:6px 14px; border-radius:6px; }
.container { max-width: 1100px; margin: 24px auto; padding: 0 16px; }
.status-hero { border-radius: 8px; color: #fff; padding: 18px 22px; margin-bottom: 18px;
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 16px;
align-items: center; box-shadow: 0 4px 18px rgba(15,23,42,.14); }
.status-hero.normal { background: #166534; }
.status-hero.warning { background: #92400e; }
.status-hero.danger { background: #991b1b; }
.status-hero h2 { color: #fff; margin: 0 0 6px; font-size: 1.25rem; }
.status-hero p { color: rgba(255,255,255,.88); font-size: .9rem; }
.status-clock { text-align: right; font-size: .82rem; color: rgba(255,255,255,.82); white-space: nowrap; }
.mobile-quick { display: none; }
.quick-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-bottom: 10px; }
.quick-tile { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px;
text-decoration: none; color: #1f2937; min-height: 70px; }
.quick-tile span { display: block; color: #64748b; font-size: .68rem; margin-bottom: 5px; }
.quick-tile strong { font-size: 1.25rem; line-height: 1; }
.quick-tile.danger { border-color: #fecaca; background: #fff1f2; }
.quick-tile.warn { border-color: #fde68a; background: #fffbeb; }
.quick-tile.ok { border-color: #bbf7d0; background: #f0fdf4; }
.quick-links { display: flex; gap: 8px; overflow-x: auto; -webkit-overflow-scrolling: touch;
padding-bottom: 6px; margin-bottom: 16px; }
.quick-links a { flex: 0 0 auto; color: #1d4ed8; background: #eff6ff; border: 1px solid #bfdbfe;
text-decoration: none; border-radius: 999px; padding: 8px 11px; font-size: .78rem; font-weight: 700; }
.ops-grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(280px, .9fr);
gap: 16px; align-items: start; margin-bottom: 24px; }
.panel { background: #fff; border-radius: 8px; padding: 18px;
box-shadow: 0 1px 4px rgba(0,0,0,.1); }
.panel h2 { margin-top: 0; }
.diag-list { display: grid; gap: 8px; }
.diag-item { border: 1px solid #e2e8f0; border-radius: 7px; padding: 9px 10px;
display: grid; grid-template-columns: auto minmax(0,1fr); gap: 8px; align-items: start; }
.diag-dot { width: 10px; height: 10px; border-radius: 50%; margin-top: 4px; background: #22c55e; }
.diag-item.warn .diag-dot { background: #f59e0b; }
.diag-item.critical .diag-dot { background: #ef4444; }
.diag-item strong { display: block; font-size: .82rem; }
.diag-item span { color: #64748b; font-size: .74rem; line-height: 1.35; }
.report-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
.report-stat { border: 1px solid #e2e8f0; border-radius: 7px; padding: 10px; }
.report-stat span { display: block; color: #64748b; font-size: .72rem; margin-bottom: 4px; }
.report-stat strong { font-size: 1.05rem; }
.cards { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 24px; }
.card { background: #fff; border-radius: 8px; padding: 20px 24px;
box-shadow: 0 1px 4px rgba(0,0,0,.1); flex: 1; min-width: 180px; }
.card .val { font-size: 2rem; font-weight: 700; margin-top: 6px; }
.sensor-grid { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 24px; }
.sensor-card { background: #fff; border-radius: 8px; padding: 20px;
box-shadow: 0 1px 4px rgba(0,0,0,.1);
border-left: 6px solid #22c55e; min-width: 200px; }
.sensor-card.alert { border-left-color: #ef4444; background: #fff5f5; }
.sensor-card.offline { border-left-color: #f59e0b; background: #fffbeb; }
.sensor-card .badge { display: inline-block; padding: 2px 10px; border-radius: 9999px;
font-size: .75rem; font-weight: 600; }
.badge.ok { background: #dcfce7; color: #166534; }
.badge.alert { background: #fee2e2; color: #991b1b; }
.badge.offline { background: #fef3c7; color: #92400e; }
.metric-line { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.metric-chip { display: inline-flex; align-items: center; border-radius: 999px;
background: #eef2ff; color: #3730a3; padding: 4px 8px;
font-size: .76rem; font-weight: 700; }
.metric-chip.hot { background: #fee2e2; color: #991b1b; }
.metric-chip.cold { background: #dbeafe; color: #1e40af; }
.metric-chip.humid { background: #fef9c3; color: #854d0e; }
.metric-time { color: #64748b; font-size: .72rem; margin-top: 6px; }
table { width: 100%; border-collapse: collapse; background: #fff;
border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
th { background: #f8fafc; font-size: .8rem; color: #64748b;
padding: 10px 14px; text-align: left; border-bottom: 1px solid #e2e8f0; }
td { padding: 9px 14px; border-bottom: 1px solid #f1f5f9; font-size: .85rem; }
tr:hover td { background: #f8fafc; }
.alert-row td { background: #fef2f2 !important; }
h2 { margin: 24px 0 12px; font-size: 1rem; color: #475569; }
.refresh { float: right; font-size: .8rem; color: #94a3b8; }
.ok-color { color: #16a34a; font-weight: 600; }
.err-color { color: #dc2626; font-weight: 600; }
.warn-color { color: #d97706; font-weight: 600; }
/* 상태 배지 (테이블) */
.pill { display: inline-block; border-radius: 999px; padding: 2px 9px;
font-size: .72rem; font-weight: 700; background: #dcfce7; color: #166534; }
.pill.high_temp { background: #fee2e2; color: #991b1b; }
.pill.low_temp { background: #dbeafe; color: #1e40af; }
.pill.high_humidity { background: #fef9c3; color: #854d0e; }
.pill.low_humidity { background: #ede9fe; color: #6d28d9; }
/* 온습도 추이 차트 */
.chart-panel { background: #fff; border-radius: 8px; padding: 18px;
box-shadow: 0 1px 4px rgba(0,0,0,.1); margin-bottom: 24px; }
.chart-legend { display: flex; gap: 16px; flex-wrap: wrap; margin: 6px 0 12px; font-size: .8rem; color: #475569; }
.chart-legend span { display: inline-flex; align-items: center; gap: 6px; }
.legend-swatch { width: 14px; height: 4px; border-radius: 2px; display: inline-block; }
.chart-svg-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.chart-empty { color: #94a3b8; padding: 24px; text-align: center; }
/* 테이블 가로 스크롤 컨테이너 */
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch;
margin-bottom: 24px; border-radius: 8px; }
.table-wrap table { margin-bottom: 0; }
.mobile-detail { margin-bottom: 24px; }
.mobile-detail summary { display: none; }
.empty-note { color: #94a3b8; padding: 18px; text-align: center;
background: #fff; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
/* --- 태블릿 (≤768px) --- */
@media (max-width: 768px) {
header { display: block !important; }
.top-actions { margin-top: 12px; overflow-x: auto; flex-wrap: nowrap; -webkit-overflow-scrolling: touch; padding-bottom: 2px; }
.top-actions a { flex: 0 0 auto; padding: 7px 11px; }
.status-hero { grid-template-columns: 1fr; }
.status-clock { text-align: left; }
.mobile-quick { display: block; }
.ops-grid { grid-template-columns: 1fr; }
.report-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.cards { flex-direction: column; }
.card { min-width: 0; }
.sensor-card { min-width: 0; }
header h1 { font-size: 1.1rem; }
.card .val { font-size: 1.6rem; }
th { font-size: .75rem; padding: 8px 10px; }
td { font-size: .8rem; padding: 7px 10px; }
.mobile-detail { background: #fff; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,.1); overflow: hidden; }
.mobile-detail summary { display: flex; justify-content: space-between; align-items: center; gap: 10px;
padding: 13px 14px; color: #334155; font-weight: 700; cursor: pointer; list-style: none; }
.mobile-detail summary::-webkit-details-marker { display: none; }
.mobile-detail summary span { color: #64748b; font-size: .74rem; font-weight: 600; }
.mobile-detail .table-wrap { margin-bottom: 0; border-top: 1px solid #e2e8f0; border-radius: 0; }
}
/* --- 폰 (≤480px) --- */
@media (max-width: 480px) {
.container { padding: 0 10px; }
header { padding: 12px 16px; }
header h1 { font-size: 1rem; }
.quick-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.card { padding: 14px 16px; }
.card .val { font-size: 1.3rem; }
.sensor-grid { flex-direction: column; }
.sensor-card { min-width: 0; }
th { font-size: .7rem; padding: 6px 8px; }
td { font-size: .75rem; padding: 6px 8px; }
.col-hide-xs { display: none; }
.report-grid { grid-template-columns: 1fr; }
}
/* --- 초소형 (≤360px) --- */
@media (max-width: 360px) {
header h1 { font-size: .9rem; }
.card .val { font-size: 1.1rem; }
th { font-size: .65rem; padding: 5px 6px; }
td { font-size: .7rem; padding: 5px 6px; }
}
</style>
<script>setTimeout(()=>location.reload(), 30000);</script><!-- 30초 자동 새로고침 -->
</head>
<body>
<header style="display:flex;align-items:center;justify-content:space-between">
<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>
<div 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>
<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="#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" style="color:<?= $alert_count > 0 ? '#ef4444' : '#22c55e' ?>">
<?= $alert_count ?>개
</div>
</div>
<div class="card">
<div>오프라인</div>
<div class="val" style="color:<?= $offline_count > 0 ? '#f59e0b' : '#22c55e' ?>">
<?= $offline_count ?>개
</div>
</div>
</div>
<div class="cards" style="margin-bottom:0">
<div class="card">
<div>최근 온도</div>
<div class="val" style="font-size:1.4rem"><?= htmlspecialchars(metric_display($latest_temp !== null ? (string)$latest_temp : null, '°C')) ?></div>
</div>
<div class="card">
<div>최근 습도</div>
<div class="val" style="font-size:1.4rem"><?= htmlspecialchars(metric_display($latest_hum !== null ? (string)$latest_hum : null, '%')) ?></div>
</div>
<div class="card">
<div>현재 시각</div>
<div class="val" style="font-size:1.1rem"><?= date('Y-m-d H:i:s') ?></div>
</div>
</div>
</div>
<aside class="panel" id="diagnostics-section">
<h2>운영 자가진단</h2>
<div style="font-size:.82rem;color:#64748b;margin-bottom:12px">
실패 <?= $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'];
?>
<div class="diag-item <?= htmlspecialchars($diag_class) ?>">
<i class="diag-dot"></i>
<div>
<strong><?= htmlspecialchars($check['label']) ?> · <?= $check['ok'] ? '정상' : '확인 필요' ?></strong>
<span><?= htmlspecialchars($check['detail']) ?></span>
</div>
</div>
<?php endforeach; ?>
</div>
<p style="color:#64748b;font-size:.84rem;margin:12px 0 0">보안대책서 첨부용 점검표는 상단의 보안 증적 화면에서 내려받을 수 있습니다.</p>
</aside>
</div>
<!-- 센서 상태 -->
<h2 id="sensors-section">센서 현재 상태 <span class="refresh">30초 자동 새로고침</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' || $status_code === 'low_humidity') { $hum_chip_class = 'humid'; }
?>
<div class="sensor-card <?= $card_class ?>">
<strong><?= htmlspecialchars($s['sensor_name']) ?></strong><br>
<small style="color:#64748b"><?= htmlspecialchars($s['device_location']) ?> / <?= htmlspecialchars($s['device_id']) ?></small><br>
<div style="margin-top:10px">
<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" style="margin-top:10px">측정값 없음</div>
<?php endif; ?>
<div style="font-size:.75rem;margin-top:6px;<?= $s['is_offline'] ? 'color:#dc2626;font-weight:600' : 'color:#94a3b8' ?>">
최종확인: <?= htmlspecialchars($s['last_seen']) ?>
<?php if ($s['is_offline']): ?>
(<?= $s['offline_elapsed_min'] ?>분 전)
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<?php if (empty($statuses)): ?>
<p style="color:#94a3b8">아직 수신된 센서 데이터가 없습니다.</p>
<?php endif; ?>
</div>
<!-- 온습도 추이 차트 (인라인 SVG, 외부 라이브러리 없음) -->
<h2 id="chart-section">온습도 추이
<?php if ($chart_sensor_id > 0): ?>
<span style="font-weight:400;color:#94a3b8;font-size:.82rem">
· <?= 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" style="background:#ef4444"></i> 온도(°C)</span>
<span><i class="legend-swatch" style="background:#2563eb"></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);
$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="온습도 추이 라인 차트" style="min-width:680px">
<!-- 플롯 배경/축 -->
<rect x="<?= $pad_l ?>" y="<?= $pad_t ?>" width="<?= $plot_w ?>" height="<?= $plot_h ?>"
fill="#f8fafc" stroke="#e2e8f0"></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) ?>"
stroke="#e2e8f0" stroke-dasharray="3,3"></line>
<text x="<?= $pad_l - 6 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>"
text-anchor="end" font-size="10" fill="#ef4444"><?= htmlspecialchars($fmt($tv)) ?></text>
<text x="<?= $pad_l + $plot_w + 6 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>"
text-anchor="start" font-size="10" fill="#2563eb"><?= 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" fill="#94a3b8"><?= htmlspecialchars($tlabel) ?></text>
<?php endforeach; ?>
<!-- 습도 라인 -->
<?php foreach ($hum_segments as $seg): ?>
<polyline points="<?= htmlspecialchars(implode(' ', $seg)) ?>"
fill="none" stroke="#2563eb" stroke-width="1.8"
stroke-linejoin="round" stroke-linecap="round"></polyline>
<?php endforeach; ?>
<!-- 온도 라인 -->
<?php foreach ($temp_segments as $seg): ?>
<polyline points="<?= htmlspecialchars(implode(' ', $seg)) ?>"
fill="none" stroke="#ef4444" stroke-width="1.8"
stroke-linejoin="round" stroke-linecap="round"></polyline>
<?php endforeach; ?>
</svg>
</div>
<?php else: ?>
<div class="chart-empty">추이를 표시할 측정 데이터가 충분하지 않습니다 (2개 이상 필요).</div>
<?php endif; ?>
</div>
<section class="panel" id="summary-section" style="margin-bottom:24px">
<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 style="color:<?= $summary['sms_fail'] > 0 ? '#dc2626' : 'inherit' ?>"><?= $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" style="text-align:center;color:#94a3b8">아직 온습도 측정 데이터가 없습니다.</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' => '정기보고',
];
$evt = $row['event_type'];
?>
<tr>
<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><?= htmlspecialchars($event_labels[$evt] ?? $evt) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($logs)): ?>
<tr><td colspan="5" style="text-align:center;color:#94a3b8">아직 센서 이벤트가 없습니다.</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($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" style="font-size:.75rem;color:#64748b"><?= htmlspecialchars($row['result_message']) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($sms_logs)): ?>
<tr><td colspan="6" style="text-align:center;color:#94a3b8">아직 SMS 발송 이력이 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</details>
</div>
<script>
function applyMobileDisclosureState() {
const shouldCollapse = window.matchMedia('(max-width: 768px)').matches;
document.querySelectorAll('details[data-mobile-collapsed]').forEach((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);
</script>
</body>
</html>