feat: cafe24 관리자 UI "Slate NOC Console" 디자인시스템 통합 + 적대리뷰 18건 반영

관리자 사이트 7페이지의 파편화된 인라인 스타일(색상 리터럴 160개, 페이지별
<style>)을 단일 디자인시스템으로 통합.

신규 php/assets/app.css — "Slate NOC Console":
- CSS 변수 단일 토큰(라이트/다크 prefers-color-scheme/인쇄 강제라이트).
- 한글 시스템폰트 우선(폐쇄망 웹폰트 미로드 → 오프라인 일관 렌더), 외부 리소스 0.
- 상태 3중 인코딩(색+도형[점/좌측레일/글리프]+텍스트) — 색맹·흑백 인쇄 대비.
- WCAG AA 대비(토큰 쌍 실측), 본문/캡션 하한 .8125rem.

7페이지 리팩터(dashboard/login/monthly_report/security_evidence/setup_wizard/
setup_mfa/blocked): 인라인 <style> 제거→app.css 링크, PHP 색 삼항→클래스,
생 hex 0, PHP 로직 무변경. make_deploy_bundles.py: TEXT_EXTS += .css.

적대적 다차원 리뷰(미학·CSS아키텍처·통합일관성·a11y·slop) 확정 18건 반영:
- [HIGH] .ok 배너 셀렉터가 .badge.ok/.quick-tile.ok 와 충돌 → 정상 배지가
  사각형으로 깨지던 것 .banner-ok 네임스페이스로 수정.
- 자동새로고침 일시정지 토글(WCAG 2.2.1)+스크롤/details 복원, 차트 임계선+
  최신값 마커, 다크 헤더 슬레이트화, 그림자 절제, 정상 레일 중립화(예외만 컬러),
  지표칩 중립화+저습 .dry 분리, 진단 severity 텍스트 라벨, forced-colors 포커스,
  <main> 랜드마크, role=alert/status, ink-3 대비 상향, 인쇄 칩색 제거 등.

검증: 7페이지 php -l clean, 생 hex 0, 외부참조 0. 라이트/다크 헤드리스 렌더 확인.
This commit is contained in:
cw2007 2026-07-04 22:32:16 +09:00
parent de51d1c3d4
commit e0f0f0963a
9 changed files with 772 additions and 413 deletions

420
php/assets/app.css Normal file
View file

@ -0,0 +1,420 @@
/* =============================================================================
* app.css — 통합 모니터링 관리자 디자인 시스템 "Slate NOC Console"
*
* 단일 진실원(single source of truth). 7개 페이지가 이 파일 하나만 링크한다.
* <link rel="stylesheet" href="assets/app.css">
*
* 설계 원칙:
* - 모든 색/여백/반경/타이포는 :root CSS 변수. 페이지에 hex 리터럴 금지.
* - 한글 시스템 폰트 우선(폐쇄망 — 웹폰트 로드 불가). 외부 리소스 0.
* - 상태는 3중 인코딩: 색 + 도형(점/좌측레일/글리프) + 텍스트 라벨.
* (색맹·흑백 인쇄에서도 정상/누수/경보/오프라인 구분됨 — WCAG 1.4.1)
* - 명도 대비 WCAG AA(본문 4.5:1). 본문/캡션 하한 .8125rem, 라벨/배지/타임스탬프는 .75rem 허용.
* - 라이트 우선 + prefers-color-scheme 다크 자동 + 인쇄는 강제 라이트(리포트/증적).
* ============================================================================= */
/* ── 디자인 토큰 (라이트) ─────────────────────────────────────────────────── */
:root {
/* 표면·잉크 */
--bg: #eef1f5;
--surface: #ffffff;
--surface-2: #f5f7fa;
--raise: #fbfcfe;
--ink: #1b2430; /* 본문 (on surface: ~13:1) */
--ink-2: #54606f; /* 보조 텍스트 (on white: ~5.2:1 AA) */
--ink-3: #6b7687; /* 캡션/축라벨 (on white: ~4.5:1 AA, 최소선) */
--line: #dce1e8;
--line-2: #eaeef3;
/* 브랜드 (절제된 스틸블루 — 저채도라 저온 시맨틱/링크와 분리) */
--primary: #2b5bab;
--primary-600: #234f8f;
--on-primary: #ffffff;
--primary-tint: #e9eff8;
/* 헤더(앱바) — 라이트: 브랜드 primary 배경 + 흰 잉크 */
--header-bg: var(--primary);
--header-ink: var(--on-primary);
/* 상태 시맨틱 — rail/dot 용(진한 색) + ink(텍스트) + bg(옅은 배경) */
--normal: #1f8a4c; --normal-ink: #136c39; --normal-bg: #e6f4ec;
--leak: #d12b2b; --leak-ink: #b11f1f; --leak-bg: #fbe8e8;
--warn: #b26a00; --warn-ink: #935600; --warn-bg: #f8edda;
--offline:#6b7683; --offline-ink:#4b5560; --offline-bg:#ebeef2;
/* 지표 칩 — 서로/브랜드와 뚜렷이 구분 */
--hot-ink: var(--leak-ink); --hot-bg: var(--leak-bg);
--cold-ink: #0b5a6e; --cold-bg: #e1f1f4; /* 티일(저온) — 스틸블루와 다름 */
--humid-ink: var(--warn-ink); --humid-bg: var(--warn-bg);
--dry-ink: #4a4fa0; --dry-bg: #ecedf8; /* 절제된 인디고(저습, 희소) */
/* 차트 라인 */
--chart-temp: #c2410c; /* 온도=따뜻 */
--chart-hum: #0e7490; /* 습도=차가움(티일) */
--chart-grid: var(--line);
/* 타이포 — 한글 시스템 폰트 우선(오프라인 일관 렌더). 웹폰트 미로드. */
--font: "Malgun Gothic","Apple SD Gothic Neo",system-ui,-apple-system,"Segoe UI",Roboto,"Noto Sans KR",sans-serif;
--mono: ui-monospace,SFMono-Regular,Consolas,"D2Coding",monospace;
/* 4/8 간격 리듬 */
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-5: 20px; --sp-6: 24px; --sp-8: 32px;
/* 반경 스케일 */
--r-sm: 4px; --r: 8px; --r-lg: 12px; --r-pill: 999px;
/* 그림자 (절제) */
--shadow: 0 1px 3px rgba(16,24,40,.08), 0 1px 2px rgba(16,24,40,.04);
--shadow-lg: 0 6px 20px rgba(16,24,40,.12);
--ring: 0 0 0 3px rgba(43,91,171,.28);
}
/* ── 다크 (관제 야간/개인 선호) — 같은 토큰을 명도만 조정 ──────────────────── */
@media (prefers-color-scheme: dark) {
:root {
--bg: #10151c;
--surface: #1a2129;
--surface-2: #212a34;
--raise: #232d38;
--ink: #e6ecf3;
--ink-2: #a3adba;
--ink-3: #8a95a2;
--line: #2b3540;
--line-2: #232c36;
--primary: #4c8dff;
--primary-600: #6ba1ff;
--on-primary: #0d1219;
--primary-tint: #182740;
/* 헤더 — 다크: surface-2 배경 + 본문 잉크(하단 헤어라인은 header 규칙) */
--header-bg: var(--surface-2);
--header-ink: var(--ink);
/* 다크 필드에서 4.5:1+ 유지 위해 명도 상향 */
--normal: #35c775; --normal-ink: #7fe0a8; --normal-bg: #12281d;
--leak: #ff5a52; --leak-ink: #ff958e; --leak-bg: #301617;
--warn: #ffb020; --warn-ink: #ffcb6b; --warn-bg: #2d2410;
--offline:#8a94a1; --offline-ink:#aab3bf; --offline-bg:#20272f;
--cold-ink: #5fd0e6; --cold-bg: #10262c;
--dry-ink: #a7abf0; --dry-bg: #1c1e33;
--hot-ink: var(--leak-ink); --hot-bg: var(--leak-bg);
--humid-ink: var(--warn-ink); --humid-bg: var(--warn-bg);
--chart-temp: #ff8a5c;
--chart-hum: #43c4dc;
--shadow: 0 1px 3px rgba(0,0,0,.5);
--shadow-lg: 0 8px 24px rgba(0,0,0,.55);
--ring: 0 0 0 3px rgba(76,141,255,.4);
}
/* 다크 헤더: primary 배너가 아닌 surface-2이므로 본문과 분리할 1px 하단 헤어라인 */
header { border-bottom: 1px solid var(--line); }
}
/* ── 리셋·베이스 ──────────────────────────────────────────────────────────── */
* { box-sizing: border-box; margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body {
font-family: var(--font);
background: var(--bg);
color: var(--ink);
line-height: 1.55;
font-size: 1rem;
-webkit-font-smoothing: antialiased;
}
/* 수치는 자릿수 고정폭(표·지표 정렬) */
.val, .metric-chip, .report-stat strong, td.num, .num, .status-clock { font-variant-numeric: tabular-nums; }
a { color: var(--primary); }
:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: var(--r-sm); }
img { max-width: 100%; }
/* 타입 스케일 (4단) */
h1 { font-size: 1.375rem; font-weight: 700; line-height: 1.3; }
h2 { font-size: 1.125rem; font-weight: 700; color: var(--ink); margin: var(--sp-6) 0 var(--sp-3); }
.panel h2, .status-hero h2 { margin-top: 0; }
label, .label { font-size: .875rem; font-weight: 600; }
.caption, small { font-size: .8125rem; color: var(--ink-3); }
/* ── 헤더(앱바) ───────────────────────────────────────────────────────────── */
header {
background: var(--header-bg);
color: var(--header-ink);
padding: var(--sp-4) var(--sp-6);
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-4);
}
header h1 { font-size: 1.3rem; color: var(--header-ink); }
.top-actions { display: flex; gap: var(--sp-2); align-items: center; flex-wrap: wrap; }
.top-actions a {
color: var(--header-ink); font-size: .8125rem; font-weight: 600; text-decoration: none;
background: rgba(255,255,255,.14); border: 1px solid rgba(255,255,255,.22);
padding: 6px 14px; border-radius: var(--r-sm);
}
.top-actions a:hover { background: rgba(255,255,255,.24); }
/* ── 레이아웃 ─────────────────────────────────────────────────────────────── */
.container { max-width: 1320px; margin: var(--sp-6) auto; padding: 0 var(--sp-4); }
.ops-grid { display: grid; grid-template-columns: minmax(0,1.5fr) minmax(280px,.9fr);
gap: var(--sp-4); align-items: start; margin-bottom: var(--sp-6); }
.cards { display: flex; gap: var(--sp-4); flex-wrap: wrap; margin-bottom: var(--sp-6); }
.sensor-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px,1fr));
gap: var(--sp-4); margin-bottom: var(--sp-6); }
/* ── 패널·카드 ───────────────────────────────────────────────────────────── */
.panel, .card, .chart-panel, .leak-status-card, .empty-note {
background: var(--surface); border: 1px solid var(--line); border-radius: var(--r);
}
.panel { padding: var(--sp-5); }
.chart-panel { padding: var(--sp-5); margin-bottom: var(--sp-6); }
.card { padding: var(--sp-5) var(--sp-6); flex: 1; min-width: 180px; }
.card span { color: var(--ink-2); font-size: .8125rem; }
.card .val { font-size: 2rem; font-weight: 700; margin-top: var(--sp-1); color: var(--ink); }
/* ── 시스템 상태 배너(hero) ──────────────────────────────────────────────── */
.status-hero {
border-radius: var(--r); padding: var(--sp-5); margin-bottom: var(--sp-5);
display: grid; grid-template-columns: minmax(0,1fr) auto; gap: var(--sp-4); align-items: center;
color: var(--ink); background: var(--surface);
border: 1px solid var(--line); border-left: 6px solid var(--offline);
}
.status-hero h2 { font-size: 1.25rem; display: flex; align-items: center; gap: var(--sp-2); }
.status-hero h2::before {
content: ""; width: .7em; height: .7em; border-radius: 50%; background: var(--offline); flex: none;
}
.status-hero p { color: var(--ink-2); font-size: .9rem; margin-top: 2px; }
.status-hero.normal { border-left-color: var(--normal); }
.status-hero.warning { border-left-color: var(--warn); }
.status-hero.danger { border-left-color: var(--leak); background: var(--leak-bg); }
.status-hero.normal h2::before { background: var(--normal); }
.status-hero.warning h2::before { background: var(--warn); }
.status-hero.danger h2::before { background: var(--leak); }
.status-clock { text-align: right; font-size: .8125rem; color: var(--ink-3); white-space: nowrap; }
/* ── 센서 상태 카드 (좌측레일 + 점 + 배지 = 3중) ─────────────────────────── */
.sensor-card {
background: var(--surface); border: 1px solid var(--line); border-radius: var(--r);
border-left: 1px solid var(--line); padding: var(--sp-5);
}
.sensor-card.alert { border-left: 6px solid var(--leak); background: var(--leak-bg); }
.sensor-card.offline { border-left: 6px solid var(--offline); background: var(--offline-bg); }
/* 배지/알약 공통 — 앞에 점 글리프(도형 중복), 텍스트 라벨 필수 */
.badge, .pill {
display: inline-flex; align-items: center; gap: .4em;
border-radius: var(--r-pill); padding: 3px 10px;
font-size: .75rem; font-weight: 700; line-height: 1.4;
background: var(--normal-bg); color: var(--normal-ink);
}
.badge::before, .pill::before {
content: ""; width: .5em; height: .5em; border-radius: 50%; background: currentColor; flex: none;
}
.badge.ok, .pill.ok { background: var(--normal-bg); color: var(--normal-ink); }
.badge.alert,.pill.alert { background: var(--leak-bg); color: var(--leak-ink); }
.badge.offline,.pill.offline{ background: var(--offline-bg); color: var(--offline-ink); }
.pill.high_temp { background: var(--hot-bg); color: var(--hot-ink); }
.pill.low_temp { background: var(--cold-bg); color: var(--cold-ink); }
.pill.high_humidity{ background: var(--humid-bg); color: var(--humid-ink); }
.pill.low_humidity { background: var(--dry-bg); color: var(--dry-ink); }
.pill.leak, .pill.leak_alert { background: var(--leak-bg); color: var(--leak-ink); }
.pill.leak_recovery { background: var(--normal-bg); color: var(--normal-ink); }
/* 지표 칩 */
.metric-line { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin-top: var(--sp-3); }
.metric-chip {
display: inline-flex; align-items: center; border-radius: var(--r-pill);
background: var(--surface-2); color: var(--ink-2);
padding: 4px 10px; font-size: .78rem; font-weight: 700;
}
.metric-chip.hot { background: var(--hot-bg); color: var(--hot-ink); }
.metric-chip.cold { background: var(--cold-bg); color: var(--cold-ink); }
.metric-chip.humid { background: var(--humid-bg); color: var(--humid-ink); }
.metric-chip.dry { background: var(--dry-bg); color: var(--dry-ink); }
.metric-time { color: var(--ink-3); font-size: .75rem; margin-top: var(--sp-2); }
/* ── 운영 자가진단 ───────────────────────────────────────────────────────── */
.diag-list { display: grid; gap: var(--sp-2); }
.diag-item {
border: 1px solid var(--line); border-radius: var(--r); padding: 9px 10px;
display: grid; grid-template-columns: auto minmax(0,1fr); gap: var(--sp-2); align-items: start;
}
.diag-dot { width: 10px; height: 10px; border-radius: 50%; margin-top: 5px; background: var(--normal); flex: none; }
.diag-item.warn .diag-dot { background: var(--warn); }
.diag-item.critical .diag-dot { background: var(--leak); }
.diag-item strong { display: block; font-size: .8125rem; }
.diag-item span { color: var(--ink-2); font-size: .78rem; line-height: 1.4; }
/* ── 30일 운영 요약 ─────────────────────────────────────────────────────── */
.report-grid { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: var(--sp-3); }
.report-stat { border: 1px solid var(--line); border-radius: var(--r); padding: var(--sp-3); background: var(--surface); }
.report-stat span { display: block; color: var(--ink-2); font-size: .75rem; margin-bottom: 4px; }
.report-stat strong { font-size: 1.1rem; }
/* ── 표 ───────────────────────────────────────────────────────────────────── */
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; margin-bottom: var(--sp-6); border-radius: var(--r); }
table {
width: 100%; border-collapse: collapse; background: var(--surface);
border: 1px solid var(--line); border-radius: var(--r); overflow: hidden;
}
.table-wrap table { margin-bottom: 0; }
th {
background: var(--surface-2); font-size: .8125rem; font-weight: 700; color: var(--ink-2);
padding: 10px 14px; text-align: left; border-bottom: 1px solid var(--line); white-space: nowrap;
}
td { padding: 9px 14px; border-bottom: 1px solid var(--line-2); font-size: .875rem; color: var(--ink); }
tbody tr:hover td { background: var(--surface-2); }
.alert-row td { background: var(--leak-bg); }
.alert-row:hover td { background: var(--leak-bg); }
/* 상태 텍스트(인라인) — 색 + 앞 글리프 */
.ok-color, .err-color, .warn-color { font-weight: 700; }
.ok-color { color: var(--normal-ink); }
.err-color { color: var(--leak-ink); }
.warn-color{ color: var(--warn-ink); }
.ok-color::before { content: "\2713\00a0"; } /* ✓ */
.err-color::before { content: "\2715\00a0"; } /* ✕ */
.warn-color::before{ content: "\26a0\00a0"; } /* ⚠ */
/* 점검 통과/실패(설치 마법사·보안 증적) — 색만 아님, 글리프+텍스트 */
.check.ok, .check-ok { color: var(--normal-ink); font-weight: 700; }
.check.fail, .check-fail { color: var(--leak-ink); font-weight: 700; }
.check.ok::before, .check-ok::before { content: "\2713\00a0"; }
.check.fail::before, .check-fail::before { content: "\2715\00a0"; }
/* ── 차트 ─────────────────────────────────────────────────────────────────── */
.chart-legend { display: flex; gap: var(--sp-4); flex-wrap: wrap; margin: var(--sp-2) 0 var(--sp-3);
font-size: .8125rem; color: var(--ink-2); }
.chart-legend span { display: inline-flex; align-items: center; gap: 6px; }
.legend-swatch { width: 14px; height: 4px; border-radius: 2px; display: inline-block; }
.legend-swatch.temp { background: var(--chart-temp); }
.legend-swatch.hum { background: var(--chart-hum); }
.chart-svg-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.chart-empty, .empty-note { color: var(--ink-3); padding: var(--sp-6); text-align: center; }
.empty-note { padding: var(--sp-5); }
/* ── 누수 패널 ───────────────────────────────────────────────────────────── */
.leak-status-card { border-left: 1px solid var(--line); padding: var(--sp-5); }
.leak-status-card.leak { border-left: 6px solid var(--leak); background: var(--leak-bg); }
.leak-status-card.offline { border-left: 6px solid var(--offline); background: var(--offline-bg); }
.leak-thumbs { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
.leak-thumbs a { display: inline-block; line-height: 0; }
.leak-thumbs img { width: 64px; height: 64px; object-fit: cover; border-radius: var(--r-sm);
border: 1px solid var(--line); background: var(--surface-2); }
/* ── 모바일 요약 타일/링크 ──────────────────────────────────────────────── */
.mobile-quick { display: none; }
.quick-grid { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: var(--sp-2); margin-bottom: var(--sp-3); }
.quick-tile { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r);
padding: 10px; text-decoration: none; color: var(--ink); min-height: 70px; }
.quick-tile span { display: block; color: var(--ink-2); font-size: .75rem; margin-bottom: 5px; }
.quick-tile strong { font-size: 1.25rem; line-height: 1; }
.quick-tile.danger { border-color: var(--leak); background: var(--leak-bg); }
.quick-tile.warn { border-color: var(--warn); background: var(--warn-bg); }
.quick-tile.ok { border-color: var(--normal); background: var(--normal-bg); }
.quick-links { display: flex; gap: var(--sp-2); overflow-x: auto; -webkit-overflow-scrolling: touch;
padding-bottom: 6px; margin-bottom: var(--sp-4); }
.quick-links a { flex: 0 0 auto; color: var(--primary-600); background: var(--primary-tint);
border: 1px solid var(--line); text-decoration: none; border-radius: var(--r-pill);
padding: 8px 12px; font-size: .8125rem; font-weight: 700; }
/* ── 모바일 접힘(disclosure) ────────────────────────────────────────────── */
.mobile-detail { margin-bottom: var(--sp-6); }
.mobile-detail summary { display: none; }
.refresh { float: right; font-size: .8125rem; color: var(--ink-2); }
/* ── 폼 / 로그인 / 카드형 페이지 ────────────────────────────────────────── */
.login-box {
background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg);
padding: var(--sp-8) var(--sp-6); box-shadow: var(--shadow-lg); width: 100%; max-width: 400px;
}
.login-box h1 { color: var(--primary); margin-bottom: var(--sp-2); text-align: center; }
.login-box p.sub { font-size: .875rem; color: var(--ink-2); text-align: center; margin-bottom: var(--sp-6); }
.form-group { margin-bottom: var(--sp-4); }
.form-group label { display: block; color: var(--ink-2); margin-bottom: 6px; }
.form-group input, input[type=text], input[type=password], textarea {
width: 100%; padding: 10px 14px; border: 1px solid var(--line); border-radius: var(--r);
font-size: .95rem; font-family: inherit; color: var(--ink); background: var(--surface); outline: none;
}
.form-group input:focus, input:focus, textarea:focus { border-color: var(--primary); box-shadow: var(--ring); }
/* 강제 색상(고대비/Windows High Contrast)에서는 border-color·box-shadow가 무시되어
입력 포커스가 사라진다 → 시스템 아웃라인으로 포커스 링 복원 */
@media (forced-colors: active) {
input:focus, textarea:focus { outline: 2px solid; outline-offset: 1px; }
}
.btn {
display: inline-block; width: 100%; padding: 12px; background: var(--primary); color: var(--on-primary);
border: none; border-radius: var(--r); font-size: 1rem; font-weight: 700; cursor: pointer; text-align: center;
}
.btn:hover { background: var(--primary-600); }
.btn:disabled { background: var(--offline); cursor: not-allowed; }
.btn.secondary { background: var(--surface); color: var(--primary); border: 1px solid var(--line); }
/* 배너 — 성공 배너는 .banner-ok. (생 .ok 는 .badge.ok/.pill.ok/.quick-tile.ok 와
특이도 동률로 충돌해 정상 배지를 사각형으로 깨뜨리므로 네임스페이스한다.) */
.error, .expired, .banner-ok {
padding: 10px 14px; border-radius: var(--r); font-size: .875rem; margin-bottom: var(--sp-4);
border: 1px solid var(--line); border-left-width: 4px;
}
.error { background: var(--leak-bg); color: var(--leak-ink); border-left-color: var(--leak); }
.expired { background: var(--warn-bg); color: var(--warn-ink); border-left-color: var(--warn); }
.banner-ok { background: var(--normal-bg); color: var(--normal-ink); border-left-color: var(--normal); }
/* MFA 시크릿/코드 블록 등 */
.secret, code, .mono {
font-family: var(--mono); background: var(--surface-2); border: 1px solid var(--line);
border-radius: var(--r-sm); padding: 2px 6px; word-break: break-all; color: var(--ink);
}
.secret { display: block; padding: 12px; font-size: 1.05rem; letter-spacing: .06em; }
/* ── 반응형 ──────────────────────────────────────────────────────────────── */
@media (max-width: 768px) {
header { flex-direction: column; align-items: flex-start; }
.top-actions { margin-top: var(--sp-3); overflow-x: auto; flex-wrap: nowrap; -webkit-overflow-scrolling: touch; }
.top-actions a { flex: 0 0 auto; }
.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, .sensor-card { min-width: 0; }
header h1 { font-size: 1.1rem; }
.card .val { font-size: 1.6rem; }
th, td { padding: 8px 10px; }
td { font-size: .8125rem; }
.mobile-detail { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r); overflow: hidden; }
.mobile-detail summary { display: flex; justify-content: space-between; align-items: center; gap: 10px;
padding: 13px 14px; color: var(--ink); font-weight: 700; cursor: pointer; list-style: none; }
.mobile-detail summary::-webkit-details-marker { display: none; }
.mobile-detail summary span { color: var(--ink-2); font-size: .8125rem; font-weight: 600; }
.mobile-detail .table-wrap { margin-bottom: 0; border-top: 1px solid var(--line); border-radius: 0; }
}
@media (max-width: 480px) {
.container { padding: 0 var(--sp-3); }
header { padding: var(--sp-3) var(--sp-4); }
.quick-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }
.sensor-grid { grid-template-columns: 1fr; }
.card { padding: var(--sp-4); }
.col-hide-xs { display: none; }
.report-grid { grid-template-columns: 1fr; }
.login-box { padding: var(--sp-6) var(--sp-5); margin: 0 var(--sp-3); }
/* 캡션/본문 하한 유지 — 이 밑으로 축소 금지 */
th, td { font-size: .8125rem; }
}
/* ── 인쇄 — 강제 라이트(월간 리포트·보안 증적은 종이 가독) ────────────────── */
@media print {
:root {
--bg: #fff; --surface: #fff; --surface-2: #fff; --raise: #fff;
--ink: #000; --ink-2: #333; --ink-3: #444; --line: #999; --line-2: #ccc;
--primary: #14315e; --on-primary: #fff; --primary-tint: #fff;
--normal-bg: #fff; --leak-bg: #fff; --warn-bg: #fff; --offline-bg: #fff;
--cold-bg: #fff; --dry-bg: #fff;
--shadow: none; --shadow-lg: none;
}
body { background: #fff; color: #000; }
header, .top-actions, .refresh, .mobile-quick, .btn { display: none !important; }
.panel, .card, table, .chart-panel, .status-hero { box-shadow: none; border: 1px solid #999;
page-break-inside: avoid; break-inside: avoid; }
.badge, .pill { border: 1px solid currentColor; } /* 흑백에서도 테두리로 구분 */
a { color: #000; text-decoration: underline; }
}

View file

@ -7,10 +7,15 @@ $time = date('c');
?> ?>
<!doctype html><html lang="ko"><head> <!doctype html><html lang="ko"><head>
<meta charset="utf-8"><title>접근 거부</title> <meta charset="utf-8"><title>접근 거부</title>
<link rel="stylesheet" href="assets/app.css">
<meta name="robots" content="noindex"><meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="robots" content="noindex"><meta name="viewport" content="width=device-width,initial-scale=1">
<style>body{margin:0;display:grid;place-items:center;min-height:100vh;background:#faf7f2;font-family:system-ui,-apple-system,"Noto Sans KR",sans-serif;color:#222}.card{max-width:520px;background:#fff;border:1px solid #eee;border-radius:16px;padding:28px;box-shadow:0 10px 24px rgba(0,0,0,.06);text-align:center}h1{margin:0 0 8px;font-size:22px}.small{color:#666;font-size:13px;margin-top:14px}code{background:#f0f0f0;padding:2px 6px;border-radius:6px}</style> <style>
</head><body><div class="card"> /* 페이지 전용: 단독 안내 카드 중앙 정렬(대시보드 셸 없음) */
body { min-height: 100vh; display: grid; place-items: center; padding: var(--sp-4); }
.blocked-card { text-align: center; }
</style>
</head><body><div class="login-box blocked-card">
<h1>접근이 거부되었습니다</h1> <h1>접근이 거부되었습니다</h1>
<p>관리자에게 연락하세요.</p> <p class="sub">관리자에게 연락하세요.</p>
<p class="small">요청 ID: <code><?=htmlspecialchars($id)?></code></p> <p class="caption">요청 ID: <code><?=htmlspecialchars($id)?></code></p>
</div></body></html> </div></body></html>

View file

@ -321,176 +321,48 @@ if ($alert_count > 0) {
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>온습도·누수 통합 모니터링 대시보드 <?= htmlspecialchars(APP_VERSION) ?></title> <title>온습도·누수 통합 모니터링 대시보드 <?= htmlspecialchars(APP_VERSION) ?></title>
<link rel="stylesheet" href="assets/app.css">
<style> <style>
* { box-sizing: border-box; margin: 0; padding: 0; } /* dashboard.php 전용 잔여 스타일 — 값은 var(--*) 토큰만(app.css 규칙 재정의 아님) */
body { font-family: 'Noto Sans KR', sans-serif; background: #f0f4f8; color: #333; } /* 요약 카드 값: 상태 색(글리프 포함)이 .card .val 위에서 이기도록 스코프 */
header { background: #1a56db; color: #fff; padding: 16px 24px; } .card .val.ok-color { color: var(--normal-ink); }
header h1 { font-size: 1.3rem; } .card .val.err-color { color: var(--leak-ink); }
.top-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } .card .val.warn-color { color: var(--warn-ink); }
.top-actions a { color:#fff; font-size:.85rem; text-decoration:none; background:rgba(255,255,255,.15); padding:6px 14px; border-radius:6px; } .val.compact { font-size: 1.4rem; }
.container { max-width: 1100px; margin: 24px auto; padding: 0 16px; } .val.clock { font-size: 1.1rem; }
.status-hero { border-radius: 8px; color: #fff; padding: 18px 22px; margin-bottom: 18px; .cards.flush { margin-bottom: 0; }
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 16px; /* 패널 보조 캡션 */
align-items: center; box-shadow: 0 4px 18px rgba(15,23,42,.14); } .diag-summary { color: var(--ink-2); font-size: .8125rem; margin-bottom: var(--sp-3); }
.status-hero.normal { background: #166534; } .panel-note { color: var(--ink-2); font-size: .8125rem; margin-top: var(--sp-3); }
.status-hero.warning { background: #92400e; } .h2-sub { font-weight: 400; color: var(--ink-2); font-size: .8125rem; }
.status-hero.danger { background: #991b1b; } /* 자동 새로고침 토글(소형) — .refresh 안에서 인쇄 시 함께 숨김. 토큰만 사용 */
.status-hero h2 { color: #fff; margin: 0 0 6px; font-size: 1.25rem; } .refresh-toggle {
.status-hero p { color: rgba(255,255,255,.88); font-size: .9rem; } font: inherit; font-size: .8125rem; line-height: 1.2; cursor: pointer;
.status-clock { text-align: right; font-size: .82rem; color: rgba(255,255,255,.82); white-space: nowrap; } margin-left: var(--sp-2); padding: 2px 10px;
.mobile-quick { display: none; } background: var(--surface); color: var(--ink-2);
.quick-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-bottom: 10px; } border: 1px solid var(--line); border-radius: var(--r-pill);
.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; }
/* 누수(leak) 패널 */
.leak-status-card { border-left: 6px solid #22c55e; }
.leak-status-card.leak { border-left-color: #ef4444; background: #fff5f5; }
.leak-status-card.offline { border-left-color: #f59e0b; background: #fffbeb; }
.pill.leak { background: #fee2e2; color: #991b1b; }
.pill.leak_alert { background: #fee2e2; color: #991b1b; }
.pill.leak_recovery { background: #dcfce7; color: #166534; }
.leak-thumbs { display: flex; flex-wrap: wrap; gap: 6px; }
.leak-thumbs a { display: inline-block; line-height: 0; }
.leak-thumbs img { width: 64px; height: 64px; object-fit: cover;
border-radius: 6px; border: 1px solid #e2e8f0; background: #f1f5f9; }
/* 테이블 가로 스크롤 컨테이너 */
.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; }
} }
.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> </style>
<script>setTimeout(()=>location.reload(), 30000);</script><!-- 30초 자동 새로고침 --> <!-- 자동 새로고침(토글·스크롤/펼침 복원)은 body 하단 스크립트에서 처리(WCAG 2.2.1) -->
</head> </head>
<body> <body>
<header style="display:flex;align-items:center;justify-content:space-between"> <header>
<h1>온습도·누수 통합 모니터링 대시보드 <?= htmlspecialchars(APP_VERSION) ?></h1> <h1>온습도·누수 통합 모니터링 대시보드 <?= htmlspecialchars(APP_VERSION) ?></h1>
<div class="top-actions"> <div class="top-actions">
<a href="setup_wizard.php">설치 점검</a> <a href="setup_wizard.php">설치 점검</a>
@ -499,7 +371,7 @@ if ($alert_count > 0) {
<a href="?logout">로그아웃</a> <a href="?logout">로그아웃</a>
</div> </div>
</header> </header>
<div class="container"> <main class="container">
<section class="status-hero <?= htmlspecialchars($system_state) ?>"> <section class="status-hero <?= htmlspecialchars($system_state) ?>">
<div> <div>
@ -585,57 +457,61 @@ if ($alert_count > 0) {
</div> </div>
<div class="card"> <div class="card">
<div>경보 센서</div> <div>경보 센서</div>
<div class="val" style="color:<?= $alert_count > 0 ? '#ef4444' : '#22c55e' ?>"> <div class="val <?= $alert_count > 0 ? 'err-color' : 'ok-color' ?>">
<?= $alert_count ?>개 <?= $alert_count ?>개
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<div>오프라인</div> <div>오프라인</div>
<div class="val" style="color:<?= $offline_count > 0 ? '#f59e0b' : '#22c55e' ?>"> <div class="val <?= $offline_count > 0 ? 'warn-color' : 'ok-color' ?>">
<?= $offline_count ?>개 <?= $offline_count ?>개
</div> </div>
</div> </div>
</div> </div>
<div class="cards" style="margin-bottom:0"> <div class="cards flush">
<div class="card"> <div class="card">
<div>최근 온도</div> <div>최근 온도</div>
<div class="val" style="font-size:1.4rem"><?= htmlspecialchars(metric_display($latest_temp !== null ? (string)$latest_temp : null, '°C')) ?></div> <div class="val compact"><?= htmlspecialchars(metric_display($latest_temp !== null ? (string)$latest_temp : null, '°C')) ?></div>
</div> </div>
<div class="card"> <div class="card">
<div>최근 습도</div> <div>최근 습도</div>
<div class="val" style="font-size:1.4rem"><?= htmlspecialchars(metric_display($latest_hum !== null ? (string)$latest_hum : null, '%')) ?></div> <div class="val compact"><?= htmlspecialchars(metric_display($latest_hum !== null ? (string)$latest_hum : null, '%')) ?></div>
</div> </div>
<div class="card"> <div class="card">
<div>현재 시각</div> <div>현재 시각</div>
<div class="val" style="font-size:1.1rem"><?= date('Y-m-d H:i:s') ?></div> <div class="val clock"><?= date('Y-m-d H:i:s') ?></div>
</div> </div>
</div> </div>
</div> </div>
<aside class="panel" id="diagnostics-section"> <aside class="panel" id="diagnostics-section">
<h2>운영 자가진단</h2> <h2>운영 자가진단</h2>
<div style="font-size:.82rem;color:#64748b;margin-bottom:12px"> <div class="diag-summary">
실패 <?= $ops_failed ?>건 · 중요 <?= $ops_critical_count ?>건 · 주의 <?= $ops_warn_count ?>건 실패 <?= $ops_failed ?>건 · 중요 <?= $ops_critical_count ?>건 · 주의 <?= $ops_warn_count ?>건
</div> </div>
<div class="diag-list"> <div class="diag-list">
<?php foreach ($ops_checks as $check): <?php foreach ($ops_checks as $check):
$diag_class = $check['ok'] ? 'ok' : $check['severity']; $diag_class = $check['ok'] ? 'ok' : $check['severity'];
// 색 외 채널: 심각도를 텍스트로도 구분(critical=중요, warn=주의)
$diag_status_text = $check['ok']
? '정상'
: ($check['severity'] === 'critical' ? '확인 필요 · 중요' : '확인 필요 · 주의');
?> ?>
<div class="diag-item <?= htmlspecialchars($diag_class) ?>"> <div class="diag-item <?= htmlspecialchars($diag_class) ?>">
<i class="diag-dot"></i> <i class="diag-dot"></i>
<div> <div>
<strong><?= htmlspecialchars($check['label']) ?> · <?= $check['ok'] ? '정상' : '확인 필요' ?></strong> <strong><?= htmlspecialchars($check['label']) ?> · <?= htmlspecialchars($diag_status_text) ?></strong>
<span><?= htmlspecialchars($check['detail']) ?></span> <span><?= htmlspecialchars($check['detail']) ?></span>
</div> </div>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<p style="color:#64748b;font-size:.84rem;margin:12px 0 0">보안대책서 첨부용 점검표는 상단의 보안 증적 화면에서 내려받을 수 있습니다.</p> <p class="panel-note">보안대책서 첨부용 점검표는 상단의 보안 증적 화면에서 내려받을 수 있습니다.</p>
</aside> </aside>
</div> </div>
<!-- 센서 상태 --> <!-- 센서 상태 -->
<h2 id="sensors-section">센서 현재 상태 <span class="refresh">30초 자동 새로고침</span></h2> <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"> <div class="sensor-grid">
<?php foreach ($statuses as $s): <?php foreach ($statuses as $s):
// 상태 우선순위: 오프라인 > 임계 경보 > 정상 // 상태 우선순위: 오프라인 > 임계 경보 > 정상
@ -658,12 +534,13 @@ if ($alert_count > 0) {
$hum_chip_class = ''; $hum_chip_class = '';
if ($status_code === 'high_temp') { $temp_chip_class = 'hot'; } if ($status_code === 'high_temp') { $temp_chip_class = 'hot'; }
elseif ($status_code === 'low_temp') { $temp_chip_class = 'cold'; } elseif ($status_code === 'low_temp') { $temp_chip_class = 'cold'; }
elseif ($status_code === 'high_humidity' || $status_code === 'low_humidity') { $hum_chip_class = 'humid'; } elseif ($status_code === 'high_humidity') { $hum_chip_class = 'humid'; }
elseif ($status_code === 'low_humidity') { $hum_chip_class = 'dry'; }
?> ?>
<div class="sensor-card <?= $card_class ?>"> <div class="sensor-card <?= $card_class ?>">
<strong><?= htmlspecialchars($s['sensor_name']) ?></strong><br> <strong><?= htmlspecialchars($s['sensor_name']) ?></strong><br>
<small style="color:#64748b"><?= htmlspecialchars($s['device_location']) ?> / <?= htmlspecialchars($s['device_id']) ?></small><br> <small><?= htmlspecialchars($s['device_location']) ?> / <?= htmlspecialchars($s['device_id']) ?></small><br>
<div style="margin-top:10px"> <div class="badge-row">
<span class="badge <?= $badge_class ?>"> <span class="badge <?= $badge_class ?>">
<?= htmlspecialchars($badge_text) ?> <?= htmlspecialchars($badge_text) ?>
</span> </span>
@ -679,9 +556,9 @@ if ($alert_count > 0) {
</div> </div>
<div class="metric-time">측정: <?= htmlspecialchars($metric['measured_at']) ?></div> <div class="metric-time">측정: <?= htmlspecialchars($metric['measured_at']) ?></div>
<?php else: ?> <?php else: ?>
<div class="metric-time" style="margin-top:10px">측정값 없음</div> <div class="metric-time">측정값 없음</div>
<?php endif; ?> <?php endif; ?>
<div style="font-size:.75rem;margin-top:6px;<?= $s['is_offline'] ? 'color:#dc2626;font-weight:600' : 'color:#94a3b8' ?>"> <div class="metric-time<?= $s['is_offline'] ? ' stale' : '' ?>">
최종확인: <?= htmlspecialchars($s['last_seen']) ?> 최종확인: <?= htmlspecialchars($s['last_seen']) ?>
<?php if ($s['is_offline']): ?> <?php if ($s['is_offline']): ?>
(<?= $s['offline_elapsed_min'] ?>분 전) (<?= $s['offline_elapsed_min'] ?>분 전)
@ -690,7 +567,7 @@ if ($alert_count > 0) {
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($statuses)): ?> <?php if (empty($statuses)): ?>
<p style="color:#94a3b8">아직 수신된 센서 데이터가 없습니다.</p> <p class="empty-note">아직 수신된 센서 데이터가 없습니다.</p>
<?php endif; ?> <?php endif; ?>
</div> </div>
@ -720,17 +597,17 @@ if ($alert_count > 0) {
<div class="sensor-card leak-status-card <?= $leak_card_class ?>"> <div class="sensor-card leak-status-card <?= $leak_card_class ?>">
<?php if ($leak_status === null): ?> <?php if ($leak_status === null): ?>
<strong><?= (int)$leak_sid ?>번 센서 (누수)</strong><br> <strong><?= (int)$leak_sid ?>번 센서 (누수)</strong><br>
<div style="margin-top:10px"> <div class="badge-row">
<span class="badge <?= $leak_badge_class ?>"><?= htmlspecialchars($leak_badge_text) ?></span> <span class="badge <?= $leak_badge_class ?>"><?= htmlspecialchars($leak_badge_text) ?></span>
</div> </div>
<div class="metric-time" style="margin-top:10px">아직 수신된 누수 센서 데이터가 없습니다.</div> <div class="metric-time">아직 수신된 누수 센서 데이터가 없습니다.</div>
<?php else: ?> <?php else: ?>
<strong><?= htmlspecialchars($leak_status['sensor_name']) ?></strong><br> <strong><?= htmlspecialchars($leak_status['sensor_name']) ?></strong><br>
<small style="color:#64748b"><?= htmlspecialchars($leak_status['device_location']) ?> / <?= htmlspecialchars($leak_status['device_id']) ?></small><br> <small><?= htmlspecialchars($leak_status['device_location']) ?> / <?= htmlspecialchars($leak_status['device_id']) ?></small><br>
<div style="margin-top:10px"> <div class="badge-row">
<span class="badge <?= $leak_badge_class ?>"><?= htmlspecialchars($leak_badge_text) ?></span> <span class="badge <?= $leak_badge_class ?>"><?= htmlspecialchars($leak_badge_text) ?></span>
</div> </div>
<div style="font-size:.75rem;margin-top:8px;<?= $leak_status['is_offline'] ? 'color:#dc2626;font-weight:600' : 'color:#94a3b8' ?>"> <div class="metric-time<?= $leak_status['is_offline'] ? ' stale' : '' ?>">
최종확인: <?= htmlspecialchars($leak_status['last_seen']) ?> 최종확인: <?= htmlspecialchars($leak_status['last_seen']) ?>
<?php if ($leak_status['is_offline']): ?> <?php if ($leak_status['is_offline']): ?>
(<?= $leak_status['offline_elapsed_min'] ?>분 전) (<?= $leak_status['offline_elapsed_min'] ?>분 전)
@ -781,13 +658,13 @@ if ($alert_count > 0) {
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php else: ?> <?php else: ?>
<span style="color:#94a3b8">-</span> <span class="caption">-</span>
<?php endif; ?> <?php endif; ?>
</td> </td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($leak_incidents)): ?> <?php if (empty($leak_incidents)): ?>
<tr><td colspan="6" style="text-align:center;color:#94a3b8">아직 누수 사고 이력이 없습니다.</td></tr> <tr><td colspan="6" class="empty-cell">아직 누수 사고 이력이 없습니다.</td></tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>
@ -797,7 +674,7 @@ if ($alert_count > 0) {
<!-- 온습도 추이 차트 (인라인 SVG, 외부 라이브러리 없음) --> <!-- 온습도 추이 차트 (인라인 SVG, 외부 라이브러리 없음) -->
<h2 id="chart-section">온습도 추이 <h2 id="chart-section">온습도 추이
<?php if ($chart_sensor_id > 0): ?> <?php if ($chart_sensor_id > 0): ?>
<span style="font-weight:400;color:#94a3b8;font-size:.82rem"> <span class="h2-sub">
· <?= htmlspecialchars($chart_sensor_name !== '' ? $chart_sensor_name : ('센서 ' . $chart_sensor_id)) ?> 최근 <?= count($chart_points) ?>개 · <?= htmlspecialchars($chart_sensor_name !== '' ? $chart_sensor_name : ('센서 ' . $chart_sensor_id)) ?> 최근 <?= count($chart_points) ?>개
</span> </span>
<?php endif; ?> <?php endif; ?>
@ -805,8 +682,8 @@ if ($alert_count > 0) {
<div class="chart-panel"> <div class="chart-panel">
<?php if (count($chart_points) >= 2): ?> <?php if (count($chart_points) >= 2): ?>
<div class="chart-legend"> <div class="chart-legend">
<span><i class="legend-swatch" style="background:#ef4444"></i> 온도(°C)</span> <span><i class="legend-swatch temp"></i> 온도(°C)</span>
<span><i class="legend-swatch" style="background:#2563eb"></i> 습도(%)</span> <span><i class="legend-swatch hum"></i> 습도(%)</span>
</div> </div>
<div class="chart-svg-wrap"> <div class="chart-svg-wrap">
<?php <?php
@ -869,14 +746,22 @@ if ($alert_count > 0) {
$temp_segments = $build_segments('temp', $y_temp); $temp_segments = $build_segments('temp', $y_temp);
$hum_segments = $build_segments('hum', $y_hum); $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'), '.'); $fmt = fn($v) => rtrim(rtrim(number_format($v, 1, '.', ''), '0'), '.');
?> ?>
<svg viewBox="0 0 <?= $chart_w ?> <?= $chart_h ?>" width="100%" height="<?= $chart_h ?>" <svg viewBox="0 0 <?= $chart_w ?> <?= $chart_h ?>" width="100%" height="<?= $chart_h ?>"
preserveAspectRatio="xMidYMid meet" role="img" preserveAspectRatio="xMidYMid meet" role="img"
aria-label="온습도 추이 라인 차트" style="min-width:680px"> aria-label="온습도 추이 라인 차트" class="chart-svg">
<!-- 플롯 배경/축 --> <!-- 플롯 배경/축 -->
<rect x="<?= $pad_l ?>" y="<?= $pad_t ?>" width="<?= $plot_w ?>" height="<?= $plot_h ?>" <rect x="<?= $pad_l ?>" y="<?= $pad_t ?>" width="<?= $plot_w ?>" height="<?= $plot_h ?>"
fill="#f8fafc" stroke="#e2e8f0"></rect> class="plot-bg"></rect>
<?php <?php
// 가로 그리드 + 좌(온도)/우(습도) 눈금 라벨 (5단계) // 가로 그리드 + 좌(온도)/우(습도) 눈금 라벨 (5단계)
for ($g = 0; $g <= 4; $g++): for ($g = 0; $g <= 4; $g++):
@ -886,11 +771,11 @@ if ($alert_count > 0) {
?> ?>
<line x1="<?= $pad_l ?>" y1="<?= sprintf('%.1f', $gy) ?>" <line x1="<?= $pad_l ?>" y1="<?= sprintf('%.1f', $gy) ?>"
x2="<?= $pad_l + $plot_w ?>" y2="<?= sprintf('%.1f', $gy) ?>" x2="<?= $pad_l + $plot_w ?>" y2="<?= sprintf('%.1f', $gy) ?>"
stroke="#e2e8f0" stroke-dasharray="3,3"></line> class="grid-line" stroke-dasharray="3,3"></line>
<text x="<?= $pad_l - 6 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>" <text x="<?= $pad_l - 6 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>"
text-anchor="end" font-size="10" fill="#ef4444"><?= htmlspecialchars($fmt($tv)) ?></text> 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 x="<?= $pad_l + $plot_w + 6 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>"
text-anchor="start" font-size="10" fill="#2563eb"><?= htmlspecialchars($fmt($hv)) ?></text> text-anchor="start" font-size="10" class="axis-hum"><?= htmlspecialchars($fmt($hv)) ?></text>
<?php endfor; ?> <?php endfor; ?>
<!-- X축 시간 라벨 (시작/중간/끝) --> <!-- X축 시간 라벨 (시작/중간/끝) -->
@ -902,21 +787,63 @@ if ($alert_count > 0) {
$tlabel = substr($chart_points[$li]['t'], 5, 11); // MM-DD HH:MM $tlabel = substr($chart_points[$li]['t'], 5, 11); // MM-DD HH:MM
?> ?>
<text x="<?= sprintf('%.1f', $lx) ?>" y="<?= $chart_h - 12 ?>" <text x="<?= sprintf('%.1f', $lx) ?>" y="<?= $chart_h - 12 ?>"
text-anchor="<?= $anchor ?>" font-size="10" fill="#94a3b8"><?= htmlspecialchars($tlabel) ?></text> text-anchor="<?= $anchor ?>" font-size="10" class="axis-x"><?= htmlspecialchars($tlabel) ?></text>
<?php endforeach; ?> <?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): ?> <?php foreach ($hum_segments as $seg): ?>
<polyline points="<?= htmlspecialchars(implode(' ', $seg)) ?>" <polyline points="<?= htmlspecialchars(implode(' ', $seg)) ?>"
fill="none" stroke="#2563eb" stroke-width="1.8" class="line-hum" fill="none" stroke-width="1.8" stroke-dasharray="6,3"
stroke-linejoin="round" stroke-linecap="round"></polyline> stroke-linejoin="round" stroke-linecap="round"></polyline>
<?php endforeach; ?> <?php endforeach; ?>
<!-- 온도 라인 --> <!-- 온도 라인 -->
<?php foreach ($temp_segments as $seg): ?> <?php foreach ($temp_segments as $seg): ?>
<polyline points="<?= htmlspecialchars(implode(' ', $seg)) ?>" <polyline points="<?= htmlspecialchars(implode(' ', $seg)) ?>"
fill="none" stroke="#ef4444" stroke-width="1.8" class="line-temp" fill="none" stroke-width="1.8"
stroke-linejoin="round" stroke-linecap="round"></polyline> stroke-linejoin="round" stroke-linecap="round"></polyline>
<?php endforeach; ?> <?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> </svg>
</div> </div>
<?php else: ?> <?php else: ?>
@ -924,7 +851,7 @@ if ($alert_count > 0) {
<?php endif; ?> <?php endif; ?>
</div> </div>
<section class="panel" id="summary-section" style="margin-bottom:24px"> <section class="panel" id="summary-section">
<h2>30일 운영 요약</h2> <h2>30일 운영 요약</h2>
<div class="report-grid"> <div class="report-grid">
<div class="report-stat"> <div class="report-stat">
@ -957,7 +884,7 @@ if ($alert_count > 0) {
</div> </div>
<div class="report-stat"> <div class="report-stat">
<span>누수감지 SMS</span> <span>누수감지 SMS</span>
<strong style="color:<?= $summary['leak_alert'] > 0 ? '#dc2626' : 'inherit' ?>"><?= $summary['leak_alert'] ?>건</strong> <strong class="<?= $summary['leak_alert'] > 0 ? 'err-color' : '' ?>"><?= $summary['leak_alert'] ?>건</strong>
</div> </div>
<div class="report-stat"> <div class="report-stat">
<span>누수복구 SMS</span> <span>누수복구 SMS</span>
@ -965,7 +892,7 @@ if ($alert_count > 0) {
</div> </div>
<div class="report-stat"> <div class="report-stat">
<span>SMS 실패</span> <span>SMS 실패</span>
<strong style="color:<?= $summary['sms_fail'] > 0 ? '#dc2626' : 'inherit' ?>"><?= $summary['sms_fail'] ?>건</strong> <strong class="<?= $summary['sms_fail'] > 0 ? 'err-color' : '' ?>"><?= $summary['sms_fail'] ?>건</strong>
</div> </div>
<div class="report-stat"> <div class="report-stat">
<span>평균 온도</span> <span>평균 온도</span>
@ -1013,7 +940,7 @@ if ($alert_count > 0) {
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($recent_metrics)): ?> <?php if (empty($recent_metrics)): ?>
<tr><td colspan="6" style="text-align:center;color:#94a3b8">아직 온습도 측정 데이터가 없습니다.</td></tr> <tr><td colspan="6" class="empty-cell">아직 온습도 측정 데이터가 없습니다.</td></tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>
@ -1058,7 +985,7 @@ if ($alert_count > 0) {
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($logs)): ?> <?php if (empty($logs)): ?>
<tr><td colspan="5" style="text-align:center;color:#94a3b8">아직 센서 이벤트가 없습니다.</td></tr> <tr><td colspan="5" class="empty-cell">아직 센서 이벤트가 없습니다.</td></tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>
@ -1088,36 +1015,115 @@ if ($alert_count > 0) {
<span class="err-color">실패</span> <span class="err-color">실패</span>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td class="col-hide-xs" style="font-size:.75rem;color:#64748b"><?= htmlspecialchars($row['result_message']) ?></td> <td class="col-hide-xs caption"><?= htmlspecialchars($row['result_message']) ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($sms_logs)): ?> <?php if (empty($sms_logs)): ?>
<tr><td colspan="6" style="text-align:center;color:#94a3b8">아직 SMS 발송 이력이 없습니다.</td></tr> <tr><td colspan="6" class="empty-cell">아직 SMS 발송 이력이 없습니다.</td></tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>
</div> </div>
</details> </details>
</div> </main>
<script> <script>
function applyMobileDisclosureState() { (function () {
const shouldCollapse = window.matchMedia('(max-width: 768px)').matches; 'use strict';
document.querySelectorAll('details[data-mobile-collapsed]').forEach((detail) => { var REFRESH_MS = 30000;
if (shouldCollapse && !detail.dataset.mobileInitialized) { var LS_REFRESH = 'leakDash.autoRefresh'; // 'on' | 'off' (기본 on)
detail.removeAttribute('open'); var SS_SCROLL = 'leakDash.scrollY'; // reload 직전 저장
detail.dataset.mobileInitialized = '1'; var SS_DETAILS = 'leakDash.detailsOpen'; // 열린 mobile-detail 인덱스 JSON
}
if (!shouldCollapse) {
detail.setAttribute('open', '');
delete detail.dataset.mobileInitialized;
}
});
}
applyMobileDisclosureState(); var details = Array.prototype.slice.call(
window.addEventListener('resize', applyMobileDisclosureState); 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> </script>
</body> </body>
</html> </html>

View file

@ -162,62 +162,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$is_locked && !$configuration_erro
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>로그인 - 누수감지 대시보드 <?= htmlspecialchars(APP_VERSION) ?></title> <title>로그인 - 누수감지 대시보드 <?= htmlspecialchars(APP_VERSION) ?></title>
<link rel="stylesheet" href="assets/app.css">
<style> <style>
* { box-sizing: border-box; margin: 0; padding: 0; } /* 로그인 페이지 전용: 카드 화면 중앙 정렬 (그 외 크롬/폼 스타일은 app.css) */
body { font-family: 'Noto Sans KR', sans-serif; background: #f0f4f8; color: #333; body { display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: var(--sp-4); }
display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.login-box { background: #fff; border-radius: 12px; padding: 40px 36px;
box-shadow: 0 4px 24px rgba(0,0,0,.1); width: 100%; max-width: 400px; }
.login-box h1 { font-size: 1.4rem; color: #1a56db; margin-bottom: 8px; text-align: center; }
.login-box p.sub { font-size: .85rem; color: #64748b; text-align: center; margin-bottom: 28px; }
.form-group { margin-bottom: 18px; }
.form-group label { display: block; font-size: .85rem; color: #475569; margin-bottom: 6px; font-weight: 600; }
.form-group input { width: 100%; padding: 10px 14px; border: 1px solid #d1d5db; border-radius: 8px;
font-size: .95rem; outline: none; transition: border-color .2s; }
.form-group input:focus { border-color: #1a56db; box-shadow: 0 0 0 3px rgba(26,86,219,.1); }
.btn { width: 100%; padding: 12px; background: #1a56db; color: #fff; border: none;
border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer;
transition: background .2s; }
.btn:hover { background: #1347b8; }
.btn:disabled { background: #94a3b8; cursor: not-allowed; }
.error { background: #fef2f2; color: #991b1b; padding: 10px 14px; border-radius: 8px;
font-size: .85rem; margin-bottom: 18px; border: 1px solid #fecaca; }
.expired { background: #fefce8; color: #854d0e; padding: 10px 14px; border-radius: 8px;
font-size: .85rem; margin-bottom: 18px; border: 1px solid #fde68a; }
/* --- 폰 (≤480px) --- */
@media (max-width: 480px) {
.login-box { padding: 28px 20px; margin: 0 12px; }
.login-box h1 { font-size: 1.2rem; }
.login-box p.sub { font-size: .8rem; margin-bottom: 20px; }
.form-group input { font-size: .9rem; padding: 9px 12px; }
.btn { font-size: .95rem; padding: 11px; }
}
/* --- 초소형 (≤360px) --- */
@media (max-width: 360px) {
.login-box { padding: 24px 16px; margin: 0 8px; }
.login-box h1 { font-size: 1.1rem; }
.form-group input { font-size: .85rem; padding: 8px 10px; }
.btn { font-size: .9rem; padding: 10px; }
}
</style> </style>
</head> </head>
<body> <body>
<div class="login-box"> <main class="login-box">
<h1>누수감지 모니터링 <?= htmlspecialchars(APP_VERSION) ?></h1> <h1>누수감지 모니터링 <?= htmlspecialchars(APP_VERSION) ?></h1>
<p class="sub">대시보드에 접근하려면 로그인하세요.</p> <p class="sub">대시보드에 접근하려면 로그인하세요.</p>
<?php if ($expired): ?> <?php if ($expired): ?>
<div class="expired">세션이 만료되었습니다. 다시 로그인해주세요.</div> <div class="expired" role="status">세션이 만료되었습니다. 다시 로그인해주세요.</div>
<?php endif; ?> <?php endif; ?>
<?php if ($error): ?> <?php if ($error): ?>
<div class="error"><?= htmlspecialchars($error) ?></div> <div class="error" role="alert"><?= htmlspecialchars($error) ?></div>
<?php endif; ?> <?php endif; ?>
<?php if ($configuration_error): ?> <?php if ($configuration_error): ?>
<div class="error">관리자 비밀번호 해시 또는 MFA 인증앱 비밀키가 설정되지 않았습니다. config.local.php의 ADMIN_PASSWORD_HASH와 ADMIN_TOTP_SECRET을 확인하세요.</div> <div class="error" role="alert">관리자 비밀번호 해시 또는 MFA 인증앱 비밀키가 설정되지 않았습니다. config.local.php의 ADMIN_PASSWORD_HASH와 ADMIN_TOTP_SECRET을 확인하세요.</div>
<?php endif; ?> <?php endif; ?>
<form method="POST" action=""> <form method="POST" action="">
@ -240,6 +205,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$is_locked && !$configuration_erro
</div> </div>
<button type="submit" class="btn" <?= ($is_locked || $configuration_error) ? 'disabled' : '' ?>>로그인</button> <button type="submit" class="btn" <?= ($is_locked || $configuration_error) ? 'disabled' : '' ?>>로그인</button>
</form> </form>
</div> </main>
</body> </body>
</html> </html>

View file

@ -251,52 +251,37 @@ if (($_GET['format'] ?? '') === 'csv') {
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>월간 운영 보고서 - <?= htmlspecialchars($month) ?></title> <title>월간 운영 보고서 - <?= htmlspecialchars($month) ?></title>
<link rel="stylesheet" href="assets/app.css">
<style> <style>
* { box-sizing: border-box; } /* 월간 리포트 전용: 조회 툴바(공유 app.css 미포함). 값은 전부 토큰 — 생 hex 없음 */
body { margin: 0; font-family: 'Noto Sans KR', sans-serif; background: #f0f4f8; color: #1f2937; } .toolbar {
header { background: #1a56db; color: #fff; padding: 16px 24px; display:flex; justify-content:space-between; align-items:center; gap:12px; } display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3);
header h1 { margin:0; font-size:1.15rem; } background: var(--surface); border: 1px solid var(--line); border-radius: var(--r);
header a { color:#fff; text-decoration:none; background:rgba(255,255,255,.15); padding:7px 12px; border-radius:6px; font-size:.85rem; } box-shadow: var(--shadow); padding: var(--sp-3) var(--sp-4); margin-bottom: var(--sp-5);
.container { max-width: 1060px; margin: 24px auto; padding: 0 16px; }
.toolbar { background:#fff; border-radius:8px; padding:14px 16px; box-shadow:0 1px 4px rgba(0,0,0,.1); display:flex; gap:10px; flex-wrap:wrap; align-items:center; margin-bottom:18px; }
input, button, .btn { border:1px solid #cbd5e1; border-radius:6px; padding:8px 10px; font-size:.9rem; }
button, .btn { background:#1a56db; color:#fff; border-color:#1a56db; text-decoration:none; cursor:pointer; }
.toolbar label { display:flex; align-items:center; gap:6px; }
.grid { display:grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap:12px; margin-bottom:18px; }
.card { background:#fff; border-radius:8px; padding:16px; box-shadow:0 1px 4px rgba(0,0,0,.1); }
.card span { color:#64748b; font-size:.78rem; display:block; margin-bottom:5px; }
.card strong { font-size:1.35rem; }
table { width:100%; border-collapse:collapse; background:#fff; border-radius:8px; overflow:hidden; box-shadow:0 1px 4px rgba(0,0,0,.1); margin-bottom:22px; }
th, td { padding:9px 12px; border-bottom:1px solid #e2e8f0; text-align:left; font-size:.84rem; }
th { background:#f8fafc; color:#64748b; }
h2 { font-size:1rem; color:#475569; margin:22px 0 10px; }
.table-wrap { overflow-x:auto; -webkit-overflow-scrolling:touch; margin-bottom:22px; border-radius:8px; }
.table-wrap table { margin-bottom:0; min-width:860px; }
@media print {
body { background:#fff; }
header, .toolbar { display:none; }
.container { margin:0; max-width:none; }
.card, table { box-shadow:none; }
} }
@media (max-width:760px) { .grid { grid-template-columns: repeat(2, minmax(0,1fr)); } header { flex-direction:column; align-items:flex-start; } } .toolbar label { display: flex; align-items: center; gap: var(--sp-2); }
@media (max-width:520px) { .toolbar input[type="month"] {
.container { margin:16px auto; padding:0 10px; } padding: 8px 10px; border: 1px solid var(--line); border-radius: var(--r-sm);
header { padding:14px 16px; } font-family: inherit; font-size: .875rem; color: var(--ink); background: var(--surface);
header h1 { font-size:1rem; }
.toolbar { display:grid; grid-template-columns:1fr; }
.toolbar label, .toolbar input, .toolbar button, .toolbar .btn { width:100%; }
.toolbar label { display:grid; gap:6px; }
.grid { grid-template-columns: repeat(2, minmax(0,1fr)); gap:8px; }
.card { padding:12px; }
.card strong { font-size:1.05rem; }
} }
@media (max-width:360px) { .grid { grid-template-columns:1fr; } } .toolbar button, .toolbar .tool-btn {
padding: 8px 14px; border: 1px solid var(--primary); border-radius: var(--r-sm);
font-size: .875rem; font-weight: 700; cursor: pointer; text-decoration: none;
background: var(--primary); color: var(--on-primary);
}
.toolbar button:hover, .toolbar .tool-btn:hover {
background: var(--primary-600); border-color: var(--primary-600);
}
/* 표 빈 상태 셀(가운데 정렬·뮤트) — app.css 는 블록형 .empty-note 만 제공 */
.no-data { text-align: center; color: var(--ink-3); }
/* 툴바는 페이지 전용 요소 → app.css print 규칙에 없어 중복이 아님 */
@media print { .toolbar { display: none; } }
</style> </style>
</head> </head>
<body> <body>
<header> <header>
<h1><?= htmlspecialchars($month) ?> 월간 운영 보고서</h1> <h1><?= htmlspecialchars($month) ?> 월간 운영 보고서</h1>
<div> <div class="top-actions">
<a href="dashboard.php">대시보드</a> <a href="dashboard.php">대시보드</a>
</div> </div>
</header> </header>
@ -304,27 +289,28 @@ if (($_GET['format'] ?? '') === 'csv') {
<form class="toolbar" method="GET"> <form class="toolbar" method="GET">
<label>월 <input type="month" name="month" value="<?= htmlspecialchars($month) ?>"></label> <label>월 <input type="month" name="month" value="<?= htmlspecialchars($month) ?>"></label>
<button type="submit">조회</button> <button type="submit">조회</button>
<a class="btn" href="?month=<?= htmlspecialchars($month) ?>&format=csv">CSV 다운로드</a> <a class="tool-btn" href="?month=<?= htmlspecialchars($month) ?>&format=csv">CSV 다운로드</a>
<button type="button" onclick="window.print()">인쇄/PDF</button> <button type="button" onclick="window.print()">인쇄/PDF</button>
</form> </form>
<section class="card" style="margin-bottom:18px"> <section class="panel">
<span>보고서 생성</span> <p class="caption">보고서 생성</p>
<strong style="font-size:1rem"><?= htmlspecialchars($report_generated_at) ?></strong> <strong><?= htmlspecialchars($report_generated_at) ?></strong>
<p style="color:#64748b;font-size:.84rem;margin:8px 0 0">범위: <?= htmlspecialchars($report_scope) ?></p> <p class="caption">범위: <?= htmlspecialchars($report_scope) ?></p>
</section> </section>
<section class="grid"> <h2>월간 요약</h2>
<div class="card"><span>임계 경보(합계)</span><strong><?= $summary['threshold_alerts'] ?>건</strong></div> <section class="report-grid">
<div class="card"><span>고온/저온 경보</span><strong><?= $summary['high_temp'] ?>/<?= $summary['low_temp'] ?>건</strong></div> <div class="report-stat"><span>임계 경보(합계)</span><strong><?= $summary['threshold_alerts'] ?>건</strong></div>
<div class="card"><span>고습/저습 경보</span><strong><?= $summary['high_humidity'] ?>/<?= $summary['low_humidity'] ?>건</strong></div> <div class="report-stat"><span>고온/저온 경보</span><strong><?= $summary['high_temp'] ?>/<?= $summary['low_temp'] ?>건</strong></div>
<div class="card"><span>정상복귀</span><strong><?= $summary['recovery'] ?>건</strong></div> <div class="report-stat"><span>고습/저습 경보</span><strong><?= $summary['high_humidity'] ?>/<?= $summary['low_humidity'] ?>건</strong></div>
<div class="card"><span>장비 오프라인/복구</span><strong><?= $summary['device_offline'] ?>/<?= $summary['device_online'] ?>건</strong></div> <div class="report-stat"><span>정상복귀</span><strong><?= $summary['recovery'] ?>건</strong></div>
<div class="card"><span>SMS 실패</span><strong><?= $summary['sms_fail'] ?>건</strong></div> <div class="report-stat"><span>장비 오프라인/복구</span><strong><?= $summary['device_offline'] ?>/<?= $summary['device_online'] ?>건</strong></div>
<div class="card"><span>측정 건수</span><strong><?= $summary['metrics'] ?>건</strong></div> <div class="report-stat"><span>SMS 실패</span><strong><?= $summary['sms_fail'] ?>건</strong></div>
<div class="card"><span>평균 온도/습도</span><strong><?= fmt_num($summary['avg_temp'], '℃') ?> / <?= fmt_num($summary['avg_humidity'], '%') ?></strong></div> <div class="report-stat"><span>측정 건수</span><strong><?= $summary['metrics'] ?>건</strong></div>
<div class="card"><span>온도 최저/최고</span><strong><?= fmt_num($summary['min_temp'], '℃') ?> / <?= fmt_num($summary['max_temp'], '℃') ?></strong></div> <div class="report-stat"><span>평균 온도/습도</span><strong><?= fmt_num($summary['avg_temp'], '℃') ?> / <?= fmt_num($summary['avg_humidity'], '%') ?></strong></div>
<div class="card"><span>습도 최저/최고</span><strong><?= fmt_num($summary['min_humidity'], '%') ?> / <?= fmt_num($summary['max_humidity'], '%') ?></strong></div> <div class="report-stat"><span>온도 최저/최고</span><strong><?= fmt_num($summary['min_temp'], '℃') ?> / <?= fmt_num($summary['max_temp'], '℃') ?></strong></div>
<div class="report-stat"><span>습도 최저/최고</span><strong><?= fmt_num($summary['min_humidity'], '%') ?> / <?= fmt_num($summary['max_humidity'], '%') ?></strong></div>
</section> </section>
<h2>센서별 요약</h2> <h2>센서별 요약</h2>
@ -356,7 +342,7 @@ if (($_GET['format'] ?? '') === 'csv') {
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($sensor_rows)): ?> <?php if (empty($sensor_rows)): ?>
<tr><td colspan="13" style="text-align:center;color:#94a3b8">해당 월 측정·경보 내역이 없습니다.</td></tr> <tr><td colspan="13" class="no-data">해당 월 측정·경보 내역이 없습니다.</td></tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>

View file

@ -68,56 +68,43 @@ if (($_GET['format'] ?? '') === 'md') {
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>보안 증적 보고서 - 온습도 모니터링 <?= htmlspecialchars(APP_VERSION) ?></title> <title>보안 증적 보고서 - 온습도 모니터링 <?= htmlspecialchars(APP_VERSION) ?></title>
<link rel="stylesheet" href="assets/app.css">
<style> <style>
* { box-sizing: border-box; } /* 보안 증적 전용 잔여 스타일 — 값은 app.css 토큰(var)만 사용 */
body { margin: 0; font-family: 'Noto Sans KR', sans-serif; background: #f0f4f8; color: #1f2937; } .evi-summary { margin-bottom: var(--sp-5); }
header { background: #1a56db; color: #fff; padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; gap: 12px; } .evi-summary strong { font-size: 1.25rem; }
header h1 { font-size: 1.15rem; margin: 0; } .evi-actions { display: flex; gap: var(--sp-2); flex-wrap: wrap; margin-top: var(--sp-4); }
header a, .btn { color: #fff; text-decoration: none; background: rgba(255,255,255,.15); padding: 7px 12px; border-radius: 6px; font-size: .85rem; border: 0; cursor: pointer; } .evi-actions a, .evi-actions button {
.container { max-width: 1120px; margin: 24px auto; padding: 0 16px; } display: inline-block; background: var(--primary); color: var(--on-primary);
.summary { background: #fff; border-radius: 8px; padding: 18px; box-shadow: 0 1px 4px rgba(0,0,0,.1); margin-bottom: 18px; } border: 0; border-radius: var(--r-sm); padding: 8px 14px;
.summary strong { font-size: 1.2rem; color: <?= $failed_count ? '#b91c1c' : '#166534' ?>; } font-size: .8125rem; font-weight: 700; font-family: var(--font);
.actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; } text-decoration: none; cursor: pointer;
.actions a, .actions button { background: #1a56db; color: #fff; border: 0; border-radius: 6px; padding: 8px 12px; text-decoration: none; font-weight: 700; cursor: pointer; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.08); margin-bottom: 24px; }
th, td { border-bottom: 1px solid #e2e8f0; padding: 9px 10px; text-align: left; font-size: .84rem; vertical-align: top; }
th { background: #f8fafc; color: #475569; }
h2 { font-size: 1rem; color: #334155; margin: 24px 0 10px; }
.ok { color: #166534; font-weight: 700; }
.fail { color: #b91c1c; font-weight: 700; }
.muted { color: #64748b; font-size: .84rem; }
@media print {
body { background: #fff; }
header, .actions { display: none; }
.container { margin: 0; max-width: none; }
table, .summary { box-shadow: none; }
}
@media (max-width: 760px) {
header { align-items: flex-start; flex-direction: column; }
table { display: block; overflow-x: auto; }
} }
.evi-actions a:hover, .evi-actions button:hover { background: var(--primary-600); }
@media print { .evi-actions { display: none; } }
</style> </style>
</head> </head>
<body> <body>
<header> <header>
<h1>보안 증적 보고서 <?= htmlspecialchars(APP_VERSION) ?></h1> <h1>보안 증적 보고서 <?= htmlspecialchars(APP_VERSION) ?></h1>
<div style="display:flex;gap:8px;flex-wrap:wrap"> <div class="top-actions">
<a href="dashboard.php">대시보드</a> <a href="dashboard.php">대시보드</a>
<a href="setup_wizard.php">설치 점검</a> <a href="setup_wizard.php">설치 점검</a>
</div> </div>
</header> </header>
<main class="container"> <main class="container">
<section class="summary"> <section class="panel evi-summary">
<strong><?= $failed_count === 0 ? '전체 점검 통과' : "확인 필요 {$failed_count}건" ?></strong> <strong class="<?= $failed_count === 0 ? 'check-ok' : 'check-fail' ?>"><?= $failed_count === 0 ? '전체 점검 통과' : "확인 필요 {$failed_count}건" ?></strong>
<p class="muted">생성시각: <?= htmlspecialchars($generated_at) ?> · 버전: <?= htmlspecialchars(APP_VERSION) ?></p> <p class="caption">생성시각: <?= htmlspecialchars($generated_at) ?> · 버전: <?= htmlspecialchars(APP_VERSION) ?></p>
<p class="muted">이 보고서는 보안대책서 첨부용 운영 점검 결과와 보안통제 매트릭스를 제공합니다. 비밀번호, API 키, SMS 인증키 원문은 표시하지 않습니다.</p> <p class="caption">이 보고서는 보안대책서 첨부용 운영 점검 결과와 보안통제 매트릭스를 제공합니다. 비밀번호, API 키, SMS 인증키 원문은 표시하지 않습니다.</p>
<div class="actions"> <div class="evi-actions">
<a href="?format=md">Markdown 다운로드</a> <a href="?format=md">Markdown 다운로드</a>
<button type="button" onclick="window.print()">인쇄/PDF</button> <button type="button" onclick="window.print()">인쇄/PDF</button>
</div> </div>
</section> </section>
<h2>운영 점검 결과</h2> <h2>운영 점검 결과</h2>
<div class="table-wrap">
<table> <table>
<thead> <thead>
<tr><th>분류</th><th>항목</th><th>결과</th><th>상세</th><th>조치</th><th>증적</th></tr> <tr><th>분류</th><th>항목</th><th>결과</th><th>상세</th><th>조치</th><th>증적</th></tr>
@ -127,7 +114,7 @@ if (($_GET['format'] ?? '') === 'md') {
<tr> <tr>
<td><?= htmlspecialchars($check['category']) ?></td> <td><?= htmlspecialchars($check['category']) ?></td>
<td><?= htmlspecialchars($check['label']) ?></td> <td><?= htmlspecialchars($check['label']) ?></td>
<td class="<?= $check['ok'] ? 'ok' : 'fail' ?>"><?= $check['ok'] ? '정상' : '확인 필요' ?></td> <td class="<?= $check['ok'] ? 'check-ok' : 'check-fail' ?>"><?= $check['ok'] ? '정상' : '확인 필요' ?></td>
<td><?= htmlspecialchars($check['detail']) ?></td> <td><?= htmlspecialchars($check['detail']) ?></td>
<td><?= htmlspecialchars($check['fix'] ?: '-') ?></td> <td><?= htmlspecialchars($check['fix'] ?: '-') ?></td>
<td><?= htmlspecialchars($check['evidence'] ?: '-') ?></td> <td><?= htmlspecialchars($check['evidence'] ?: '-') ?></td>
@ -135,8 +122,10 @@ if (($_GET['format'] ?? '') === 'md') {
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
</table> </table>
</div>
<h2>보안통제 매트릭스</h2> <h2>보안통제 매트릭스</h2>
<div class="table-wrap">
<table> <table>
<thead> <thead>
<tr><th>영역</th><th>보안대책</th><th>구현 위치</th><th>확인 증적</th></tr> <tr><th>영역</th><th>보안대책</th><th>구현 위치</th><th>확인 증적</th></tr>
@ -151,6 +140,7 @@ if (($_GET['format'] ?? '') === 'md') {
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
</table> </table>
</div>
</main> </main>
</body> </body>
</html> </html>

View file

@ -70,36 +70,25 @@ $token = $_GET['token'] ?? $_POST['token'] ?? '';
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>관리자 MFA 등록</title> <title>관리자 MFA 등록</title>
<link rel="stylesheet" href="assets/app.css">
<style> <style>
* { box-sizing: border-box; } /* 페이지 전용: 문서형 좁은 단 (app.css .container는 1320px로 넓음). 값은 토큰만. */
body { margin: 0; font-family: 'Noto Sans KR', sans-serif; background: #f0f4f8; color: #1f2937; } .mfa-doc { max-width: 760px; margin: var(--sp-6) auto; padding: 0 var(--sp-4); }
main { max-width: 760px; margin: 32px auto; padding: 0 16px; } .mfa-doc .panel { margin-bottom: var(--sp-4); }
.panel { background: #fff; border-radius: 8px; padding: 22px; box-shadow: 0 1px 5px rgba(15,23,42,.12); margin-bottom: 16px; } .mfa-doc textarea { min-height: 82px; resize: vertical; }
h1 { font-size: 1.3rem; margin: 0 0 8px; color: #1a56db; }
h2 { font-size: 1rem; margin: 20px 0 8px; }
p, li { line-height: 1.55; font-size: .92rem; }
code, textarea { font-family: Consolas, Monaco, monospace; }
textarea, input[type=text] { width: 100%; border: 1px solid #cbd5e1; border-radius: 6px; padding: 10px; font-size: .9rem; }
textarea { min-height: 82px; resize: vertical; }
label { display: block; font-weight: 700; margin: 12px 0 6px; }
.secret { font-size: 1.05rem; letter-spacing: .08em; word-break: break-all; background: #f8fafc; padding: 12px; border-radius: 6px; border: 1px solid #e2e8f0; }
.btn { background: #1a56db; color: #fff; border: 0; border-radius: 6px; padding: 10px 14px; font-weight: 700; cursor: pointer; }
.error { background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; border-radius: 6px; padding: 10px; }
.ok { background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534; border-radius: 6px; padding: 10px; }
.muted { color: #64748b; }
</style> </style>
</head> </head>
<body> <body>
<main> <main class="mfa-doc">
<section class="panel"> <section class="panel">
<h1>Google Authenticator MFA 등록</h1> <h1>Google Authenticator MFA 등록</h1>
<p class="muted">이 화면은 Google Authenticator, Microsoft Authenticator, Authy 등 TOTP 인증 앱에 관리자 계정을 등록하기 위한 도구입니다. 외부 Google API로 비밀키를 보내지 않습니다.</p> <p class="caption">이 화면은 Google Authenticator, Microsoft Authenticator, Authy 등 TOTP 인증 앱에 관리자 계정을 등록하기 위한 도구입니다. 외부 Google API로 비밀키를 보내지 않습니다.</p>
<?php if ($error): ?> <?php if ($error): ?>
<div class="error"><?= htmlspecialchars($error) ?></div> <div class="error" role="alert"><?= htmlspecialchars($error) ?></div>
<?php endif; ?> <?php endif; ?>
<?php if ($verified): ?> <?php if ($verified): ?>
<div class="ok">인증 앱 코드가 확인되었습니다. 아래 설정 줄을 `config.local.php`에 반영하세요.</div> <div class="banner-ok" role="status">인증 앱 코드가 확인되었습니다. 아래 설정 줄을 `config.local.php`에 반영하세요.</div>
<?php endif; ?> <?php endif; ?>
<h2>1. 인증 앱에 수동 등록</h2> <h2>1. 인증 앱에 수동 등록</h2>
@ -111,7 +100,7 @@ $token = $_GET['token'] ?? $_POST['token'] ?? '';
<div class="secret"><?= htmlspecialchars($secret) ?></div> <div class="secret"><?= htmlspecialchars($secret) ?></div>
<h2>2. 앱 링크 등록</h2> <h2>2. 앱 링크 등록</h2>
<p class="muted">모바일 브라우저에서 열면 인증 앱이 이 URI를 인식할 수 있습니다. 데스크톱에서는 수동 등록을 사용하세요.</p> <p class="caption">모바일 브라우저에서 열면 인증 앱이 이 URI를 인식할 수 있습니다. 데스크톱에서는 수동 등록을 사용하세요.</p>
<textarea readonly><?= htmlspecialchars($uri) ?></textarea> <textarea readonly><?= htmlspecialchars($uri) ?></textarea>
<p><a class="btn" href="<?= htmlspecialchars($uri) ?>">인증 앱으로 열기</a></p> <p><a class="btn" href="<?= htmlspecialchars($uri) ?>">인증 앱으로 열기</a></p>
@ -130,7 +119,7 @@ $token = $_GET['token'] ?? $_POST['token'] ?? '';
<?php if ($verified): ?> <?php if ($verified): ?>
<h2>4. config.local.php 반영</h2> <h2>4. config.local.php 반영</h2>
<textarea readonly>'ADMIN_TOTP_SECRET' => '<?= htmlspecialchars($secret) ?>',</textarea> <textarea readonly>'ADMIN_TOTP_SECRET' => '<?= htmlspecialchars($secret) ?>',</textarea>
<p class="muted">반영 후 `MFA_SETUP_TOKEN`은 빈 값으로 바꾸거나 삭제하세요.</p> <p class="caption">반영 후 `MFA_SETUP_TOKEN`은 빈 값으로 바꾸거나 삭제하세요.</p>
<?php endif; ?> <?php endif; ?>
</section> </section>
</main> </main>

View file

@ -52,66 +52,64 @@ $failed_count = ops_failed_count($checks);
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>설치 점검 - 온습도 모니터링 <?= htmlspecialchars(APP_VERSION) ?></title> <title>설치 점검 - 온습도 모니터링 <?= htmlspecialchars(APP_VERSION) ?></title>
<link rel="stylesheet" href="assets/app.css">
<style> <style>
* { box-sizing: border-box; } /* 설치 점검 전용 잔여 스타일 — 값은 app.css 토큰(var)만 사용 */
body { margin: 0; font-family: 'Noto Sans KR', sans-serif; background: #f0f4f8; color: #1f2937; } .wiz-summary { margin-bottom: var(--sp-5); }
header { background: #1a56db; color: #fff; padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; } .wiz-summary strong { font-size: 1.25rem; }
header h1 { font-size: 1.15rem; margin: 0; } .check-list { display: grid; gap: var(--sp-3); margin-bottom: var(--sp-5); }
header a { color: #fff; text-decoration: none; background: rgba(255,255,255,.15); padding: 7px 12px; border-radius: 6px; font-size: .85rem; } /* 점검 카드 = .panel + 좌측레일(도형). 상태 텍스트는 .check-ok/.check-fail(글리프+색) */
.container { max-width: 980px; margin: 24px auto; padding: 0 16px; } .check-list .panel { border-left: 6px solid var(--normal); }
.summary { background: #fff; border-radius: 8px; padding: 18px; box-shadow: 0 1px 4px rgba(0,0,0,.1); margin-bottom: 18px; } .check-list .panel.is-fail { border-left-color: var(--leak); background: var(--leak-bg); }
.summary strong { font-size: 1.2rem; color: <?= $failed_count ? '#b91c1c' : '#166534' ?>; } .wiz-actions { display: flex; gap: var(--sp-2); flex-wrap: wrap; margin: var(--sp-5) 0; }
.check-list { display: grid; gap: 10px; } .wiz-actions button {
.check { background: #fff; border-radius: 8px; padding: 14px 16px; box-shadow: 0 1px 4px rgba(0,0,0,.08); border-left: 6px solid #22c55e; } display: inline-block; background: var(--primary); color: var(--on-primary);
.check.fail { border-left-color: #ef4444; } border: 0; border-radius: var(--r-sm); padding: 10px 16px;
.check h2 { margin: 0 0 6px; font-size: .98rem; } font-size: .8125rem; font-weight: 700; font-family: var(--font); cursor: pointer;
.check p { margin: 0; color: #64748b; font-size: .84rem; } }
.fix { margin-top: 7px; color: #92400e; font-size: .8rem; } .wiz-actions button:hover { background: var(--primary-600); }
.actions { margin: 18px 0; display: flex; gap: 10px; flex-wrap: wrap; } @media print { .wiz-actions { display: none; } }
button { border: 0; background: #1a56db; color: #fff; padding: 10px 14px; border-radius: 7px; font-weight: 700; cursor: pointer; }
.result { background: #fff; border-radius: 8px; padding: 14px 16px; box-shadow: 0 1px 4px rgba(0,0,0,.08); margin-top: 18px; }
@media (max-width: 520px) { header { align-items: flex-start; flex-direction: column; gap: 10px; } }
</style> </style>
</head> </head>
<body> <body>
<header> <header>
<h1>설치/운영 점검 <?= htmlspecialchars(APP_VERSION) ?></h1> <h1>설치/운영 점검 <?= htmlspecialchars(APP_VERSION) ?></h1>
<div style="display:flex;gap:8px;flex-wrap:wrap"> <div class="top-actions">
<a href="dashboard.php">대시보드</a> <a href="dashboard.php">대시보드</a>
<a href="security_evidence.php">보안 증적</a> <a href="security_evidence.php">보안 증적</a>
</div> </div>
</header> </header>
<main class="container"> <main class="container">
<section class="summary"> <section class="panel wiz-summary">
<strong><?= $failed_count === 0 ? '전체 점검 통과' : "확인 필요 {$failed_count}건" ?></strong> <strong class="<?= $failed_count === 0 ? 'check-ok' : 'check-fail' ?>"><?= $failed_count === 0 ? '전체 점검 통과' : "확인 필요 {$failed_count}건" ?></strong>
<div style="color:#64748b;font-size:.86rem;margin-top:6px">운영 반영 전 이 화면에서 설정, DB, 권한, SMS를 확인합니다.</div> <p class="caption">운영 반영 전 이 화면에서 설정, DB, 권한, SMS를 확인합니다.</p>
</section> </section>
<div class="check-list"> <div class="check-list">
<?php foreach ($checks as $check): ?> <?php foreach ($checks as $check): ?>
<section class="check <?= $check['ok'] ? '' : 'fail' ?>"> <section class="panel<?= $check['ok'] ? '' : ' is-fail' ?>">
<h2><?= htmlspecialchars($check['category']) ?> · <?= htmlspecialchars($check['label']) ?> · <?= $check['ok'] ? '정상' : '확인 필요' ?></h2> <h2><?= htmlspecialchars($check['category']) ?> · <?= htmlspecialchars($check['label']) ?> · <span class="<?= $check['ok'] ? 'check-ok' : 'check-fail' ?>"><?= $check['ok'] ? '정상' : '확인 필요' ?></span></h2>
<p><?= htmlspecialchars($check['detail']) ?></p> <p class="caption"><?= htmlspecialchars($check['detail']) ?></p>
<?php if (!$check['ok'] && $check['fix'] !== ''): ?> <?php if (!$check['ok'] && $check['fix'] !== ''): ?>
<div class="fix">조치: <?= htmlspecialchars($check['fix']) ?></div> <p class="caption">조치: <?= htmlspecialchars($check['fix']) ?></p>
<?php endif; ?> <?php endif; ?>
<?php if (($check['evidence'] ?? '') !== ''): ?> <?php if (($check['evidence'] ?? '') !== ''): ?>
<div class="fix">증적: <?= htmlspecialchars($check['evidence']) ?></div> <p class="caption">증적: <?= htmlspecialchars($check['evidence']) ?></p>
<?php endif; ?> <?php endif; ?>
</section> </section>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<form method="POST" class="actions"> <form method="POST" class="wiz-actions">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>"> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<button type="submit" name="action" value="send_test_sms">테스트 SMS 발송</button> <button type="submit" name="action" value="send_test_sms">테스트 SMS 발송</button>
</form> </form>
<?php if ($sms_test_result !== null): ?> <?php if ($sms_test_result !== null): ?>
<section class="result"> <section class="panel">
<strong>테스트 SMS 결과</strong> <h2>테스트 SMS 결과</h2>
<?php foreach ($sms_test_result as $row): ?> <?php foreach ($sms_test_result as $row): ?>
<p><?= htmlspecialchars(mask_phone($row['receiver'])) ?> · <?= $row['result']['success'] ? '성공' : '실패' ?> · <?= htmlspecialchars($row['result']['message']) ?></p> <p class="caption"><?= htmlspecialchars(mask_phone($row['receiver'])) ?> · <span class="<?= $row['result']['success'] ? 'check-ok' : 'check-fail' ?>"><?= $row['result']['success'] ? '성공' : '실패' ?></span> · <?= htmlspecialchars($row['result']['message']) ?></p>
<?php endforeach; ?> <?php endforeach; ?>
</section> </section>
<?php endif; ?> <?php endif; ?>

View file

@ -33,7 +33,7 @@ SECRETS_DIR = DEPLOY / ".secrets"
# deploy/.secrets/web_dir.txt 에서 로드한다(없으면 랜덤 생성). 아래 DOMAIN 만 수정. # deploy/.secrets/web_dir.txt 에서 로드한다(없으면 랜덤 생성). 아래 DOMAIN 만 수정.
DOMAIN = "https://pores.kr" DOMAIN = "https://pores.kr"
TEXT_EXTS = {".sh", ".py", ".php", ".service", ".sql", ".md", ".txt", ".example", ".html"} TEXT_EXTS = {".sh", ".py", ".php", ".service", ".sql", ".md", ".txt", ".example", ".html", ".css"}
EXCLUDE_NAMES = {"__pycache__", ".gitignore", "config_local.py"} EXCLUDE_NAMES = {"__pycache__", ".gitignore", "config_local.py"}
BUNDLES = [ BUNDLES = [