참고 대시보드 미학(대형 숫자 KPI·플랫 카드)으로 재테마하고, 통합 센서(누수+온습도+미세먼지)를 유형 통합 그리드로 표시. PM2.5/PM10 카드·트렌드·이력을 추가. - php/assets/dash-v2.css: 후행 캐스케이드 오버라이드(기존 파일 무수정 가역 방식) - php/api/status_summary.php: 상태 요약 폴링 엔드포인트(try/catch·LIMIT·제네릭 에러 JSON) - dashboard_data.php: PM 카드 데이터·결로/예보 메트릭 정합 - tabs/live·trend·history: 미세먼지 표시·추세·이력 반영
392 lines
19 KiB
PHP
392 lines
19 KiB
PHP
<?php
|
|
// =============================================================================
|
|
// dashboard.php - 온습도·누수 통합 모니터링 대시보드 (탭 구조 셸)
|
|
// cafe24 웹호스팅에서 직접 브라우저로 접근하는 관리 페이지
|
|
//
|
|
// 구조 (전면 재개편 — 5인 리뷰 패널 종합 스펙 반영):
|
|
// · 상시 영역: 시스템 상태 히어로(누수·미복구 사고 포함) + 알림 스트립 +
|
|
// 대응 필요 패널 + 통합 KPI 스트립(6타일) + 탭바
|
|
// · 탭(?tab= 서버 렌더): live(실시간 현황) / trend(추이·통계) /
|
|
// history(이력 조회) / system(시스템 점검) / all(인쇄용 전체)
|
|
// · 데이터 준비는 dashboard_data.php(전역 vs 탭 전용), 마크업은 tabs/*.php
|
|
//
|
|
// 임계 판정과 SMS 발송은 서버(api/sensor_data.php + config.php METRIC_* 상수)에서
|
|
// 수행하고, 이 대시보드는 그 결과(leak_sensor_metric/status/leak_sms_log)를 표시한다.
|
|
// 폐쇄망: 외부 CDN/JS/CSS 없음(차트는 인라인 SVG 서버 렌더).
|
|
// =============================================================================
|
|
|
|
require_once __DIR__ . '/config.php';
|
|
require_once __DIR__ . '/admin_security.php';
|
|
require_once __DIR__ . '/cron_heartbeat.php';
|
|
require_once __DIR__ . '/dashboard_data.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();
|
|
|
|
// ── 탭 라우팅 ($_GET 화이트리스트 — dashboard_data.php) ─────
|
|
$current_tab = dash_current_tab(); // live|trend|history|system|all
|
|
$tabs_to_render = $current_tab === 'all' ? array_keys(DASH_TABS) : [$current_tab];
|
|
|
|
// ── 데이터 로드: 전역(모든 탭) + 활성 탭 전용 ───────────────
|
|
// 시스템 점검/전체 탭에서는 자가진단 캐시를 우회(진단이 주인공인 화면은 항상 신선)
|
|
$G = load_global_data($db, in_array($current_tab, ['system', 'all'], true));
|
|
extract($G);
|
|
|
|
// 탭 partial 렌더 — 함수 스코프로 격리해 셸 변수 오염·탭 간 변수 누출을 차단한다.
|
|
// EXTR_SKIP: 만약 로더 키가 전역 키와 겹쳐도 조용히 덮어쓰지 않는다.
|
|
function dash_render_tab(string $tab_file, array $shared_vars, array $tab_vars): void {
|
|
extract($shared_vars, EXTR_SKIP);
|
|
extract($tab_vars, EXTR_SKIP);
|
|
include $tab_file;
|
|
}
|
|
|
|
$tab_loaders = [
|
|
'live' => 'load_tab_live',
|
|
'trend' => 'load_tab_trend',
|
|
'history' => 'load_tab_history',
|
|
'system' => 'load_tab_system',
|
|
];
|
|
$tab_data = [];
|
|
foreach ($tabs_to_render as $render_tab_id) {
|
|
$tab_data[$render_tab_id] = $tab_loaders[$render_tab_id]($db, $G);
|
|
}
|
|
|
|
// KPI 스트립 타일 정의: [라벨, 값, 부가텍스트, 상태클래스, 이동 탭]
|
|
$kpi_tiles = [
|
|
['경보 센서', $alert_count . '개', null,
|
|
$alert_count > 0 ? 'err' : 'ok', 'live'],
|
|
['오프라인', $offline_count . '개', $online_count . '/' . $sensor_total . ' 온라인',
|
|
$offline_count > 0 ? 'warn' : 'ok', 'live'],
|
|
['미복구 누수', $unrecovered_count . '건', null,
|
|
$unrecovered_count > 0 ? 'err' : 'ok', 'live'],
|
|
['오늘 경보', $today_alert_count . '건', '측정·이벤트 건수',
|
|
$today_alert_count > 0 ? 'warn' : 'ok', 'trend'],
|
|
['SMS 잔여', $sms_balance !== null ? $sms_balance . '건' : '확인 전', null,
|
|
($sms_balance !== null && $sms_balance <= SMS_BALANCE_WARN_THRESHOLD) ? 'err' : 'ok', 'system'],
|
|
['중요 진단', $ops_critical_count . '건', null,
|
|
$ops_critical_count > 0 ? 'warn' : 'ok', 'system'],
|
|
];
|
|
?>
|
|
<!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">
|
|
<link rel="stylesheet" href="assets/dash-v2.css">
|
|
<style>
|
|
/* dashboard.php 전용 잔여 스타일 — 값은 var(--*) 토큰만(app.css 규칙 재정의 아님) */
|
|
.h2-sub { font-weight: 400; color: var(--ink-2); font-size: .8125rem; }
|
|
.diag-summary { color: var(--ink-2); font-size: .8125rem; margin-bottom: var(--sp-3); }
|
|
.stale { color: var(--leak-ink); font-weight: 700; }
|
|
.empty-cell { text-align: center; color: var(--ink-3); }
|
|
/* 온습도 추이 SVG — app.css 미포함 요소 토큰화(라인=차트 토큰) */
|
|
.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">
|
|
|
|
<!-- 시스템 상태 히어로: 어떤 탭에서도 최상단 고정. 경보 시 센서·값·시작·SMS 결과 명시 -->
|
|
<section class="status-hero <?= htmlspecialchars($system_state) ?>">
|
|
<div>
|
|
<h2><?= htmlspecialchars($system_title) ?></h2>
|
|
<p><?= htmlspecialchars($system_detail) ?></p>
|
|
<?php if (!empty($hero_alert_lines)): ?>
|
|
<ul class="hero-lines">
|
|
<?php foreach ($hero_alert_lines as $hl): ?>
|
|
<li><?= htmlspecialchars($hl['text']) ?></li>
|
|
<?php endforeach; ?>
|
|
</ul>
|
|
<?php endif; ?>
|
|
</div>
|
|
<div class="status-clock">
|
|
<?= date('Y-m-d H:i:s') ?><br>
|
|
<?php if ($current_tab === 'all'): ?>
|
|
인쇄용 전체 보기 · 자동 새로고침 없음
|
|
<?php else: ?>
|
|
<span class="refresh" id="refresh-wrap"><svg class="refresh-ring" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="6"/></svg>30초 자동 새로고침<button type="button" id="refresh-toggle" class="refresh-toggle" aria-pressed="true" aria-label="자동 새로고침 일시정지">⏸ 일시정지</button></span>
|
|
<?php endif; ?>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- 알림 스트립: 즉시 조치가 필요한 것만 1줄씩 (상세는 해당 탭 딥링크) -->
|
|
<?php if ($recent_alert_fail > 0): ?>
|
|
<a class="alert-strip danger" href="<?= htmlspecialchars(dash_url(['tab' => 'system'])) ?>">
|
|
<strong>경보 SMS 발송 실패</strong>
|
|
<span>최근 1시간 <?= (int)$recent_alert_fail ?>건 — 미수신 가능. Cafe24 SMS 잔액·계정·발신번호 확인</span>
|
|
<em>시스템 점검 →</em>
|
|
</a>
|
|
<?php endif; ?>
|
|
<?php if ($unrecovered_count > 0): ?>
|
|
<a class="alert-strip danger" href="<?= htmlspecialchars(dash_url(['tab' => 'live'])) ?>">
|
|
<strong>미복구 누수 사고 <?= (int)$unrecovered_count ?>건</strong>
|
|
<span>현장 조치·복구 확인이 필요합니다</span>
|
|
<em>실시간 현황 →</em>
|
|
</a>
|
|
<?php endif; ?>
|
|
<?php if ($rejection_total > 0): ?>
|
|
<a class="alert-strip warn" href="<?= htmlspecialchars(dash_url(['tab' => 'system'])) ?>">
|
|
<strong>수집 거부 <?= (int)$rejection_total ?>건</strong>
|
|
<span>최근 24시간 — 사유·출처 IP 상세 확인</span>
|
|
<em>시스템 점검 →</em>
|
|
</a>
|
|
<?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; ?>
|
|
|
|
<!-- 통합 KPI 스트립: 데스크톱·모바일 공용 6타일, 각 타일은 해당 탭 딥링크 -->
|
|
<nav class="kpi-strip" aria-label="핵심 현황">
|
|
<?php foreach ($kpi_tiles as [$k_label, $k_val, $k_sub, $k_cls, $k_tab]): ?>
|
|
<a class="kpi <?= htmlspecialchars($k_cls) ?>" href="<?= htmlspecialchars(dash_url(['tab' => $k_tab])) ?>">
|
|
<span class="kpi-lbl"><?= htmlspecialchars($k_label) ?></span>
|
|
<span class="kpi-val"><?= htmlspecialchars($k_val) ?></span>
|
|
<?php if ($k_sub !== null): ?><span class="kpi-sub"><?= htmlspecialchars($k_sub) ?></span><?php endif; ?>
|
|
</a>
|
|
<?php endforeach; ?>
|
|
</nav>
|
|
|
|
<!-- 탭바: ?tab= 서버 렌더 링크 (30초 리로드가 URL 을 보존하므로 탭 상태 자동 유지) -->
|
|
<nav class="tabbar" aria-label="대시보드 섹션">
|
|
<?php foreach (DASH_TABS as $tab_id => $tab_label):
|
|
$is_active = ($current_tab === $tab_id);
|
|
$badge = 0; $badge_cls = 'warn';
|
|
if ($tab_id === 'live') { $badge = (int)$badge_live; $badge_cls = ($alert_count + $leak_active_count) > 0 ? 'crit' : 'warn'; }
|
|
if ($tab_id === 'system') { $badge = (int)$badge_system; $badge_cls = 'crit'; }
|
|
?>
|
|
<a class="tab-link<?= $is_active ? ' active' : '' ?>"
|
|
href="<?= htmlspecialchars(dash_url(['tab' => $tab_id])) ?>"<?= $is_active ? ' aria-current="page"' : '' ?>>
|
|
<?= htmlspecialchars($tab_label) ?><?php if ($badge > 0): ?><span class="tab-badge <?= $badge_cls ?>" aria-label="확인 필요 <?= $badge ?>건"><?= $badge ?></span><?php endif; ?>
|
|
</a>
|
|
<?php endforeach; ?>
|
|
<a class="tab-print<?= $current_tab === 'all' ? ' active' : '' ?>" href="<?= htmlspecialchars(dash_url(['tab' => 'all'])) ?>"<?= $current_tab === 'all' ? ' aria-current="page"' : '' ?>>🖨 인쇄용 전체</a>
|
|
</nav>
|
|
|
|
<?php if ($current_tab === 'all'): ?>
|
|
<p class="print-note">인쇄용 전체 보기입니다(전 탭 순차 렌더 · 자동 새로고침 없음). 평시 관제는 <a href="<?= htmlspecialchars(dash_url(['tab' => 'live'])) ?>">실시간 현황</a>을 사용하세요.</p>
|
|
<?php endif; ?>
|
|
|
|
<!-- 탭 콘텐츠 (tab=all 이면 4개 순차 렌더 — 인쇄/보안 증적 전체 출력) -->
|
|
<?php
|
|
$shared_view_vars = ['render_all' => ($current_tab === 'all')] + $G;
|
|
foreach ($tabs_to_render as $render_tab_id) {
|
|
dash_render_tab(__DIR__ . '/tabs/' . $render_tab_id . '.php', $shared_view_vars, $tab_data[$render_tab_id]);
|
|
}
|
|
?>
|
|
|
|
</main>
|
|
|
|
<script>
|
|
(function () {
|
|
'use strict';
|
|
var TAB = <?= json_encode($current_tab) ?>;
|
|
var REFRESH_MS = 30000;
|
|
var LS_REFRESH = 'leakDash.autoRefresh'; // 'on' | 'off' (기본 on)
|
|
var SS_SCROLL = 'leakDash.scrollY.' + TAB; // 탭별 분리(탭 간 오복원 방지)
|
|
var SS_DETAILS = 'leakDash.detailsOpen.' + TAB; // 열린 details id 목록 JSON
|
|
|
|
// ── 구 앵커(#sensors-section 등) 하위호환: 대상이 현재 탭에 없으면 해당 탭으로 ──
|
|
var HASH_TAB = {
|
|
'sensors-section': 'live', 'leak-section': 'live',
|
|
'chart-section': 'trend',
|
|
'summary-section': 'history', 'metrics-section': 'history',
|
|
'logs-section': 'history', 'sms-section': 'history',
|
|
'diagnostics-section': 'system'
|
|
};
|
|
if (TAB !== 'all' && location.hash) {
|
|
var hid = location.hash.slice(1);
|
|
if (!document.getElementById(hid) && HASH_TAB[hid]) {
|
|
var q = 'tab=' + HASH_TAB[hid];
|
|
var search = location.search.replace(/([?&])tab=[^&]*/, '$1' + q);
|
|
if (search.indexOf('tab=') === -1) {
|
|
search = search === '' ? '?' + q : search + '&' + q;
|
|
}
|
|
location.replace('dashboard.php' + search + location.hash);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// details 는 고유 id 기반으로 저장/복원(탭마다 개수·순서가 달라 인덱스 방식 부적합)
|
|
var details = Array.prototype.slice.call(document.querySelectorAll('details[id]'));
|
|
|
|
// ── details 펼침 상태 저장/복원 ────────────────────────────
|
|
function saveDetailsState() {
|
|
try {
|
|
var openIds = [];
|
|
details.forEach(function (d) { if (d.open) { openIds.push(d.id); } });
|
|
sessionStorage.setItem(SS_DETAILS, JSON.stringify(openIds));
|
|
} catch (e) { /* storage 불가 시 무시 */ }
|
|
}
|
|
function restoreDetailsState() {
|
|
try {
|
|
var raw = sessionStorage.getItem(SS_DETAILS);
|
|
if (raw === null) { return; }
|
|
var openIds = JSON.parse(raw);
|
|
if (!Array.isArray(openIds)) { return; }
|
|
details.forEach(function (d) {
|
|
if (openIds.indexOf(d.id) !== -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;
|
|
}
|
|
});
|
|
}
|
|
// 인쇄용 전체 보기(tab=all)는 접힘/복원을 하지 않는다 — 서버가 강제 open 으로
|
|
// 렌더한 상태 그대로 인쇄되어야 함(접힌 details 는 인쇄물에서 통째로 빠짐).
|
|
if (TAB !== 'all') {
|
|
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: 끌 수 있어야 함) ────────
|
|
// location.reload() 는 URL(?tab=,?range=)을 보존하므로 탭 상태가 자동 유지된다.
|
|
// 인쇄용 전체 보기(tab=all)에서는 전체 렌더 부하 반복을 막기 위해 예약하지 않는다.
|
|
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 (TAB === 'all' || !isOn()) { return; }
|
|
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 ? '자동 새로고침 일시정지' : '자동 새로고침 재개');
|
|
var wrap = document.getElementById('refresh-wrap');
|
|
if (wrap) { wrap.classList.toggle('paused', !on); }
|
|
}
|
|
if (btn) {
|
|
btn.addEventListener('click', function () {
|
|
try { localStorage.setItem(LS_REFRESH, isOn() ? 'off' : 'on'); } catch (e) {}
|
|
renderBtn();
|
|
scheduleReload();
|
|
});
|
|
}
|
|
renderBtn();
|
|
scheduleReload();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|