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:
parent
de51d1c3d4
commit
e0f0f0963a
9 changed files with 772 additions and 413 deletions
420
php/assets/app.css
Normal file
420
php/assets/app.css
Normal 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; }
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,23 +1015,57 @@ 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;
|
||||||
|
var LS_REFRESH = 'leakDash.autoRefresh'; // 'on' | 'off' (기본 on)
|
||||||
|
var SS_SCROLL = 'leakDash.scrollY'; // reload 직전 저장
|
||||||
|
var SS_DETAILS = 'leakDash.detailsOpen'; // 열린 mobile-detail 인덱스 JSON
|
||||||
|
|
||||||
|
var details = Array.prototype.slice.call(
|
||||||
|
document.querySelectorAll('details.mobile-detail')
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── mobile-detail 펼침 상태 저장/복원 ──────────────────────
|
||||||
|
function saveDetailsState() {
|
||||||
|
try {
|
||||||
|
var openIdx = [];
|
||||||
|
details.forEach(function (d, i) { if (d.open) { openIdx.push(i); } });
|
||||||
|
sessionStorage.setItem(SS_DETAILS, JSON.stringify(openIdx));
|
||||||
|
} catch (e) { /* storage 불가 시 무시 */ }
|
||||||
|
}
|
||||||
|
function restoreDetailsState() {
|
||||||
|
try {
|
||||||
|
var raw = sessionStorage.getItem(SS_DETAILS);
|
||||||
|
if (raw === null) { return; }
|
||||||
|
var openIdx = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(openIdx)) { return; }
|
||||||
|
details.forEach(function (d, i) {
|
||||||
|
if (openIdx.indexOf(i) !== -1) { d.setAttribute('open', ''); }
|
||||||
|
else { d.removeAttribute('open'); }
|
||||||
|
});
|
||||||
|
} catch (e) { /* 무시 */ }
|
||||||
|
}
|
||||||
|
details.forEach(function (d) { d.addEventListener('toggle', saveDetailsState); });
|
||||||
|
|
||||||
|
// ── 모바일 기본 접힘(최초 방문만) ──────────────────────────
|
||||||
|
function applyMobileDisclosureState() {
|
||||||
|
var shouldCollapse = window.matchMedia('(max-width: 768px)').matches;
|
||||||
|
document.querySelectorAll('details[data-mobile-collapsed]').forEach(function (detail) {
|
||||||
if (shouldCollapse && !detail.dataset.mobileInitialized) {
|
if (shouldCollapse && !detail.dataset.mobileInitialized) {
|
||||||
detail.removeAttribute('open');
|
detail.removeAttribute('open');
|
||||||
detail.dataset.mobileInitialized = '1';
|
detail.dataset.mobileInitialized = '1';
|
||||||
|
|
@ -1114,10 +1075,55 @@ function applyMobileDisclosureState() {
|
||||||
delete detail.dataset.mobileInitialized;
|
delete detail.dataset.mobileInitialized;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
applyMobileDisclosureState();
|
||||||
|
window.addEventListener('resize', applyMobileDisclosureState);
|
||||||
|
|
||||||
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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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; ?>
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue