단일 1594줄 스크롤 페이지를 상시 영역 + 4탭 서버렌더(?tab=) 구조로 재개편한다. 5인 리뷰 패널(정보구조·관제도메인·UX·구현·접근성) 종합 스펙 기반. 상시 영역(전 탭 공통): - 시스템 상태 히어로 — 누수(is_leak)·미복구 사고를 FORECAST_ENABLED 와 무관하게 반영(기존: 누수 중에도 '정상 감시 중' 표시 가능한 결함). 경보 시 센서명·위치· 현재값·경보 시작 시각·SMS 발송 결과를 한 줄로 명시. - 알림 스트립(SMS 실패/미복구 누수/수집 거부 1줄 요약 + 탭 딥링크) - 대응 필요 패널(플레이북·담당자) — 안전 알림은 탭과 무관하게 상시 노출 - 통합 KPI 스트립 6타일 — 모바일 퀵타일·듀얼 KPI·게이지 배지를 하나로 통합 - 탭바(nav+aria-current, 경보 배지) 탭: - 실시간 현황: 온습도/누수 카드(예보 여유 상시 표시), 미복구 사고, SMS 채널·오늘 요약 - 추이·통계: 온습도 추이 차트, 7일 경보 추이, 경보 종류 도넛(단위 명기), 가동률, 리더보드 - 이력 조회: 누수 사고·측정·이벤트·SMS 이력, 30일 센서별 평균 온습도, SMS 발송 통계 - 시스템 점검: 자가진단, 수집 거부 관측성, SMS 채널 점검, 플레이북 사본, 임계 설정 요약 - ?tab=all: 인쇄용 전체 렌더(details 강제 펼침, 자동 새로고침 없음) 구현: - 데이터 계층 분리(dashboard_data.php): 전역 vs 탭 전용 쿼리 — 로드당 쿼리 ~50→전역 14+탭 5~8개(약 60% 절감). ops_checks 파일 캐시(TTL 120초, 시스템 탭은 우회). - 탭 partial(tabs/*.php)은 dash_render_tab() 함수 스코프 격리(EXTR_SKIP)로 렌더. - 30초 자동 새로고침은 URL 쿼리 보존으로 탭·기간 유지. 스크롤/details 복원은 탭별 키. - 폐쇄망 유지: 외부 CDN/JS 없음(인라인 SVG). app.css 는 기존 규칙 불변, 추가만. - 보안: dashboard_data·forecast·tabs 를 .htaccess FilesMatch + tabs/.htaccess + PHP 직접접근 가드로 삼중 차단. security_hardening_test: 대시보드 표면을 분리 구조(셸+데이터+탭)에 맞춰 검사하도록 보정.
182 lines
10 KiB
PHP
182 lines
10 KiB
PHP
<?php
|
|
// tabs/system.php — [시스템 점검] 탭. dashboard.php 셸에서 include 된다.
|
|
// 관제(무슨 일이 나는가)와 분리된 관리 정보(시스템이 올바로 설정·동작하는가):
|
|
// 자가진단 · 수집 거부 관측성 · SMS 채널 점검 · 대응 플레이북 사본 · 임계 설정 요약.
|
|
// 사용 변수: load_tab_system() 결과(extract) — $ops_issues, $ops_ok, $reject_events,
|
|
// $sms_fail_30d, $config_rows + 전역 $ops_*, $ingest_rejections, $sms_* 등.
|
|
if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
|
|
|
|
$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 ? '확인 필요' : '정상';
|
|
|
|
// 수집 거부 사유 한국어 라벨(관측성 — api/sensor_data.php 의 거부 사유 키와 대응)
|
|
$reason_ko = [
|
|
'signature' => '서명불일치(API키/펌웨어 점검)',
|
|
'timestamp' => '시각오차(SNTP/RTC 점검)',
|
|
'key_default' => '기본 API키(미설정)',
|
|
'body_too_large' => '본문과대',
|
|
'invalid_json' => 'JSON오류',
|
|
'missing_field' => '필드누락',
|
|
'sensor_id_range' => 'sensor_id 범위',
|
|
];
|
|
?>
|
|
<div class="zone" id="tab-system">
|
|
<div class="zone-label"><b>시스템 점검</b> <span class="en">System</span></div>
|
|
|
|
<div class="system-grid">
|
|
|
|
<!-- 운영 자가진단 (전체) -->
|
|
<section class="panel" 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" id="d-diag-ok"<?= !empty($render_all) ? ' open' : '' ?>>
|
|
<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">진단 결과는 최대 5분 캐시됩니다. 보안대책서 첨부용 점검표는 <a href="security_evidence.php">보안 증적</a>에서 내려받을 수 있습니다.</p>
|
|
</section>
|
|
|
|
<!-- 수집 거부 관측성 (왜 데이터가 안 들어오는가 / 무단 접근 출처) -->
|
|
<section class="panel" aria-label="수집 거부 관측성">
|
|
<h2>수집 거부 <span class="h2-sub">· 최근 24시간 <?= (int)$rejection_total ?>건</span></h2>
|
|
<?php if ($rejection_total > 0): ?>
|
|
<p class="diag-summary"><?php
|
|
$parts = [];
|
|
foreach ($ingest_rejections as $r => $c) {
|
|
$parts[] = htmlspecialchars($reason_ko[$r] ?? $r) . ' ' . (int)$c;
|
|
}
|
|
echo implode(' · ', $parts);
|
|
?></p>
|
|
<?php else: ?>
|
|
<p class="diag-summary">최근 24시간 수집 거부가 없습니다.</p>
|
|
<?php endif; ?>
|
|
|
|
<?php if (!empty($reject_events)): ?>
|
|
<h3 class="sub-h3">최근 거부 출처 <?= count($reject_events) ?>건 <span class="h2-sub">(우리 노드 오설정 vs 외부 스캐너 판별)</span></h3>
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead><tr><th>시각</th><th>출처 IP</th><th>사유</th><th class="col-hide-xs">모드</th><th class="col-hide-xs">device_id</th></tr></thead>
|
|
<tbody>
|
|
<?php foreach ($reject_events as $ev): ?>
|
|
<tr>
|
|
<td><?= htmlspecialchars((string)($ev['t'] ?? '')) ?></td>
|
|
<td><?= htmlspecialchars((string)($ev['ip'] ?? '?')) ?><?php if (!empty($ev['fwd'])): ?> <small>(xff <?= htmlspecialchars((string)$ev['fwd']) ?>)</small><?php endif; ?></td>
|
|
<td><?= htmlspecialchars($reason_ko[$ev['reason'] ?? ''] ?? (string)($ev['reason'] ?? '')) ?></td>
|
|
<td class="col-hide-xs"><?= htmlspecialchars((string)($ev['mode'] ?? '')) ?></td>
|
|
<td class="col-hide-xs"><?= htmlspecialchars((string)($ev['dev'] ?? '')) ?></td>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<?php endif; ?>
|
|
</section>
|
|
|
|
<!-- SMS 채널 점검 -->
|
|
<section class="panel" aria-label="SMS 채널 점검">
|
|
<h2>SMS 채널 점검</h2>
|
|
<dl class="mini-rows">
|
|
<div><dt>잔여 건수</dt>
|
|
<dd class="<?= ($sms_balance !== null && $sms_balance <= SMS_BALANCE_WARN_THRESHOLD) ? 'err-color' : '' ?>">
|
|
<?= $sms_balance !== null ? (int)$sms_balance . '건 (경고 임계 ' . (int)SMS_BALANCE_WARN_THRESHOLD . '건)' : '확인 전' ?>
|
|
</dd></div>
|
|
<div><dt>마지막 성공 발송</dt>
|
|
<dd><?= $sms_last_success_at !== null ? htmlspecialchars($sms_last_success_at) : '기록 없음' ?></dd></div>
|
|
<div><dt>최근 1시간 경보 SMS 실패</dt>
|
|
<dd class="<?= $recent_alert_fail > 0 ? 'err-color' : 'ok-color' ?>"><?= (int)$recent_alert_fail ?>건</dd></div>
|
|
<div><dt>최근 30일 발송 실패</dt>
|
|
<dd class="<?= $sms_fail_30d > 0 ? 'warn-color' : 'ok-color' ?>"><?= (int)$sms_fail_30d ?>건</dd></div>
|
|
</dl>
|
|
<p class="panel-note">실패 지속 시 Cafe24 SMS 잔액·계정·발신번호를 확인하세요. 발송 이력은 <a href="<?= htmlspecialchars(dash_url(['tab' => 'history'], 'sms-section')) ?>">이력 조회 탭</a>에 있습니다.</p>
|
|
</section>
|
|
|
|
<!-- 대응 플레이북·담당자 (평시 열람 사본 — 경보 시에는 상단 '대응 필요'에 자동 표시) -->
|
|
<section class="panel" aria-label="대응 플레이북">
|
|
<h2>대응 플레이북 <span class="h2-sub">· 평시 열람용</span></h2>
|
|
<?php foreach (RESPONSE_PLAYBOOKS as $pb_key => $pb): if (empty($pb['steps'])) { continue; } ?>
|
|
<details class="playbook-item" id="pb-<?= htmlspecialchars((string)$pb_key) ?>"<?= !empty($render_all) ? ' open' : '' ?>>
|
|
<summary><?= htmlspecialchars($pb['title'] ?? '대응 절차') ?></summary>
|
|
<ol class="playbook-steps">
|
|
<?php foreach ($pb['steps'] as $step): ?><li><?= htmlspecialchars($step) ?></li><?php endforeach; ?>
|
|
</ol>
|
|
</details>
|
|
<?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 else: ?>
|
|
<p class="panel-note">담당자 연락처는 config.local.php 의 RESPONSE_CONTACTS 로 등록합니다.</p>
|
|
<?php endif; ?>
|
|
</section>
|
|
|
|
<!-- 임계·설정 요약 (읽기 전용) -->
|
|
<section class="panel" aria-label="임계 및 설정 요약">
|
|
<h2>임계·설정 요약 <span class="h2-sub">· 읽기 전용</span></h2>
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead><tr><th>항목</th><th>현재값</th></tr></thead>
|
|
<tbody>
|
|
<?php foreach ($config_rows as [$label, $value]): ?>
|
|
<tr><td><?= htmlspecialchars($label) ?></td><td><?= htmlspecialchars((string)$value) ?></td></tr>
|
|
<?php endforeach; ?>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p class="panel-note">값 변경은 config.local.php 오버라이드로 합니다(폐쇄망에서 펌웨어 재배포 없이 조정 가능).</p>
|
|
</section>
|
|
|
|
<!-- 관리 화면 바로가기 -->
|
|
<section class="panel" aria-label="관리 화면 바로가기">
|
|
<h2>관리 화면 바로가기</h2>
|
|
<ul class="link-list">
|
|
<li><a href="setup_wizard.php">설치 점검</a> — DB·테이블·수신 상태 점검</li>
|
|
<li><a href="monthly_report.php">월간 보고</a> — 월별 경보·SMS 통계 보고서</li>
|
|
<li><a href="security_evidence.php">보안 증적</a> — 보안대책서 첨부용 점검표</li>
|
|
<li><a href="<?= htmlspecialchars(dash_url(['tab' => 'all'])) ?>">인쇄용 전체 보기</a> — 전 탭 한 페이지 렌더(자동 새로고침 없음)</li>
|
|
</ul>
|
|
</section>
|
|
|
|
</div>
|
|
</div>
|