feat: 대시보드 관제 개요 벤토 재편 + 샘플 대시보드 콘텐츠 위젯 5종

샘플 대시보드(고객지원/방문분석) 콘텐츠를 누수·온습도 도메인으로 이식하고,
평면 나열이던 섹션을 관계·위치가 드러나는 4개 라벨 존으로 재구성한다.

콘텐츠 위젯(전부 기존 DB 집계, 외부 라이브러리 0 · 인라인 SVG · 폐쇄망 안전):
- 가동률 게이지 + 7일 경보 스파크라인 + 상태 배지
- 듀얼 KPI(경보 센서 / 오프라인)
- 경보 종류 도넛(30일, $summary 재사용)
- 경보 최다 센서 리더보드(30일 GROUP BY)
- 경보 발생 vs 복구 7일 이벤트율 라인차트
- 온습도 추이: 기간 토글(오늘/3일/7일, ?range 링크) + 피크 마커

정보구조:
- 4개 존(관제 개요 / 실시간 상태 / 추세 분석 / 운영 이력) — eyebrow 라벨 + 하드라인
- 관제 개요 = 12칼럼 벤토 그리드(기존 ops-grid 대체, 자가진단 편입)
- 중복 카드 제거(등록센서/최근온습도/현재시각 → 게이지·히어로·요약 흡수)

검증: php -l 통과, 스텁DB 전체 렌더 exit 0(경고 0)·태그 균형·빈데이터 우아,
채운데이터 프리뷰 스크린샷 확인. 신규 CSS는 네임스페이스 셀렉터만(타 페이지 영향 0).
This commit is contained in:
cw2007 2026-07-06 10:58:39 +09:00
parent 031a0c0fdf
commit e392d6475b
2 changed files with 634 additions and 353 deletions

View file

@ -1,212 +1,138 @@
/* ============================================================================= /* =============================================================================
* app.css — 통합 모니터링 관리자 디자인 시스템 "Slate NOC Console" * app.css — 통합 모니터링 관리자 디자인 시스템 "Slate NOC Console" (Dark)
* * 다크 우선 관제 콘솔. 초대형 tabular 수치가 히어로. 7개 페이지 공용.
* 단일 진실원(single source of truth). 7개 페이지가 이 파일 하나만 링크한다. * 상태 3중 인코딩(색+도형+텍스트) 유지. 인쇄는 강제 라이트.
* <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 { :root {
/* 표면·잉크 */ --bg: #0f1533;
--bg: #eef1f5; --surface: #191f42;
--surface: #ffffff; --surface-2: #232a55;
--surface-2: #f5f7fa; --raise: #2b3366;
--raise: #fbfcfe; --ink: #e9edff;
--ink: #1b2430; /* 본문 (on surface: ~13:1) */ --ink-2: #98a2cf;
--ink-2: #54606f; /* 보조 텍스트 (on white: ~5.2:1 AA) */ --ink-3: #6f7cae;
--ink-3: #6b7687; /* 캡션/축라벨 (on white: ~4.5:1 AA, 최소선) */ --line: #2b3364;
--line: #dce1e8; --line-2: #212850;
--line-2: #eaeef3;
/* 브랜드 (절제된 스틸블루 — 저채도라 저온 시맨틱/링크와 분리) */ --primary: #6c7bff;
--primary: #2b5bab; --primary-600: #8f9bff;
--primary-600: #234f8f; --on-primary: #0b1030;
--on-primary: #ffffff; --primary-tint: #1d2450;
--primary-tint: #e9eff8;
/* 헤더(앱바) — 라이트: 브랜드 primary 배경 + 흰 잉크 */ --header-bg: #141a3d;
--header-bg: var(--primary); --header-ink: var(--ink);
--header-ink: var(--on-primary);
/* 상태 시맨틱 — rail/dot 용(진한 색) + ink(텍스트) + bg(옅은 배경) */ --normal: #35d6a0; --normal-ink: #74ecc0; --normal-bg: rgba(53,214,160,.13);
--normal: #1f8a4c; --normal-ink: #136c39; --normal-bg: #e6f4ec; --leak: #ff5470; --leak-ink: #ff90a5; --leak-bg: rgba(255,84,112,.14);
--leak: #d12b2b; --leak-ink: #b11f1f; --leak-bg: #fbe8e8; --warn: #ffb23e; --warn-ink: #ffcd7b; --warn-bg: rgba(255,178,62,.13);
--warn: #b26a00; --warn-ink: #935600; --warn-bg: #f8edda; --offline:#7885b8; --offline-ink:#9aa6d4; --offline-bg:rgba(120,133,184,.14);
--offline:#6b7683; --offline-ink:#4b5560; --offline-bg:#ebeef2;
/* 지표 칩 — 서로/브랜드와 뚜렷이 구분 */ --hot-ink: #ff9a7a; --hot-bg: rgba(255,120,90,.14);
--hot-ink: var(--leak-ink); --hot-bg: var(--leak-bg); --cold-ink: #5fd6ee; --cold-bg: rgba(63,199,230,.13);
--cold-ink: #0b5a6e; --cold-bg: #e1f1f4; /* 티일(저온) — 스틸블루와 다름 */
--humid-ink: var(--warn-ink); --humid-bg: var(--warn-bg); --humid-ink: var(--warn-ink); --humid-bg: var(--warn-bg);
--dry-ink: #4a4fa0; --dry-bg: #ecedf8; /* 절제된 인디고(저습, 희소) */ --dry-ink: #b0b8ff; --dry-bg: rgba(120,130,255,.14);
/* 차트 라인 */ --chart-temp: #ff8a5c;
--chart-temp: #c2410c; /* 온도=따뜻 */ --chart-hum: #3fc7e6;
--chart-hum: #0e7490; /* 습도=차가움(티일) */
--chart-grid: var(--line); --chart-grid: var(--line);
/* 타이포 — 한글 시스템 폰트 우선(오프라인 일관 렌더). 웹폰트 미로드. */
--font: "Malgun Gothic","Apple SD Gothic Neo",system-ui,-apple-system,"Segoe UI",Roboto,"Noto Sans KR",sans-serif; --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; --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; --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-5: 20px; --sp-6: 24px; --sp-8: 32px;
/* 반경 스케일 */ --r-sm: 6px; --r: 10px; --r-lg: 16px; --r-pill: 999px;
--r-sm: 4px; --r: 8px; --r-lg: 12px; --r-pill: 999px; --shadow: 0 1px 2px rgba(0,0,0,.4);
/* 그림자 (절제) */ --shadow-lg: 0 12px 32px rgba(0,0,0,.45);
--shadow: 0 1px 3px rgba(16,24,40,.08), 0 1px 2px rgba(16,24,40,.04); --ring: 0 0 0 3px rgba(108,123,255,.45);
--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; } * { box-sizing: border-box; margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; } html { -webkit-text-size-adjust: 100%; }
body { body {
font-family: var(--font); font-family: var(--font);
background: var(--bg); background:
color: var(--ink); radial-gradient(1200px 500px at 15% -8%, rgba(108,123,255,.10), transparent 60%),
line-height: 1.55; radial-gradient(1000px 460px at 92% -4%, rgba(63,199,230,.07), transparent 60%),
font-size: 1rem; var(--bg);
-webkit-font-smoothing: antialiased; background-attachment: fixed;
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,
.val, .metric-chip, .report-stat strong, td.num, .num, .status-clock { font-variant-numeric: tabular-nums; } .card .val, .quick-tile strong { font-variant-numeric: tabular-nums; }
a { color: var(--primary); } a { color: var(--primary-600); }
:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: var(--r-sm); } :focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: var(--r-sm); }
img { max-width: 100%; } img { max-width: 100%; }
/* 타입 스케일 (4단) */ h1 { font-size: 1.375rem; font-weight: 800; line-height: 1.25; letter-spacing: -.01em; }
h1 { font-size: 1.375rem; font-weight: 700; line-height: 1.3; } h2 { font-size: 1.0625rem; font-weight: 700; color: var(--ink); margin: var(--sp-6) 0 var(--sp-3); letter-spacing: .01em; }
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; } .panel h2, .status-hero h2 { margin-top: 0; }
label, .label { font-size: .875rem; font-weight: 600; } label, .label { font-size: .875rem; font-weight: 600; }
.caption, small { font-size: .8125rem; color: var(--ink-3); } .caption, small { font-size: .8125rem; color: var(--ink-3); }
.container > h2 { display: flex; align-items: center; gap: var(--sp-3); }
.container > h2::before { content: ""; width: 3px; height: 1.05em; border-radius: 2px; background: linear-gradient(180deg, var(--primary), var(--chart-hum)); }
/* ── 헤더(앱바) ───────────────────────────────────────────────────────────── */
header { header {
background: var(--header-bg); background: var(--header-bg); color: var(--header-ink); padding: var(--sp-4) var(--sp-6);
color: var(--header-ink);
padding: var(--sp-4) var(--sp-6);
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-4); display: flex; align-items: center; justify-content: space-between; gap: var(--sp-4);
border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20;
} }
header h1 { font-size: 1.3rem; color: var(--header-ink); } header h1 { font-size: 1.25rem; color: var(--header-ink); display: flex; align-items: center; gap: var(--sp-3); }
header h1::before { content: ""; width: 10px; height: 10px; border-radius: 50%; background: var(--normal); box-shadow: 0 0 0 4px rgba(53,214,160,.18); }
.top-actions { display: flex; gap: var(--sp-2); align-items: center; flex-wrap: wrap; } .top-actions { display: flex; gap: var(--sp-2); align-items: center; flex-wrap: wrap; }
.top-actions a { .top-actions a { color: var(--ink-2); font-size: .8125rem; font-weight: 600; text-decoration: none; background: var(--surface-2); border: 1px solid var(--line); padding: 7px 14px; border-radius: var(--r-sm); }
color: var(--header-ink); font-size: .8125rem; font-weight: 600; text-decoration: none; .top-actions a:hover { background: var(--raise); color: var(--ink); border-color: var(--primary); }
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: 1360px; margin: var(--sp-6) auto var(--sp-8); padding: 0 var(--sp-5); }
.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(300px,.9fr); gap: var(--sp-4); align-items: start; margin-bottom: var(--sp-6); }
.ops-grid { display: grid; grid-template-columns: minmax(0,1.5fr) minmax(280px,.9fr); .cards { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: var(--sp-4); margin-bottom: var(--sp-4); }
gap: var(--sp-4); align-items: start; margin-bottom: var(--sp-6); } .cards.flush { 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(250px,1fr)); gap: var(--sp-4); 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-lg); box-shadow: var(--shadow); }
.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); } .panel { padding: var(--sp-5); }
.chart-panel { padding: var(--sp-5); margin-bottom: var(--sp-6); } .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 { padding: var(--sp-5) var(--sp-5) var(--sp-6); position: relative; overflow: hidden; }
.card::before { content: ""; position: absolute; inset: 0 0 auto 0; height: 3px; background: linear-gradient(90deg, var(--primary), var(--chart-hum)); opacity: .9; }
.card > div:first-child { color: var(--ink-2); font-size: .78rem; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; }
.card span { color: var(--ink-2); font-size: .8125rem; } .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); } .card .val { font-size: clamp(2.4rem, 4.2vw, 3.4rem); font-weight: 800; line-height: 1.02; margin-top: var(--sp-2); color: var(--ink); letter-spacing: -.02em; }
.card .val.compact { font-size: clamp(1.6rem, 2.6vw, 2rem); }
.card .val.clock { font-size: clamp(1.1rem, 2vw, 1.45rem); font-weight: 700; color: var(--ink-2); letter-spacing: 0; }
.card .val.ok-color { color: var(--normal-ink); }
.card .val.err-color { color: var(--leak-ink); }
.card .val.warn-color { color: var(--warn-ink); }
.card:has(.val.ok-color)::before { background: var(--normal); }
.card:has(.val.err-color)::before { background: var(--leak); }
.card:has(.val.warn-color)::before { background: var(--warn); }
.card .val.ok-color::before, .card .val.err-color::before, .card .val.warn-color::before { content: none; }
/* ── 시스템 상태 배너(hero) ──────────────────────────────────────────────── */ .status-hero { border-radius: var(--r-lg); padding: var(--sp-6); 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: linear-gradient(180deg, rgba(53,214,160,.08), transparent 70%), var(--surface); border: 1px solid var(--line); position: relative; overflow: hidden; }
.status-hero { .status-hero::before { content: ""; position: absolute; inset: 0 0 auto 0; height: 3px; background: linear-gradient(90deg, var(--normal), var(--chart-hum)); }
border-radius: var(--r); padding: var(--sp-5); margin-bottom: var(--sp-5); .status-hero h2 { font-size: 1.4rem; font-weight: 800; display: flex; align-items: center; gap: var(--sp-3); }
display: grid; grid-template-columns: minmax(0,1fr) auto; gap: var(--sp-4); align-items: center; .status-hero h2::before { content: ""; width: .78em; height: .78em; border-radius: 50%; background: var(--normal); flex: none; box-shadow: 0 0 0 5px rgba(53,214,160,.16); }
color: var(--ink); background: var(--surface); .status-hero p { color: var(--ink-2); font-size: .95rem; margin-top: 4px; }
border: 1px solid var(--line); border-left: 6px solid var(--offline); .status-hero.warning { background: linear-gradient(180deg, rgba(255,178,62,.09), transparent 70%), var(--surface); }
} .status-hero.danger { background: linear-gradient(180deg, rgba(255,84,112,.12), transparent 72%), var(--surface); }
.status-hero h2 { font-size: 1.25rem; display: flex; align-items: center; gap: var(--sp-2); } .status-hero.warning::before { background: var(--warn); }
.status-hero h2::before { .status-hero.danger::before { background: var(--leak); }
content: ""; width: .7em; height: .7em; border-radius: 50%; background: var(--offline); flex: none; .status-hero.warning h2::before { background: var(--warn); box-shadow: 0 0 0 5px rgba(255,178,62,.16); }
} .status-hero.danger h2::before { background: var(--leak); box-shadow: 0 0 0 5px rgba(255,84,112,.18); animation: hero-pulse 1.6s ease-in-out infinite; }
.status-hero p { color: var(--ink-2); font-size: .9rem; margin-top: 2px; } @keyframes hero-pulse { 0%,100% { box-shadow: 0 0 0 4px rgba(255,84,112,.10); } 50% { box-shadow: 0 0 0 8px rgba(255,84,112,.24); } }
.status-hero.normal { border-left-color: var(--normal); } @media (prefers-reduced-motion: reduce) { .status-hero.danger h2::before { animation: none; } }
.status-hero.warning { border-left-color: var(--warn); } .status-clock { text-align: right; font-size: .8125rem; color: var(--ink-3); white-space: nowrap; font-variant-numeric: tabular-nums; }
.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-lg); padding: var(--sp-5); box-shadow: var(--shadow); transition: transform .12s ease, border-color .12s ease; }
.sensor-card { .sensor-card:hover { transform: translateY(-2px); border-color: var(--raise); }
background: var(--surface); border: 1px solid var(--line); border-radius: var(--r); .sensor-card strong { font-size: 1rem; font-weight: 700; }
border-left: 1px solid var(--line); padding: var(--sp-5); .sensor-card small { color: var(--ink-3); }
} .sensor-card.alert { border-color: var(--leak); background: linear-gradient(180deg, var(--leak-bg), transparent 60%), var(--surface); }
.sensor-card.alert { border-left: 6px solid var(--leak); background: var(--leak-bg); } .sensor-card.offline { border-color: var(--offline); background: linear-gradient(180deg, var(--offline-bg), transparent 60%), var(--surface); }
.sensor-card.offline { border-left: 6px solid var(--offline); background: var(--offline-bg); } .badge-row { margin-top: var(--sp-3); }
/* 배지/알약 공통 — 앞에 점 글리프(도형 중복), 텍스트 라벨 필수 */ .badge, .pill { display: inline-flex; align-items: center; gap: .4em; border-radius: var(--r-pill); padding: 4px 11px; font-size: .75rem; font-weight: 700; line-height: 1.4; background: var(--normal-bg); color: var(--normal-ink); border: 1px solid transparent; }
.badge, .pill { .badge::before, .pill::before { content: ""; width: .5em; height: .5em; border-radius: 50%; background: currentColor; flex: none; }
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.ok, .pill.ok { background: var(--normal-bg); color: var(--normal-ink); }
.badge.alert,.pill.alert { background: var(--leak-bg); color: var(--leak-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); } .badge.offline,.pill.offline{ background: var(--offline-bg); color: var(--offline-ink); }
@ -217,132 +143,113 @@ header h1 { font-size: 1.3rem; color: var(--header-ink); }
.pill.leak, .pill.leak_alert { background: var(--leak-bg); color: var(--leak-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); } .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-line { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin-top: var(--sp-3); }
.metric-chip { .metric-chip { display: inline-flex; align-items: center; border-radius: var(--r-pill); background: var(--surface-2); color: var(--ink-2); padding: 5px 12px; font-size: .82rem; font-weight: 700; }
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.hot { background: var(--hot-bg); color: var(--hot-ink); }
.metric-chip.cold { background: var(--cold-bg); color: var(--cold-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.humid { background: var(--humid-bg); color: var(--humid-ink); }
.metric-chip.dry { background: var(--dry-bg); color: var(--dry-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); } .metric-time { color: var(--ink-3); font-size: .75rem; margin-top: var(--sp-2); font-variant-numeric: tabular-nums; }
.metric-time.stale { color: var(--offline-ink); }
/* ── 운영 자가진단 ───────────────────────────────────────────────────────── */ .diag-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); margin: 0 0 var(--sp-3); }
.diag-list { display: grid; gap: var(--sp-2); } .diag-head h2 { margin: 0; }
.diag-item { .diag-pill { font-size: .75rem; font-weight: 700; padding: 4px 11px; border-radius: var(--r-pill); white-space: nowrap; }
border: 1px solid var(--line); border-radius: var(--r); padding: 9px 10px; .diag-pill.ok { background: var(--normal-bg); color: var(--normal-ink); }
display: grid; grid-template-columns: auto minmax(0,1fr); gap: var(--sp-2); align-items: start; .diag-pill.warn { background: var(--warn-bg); color: var(--warn-ink); }
} .diag-pill.crit { background: var(--leak-bg); color: var(--leak-ink); }
.diag-dot { width: 10px; height: 10px; border-radius: 50%; margin-top: 5px; background: var(--normal); flex: none; } .diag-counts { display: flex; gap: var(--sp-2); margin-bottom: var(--sp-3); }
.diag-item.warn .diag-dot { background: var(--warn); } .dc { flex: 1; text-align: center; font-size: .72rem; color: var(--ink-2); background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--r); padding: 8px 4px; }
.diag-item.critical .diag-dot { background: var(--leak); } .dc b { display: block; font-size: 1.3rem; font-weight: 800; color: var(--ink); line-height: 1.2; margin-bottom: 1px; font-variant-numeric: tabular-nums; }
.diag-item strong { display: block; font-size: .8125rem; } .dc.ok b { color: var(--normal-ink); }
.diag-item span { color: var(--ink-2); font-size: .78rem; line-height: 1.4; } .dc.warn b { color: var(--warn-ink); }
.dc.crit b { color: var(--leak-ink); }
.diag-issues { list-style: none; margin: 0 0 var(--sp-2); padding: 0; display: grid; gap: var(--sp-2); }
.diag-issue { display: grid; grid-template-columns: auto minmax(0,1fr); gap: var(--sp-2); align-items: start; border: 1px solid var(--line); border-radius: var(--r); padding: 9px 11px; background: var(--surface-2); }
.diag-issue.critical { background: var(--leak-bg); border-color: rgba(255,84,112,.3); }
.diag-issue.warn { background: var(--warn-bg); border-color: rgba(255,178,62,.28); }
.diag-dot { width: 9px; height: 9px; border-radius: 50%; margin-top: 5px; background: var(--warn); flex: none; }
.diag-issue.critical .diag-dot { background: var(--leak); }
.diag-issue strong { display: block; font-size: .8125rem; color: var(--ink); }
.diag-issue span { color: var(--ink-2); font-size: .78rem; line-height: 1.4; }
.diag-ok { border-top: 1px solid var(--line); margin-top: var(--sp-1); padding-top: var(--sp-2); }
.diag-ok > summary { cursor: pointer; font-size: .8125rem; color: var(--ink-2); font-weight: 600; list-style: none; display: flex; align-items: center; gap: 6px; padding: 2px 0; }
.diag-ok > summary::-webkit-details-marker { display: none; }
.diag-ok > summary::before { content: "\25B8"; color: var(--ink-3); font-size: .7rem; }
.diag-ok[open] > summary::before { content: "\25BE"; }
.diag-ok-grid { list-style: none; margin: var(--sp-2) 0 0; padding: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 7px 12px; }
.diag-ok-grid li { display: flex; align-items: center; gap: 6px; font-size: .75rem; color: var(--ink-2); min-width: 0; }
.diag-ok-grid li span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.diag-ok-grid i { width: 7px; height: 7px; border-radius: 50%; background: var(--normal); flex: none; }
.panel-note { color: var(--ink-3); font-size: .78rem; margin-top: var(--sp-3); line-height: 1.5; }
/* ── 30일 운영 요약 ─────────────────────────────────────────────────────── */
.report-grid { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: var(--sp-3); } .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 { border: 1px solid var(--line); border-radius: var(--r); padding: var(--sp-4); background: var(--surface-2); }
.report-stat span { display: block; color: var(--ink-2); font-size: .75rem; margin-bottom: 4px; } .report-stat span { display: block; color: var(--ink-2); font-size: .75rem; margin-bottom: 4px; }
.report-stat strong { font-size: 1.1rem; } .report-stat strong { font-size: 1.35rem; font-weight: 800; }
/* ── 표 ───────────────────────────────────────────────────────────────────── */ .table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; margin-bottom: var(--sp-6); border-radius: var(--r-lg); }
.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-lg); overflow: hidden; }
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; } .table-wrap table { margin-bottom: 0; }
th { th { background: var(--surface-2); font-size: .8125rem; font-weight: 700; color: var(--ink-2); padding: 11px 14px; text-align: left; border-bottom: 1px solid var(--line); white-space: nowrap; }
background: var(--surface-2); font-size: .8125rem; font-weight: 700; color: var(--ink-2); td { padding: 10px 14px; border-bottom: 1px solid var(--line-2); font-size: .875rem; color: var(--ink); }
padding: 10px 14px; text-align: left; border-bottom: 1px solid var(--line); white-space: nowrap; tbody tr:last-child td { border-bottom: none; }
}
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); } tbody tr:hover td { background: var(--surface-2); }
.alert-row td { background: var(--leak-bg); } .alert-row td { background: var(--leak-bg); }
.alert-row:hover td { background: var(--leak-bg); } .alert-row:hover td { background: var(--leak-bg); }
/* 상태 텍스트(인라인) — 색 + 앞 글리프 */
.ok-color, .err-color, .warn-color { font-weight: 700; } .ok-color, .err-color, .warn-color { font-weight: 700; }
.ok-color { color: var(--normal-ink); } .ok-color { color: var(--normal-ink); }
.err-color { color: var(--leak-ink); } .err-color { color: var(--leak-ink); }
.warn-color{ color: var(--warn-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.ok, .check-ok { color: var(--normal-ink); font-weight: 700; }
.check.fail, .check-fail { color: var(--leak-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.ok::before, .check-ok::before { content: "\2713\00a0"; }
.check.fail::before, .check-fail::before { content: "\2715\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 { 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; } .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 { width: 16px; height: 4px; border-radius: 2px; display: inline-block; }
.legend-swatch.temp { background: var(--chart-temp); } .legend-swatch.temp { background: var(--chart-temp); }
.legend-swatch.hum { background: var(--chart-hum); } .legend-swatch.hum { background: var(--chart-hum); }
.chart-svg-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; } .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; } .chart-empty, .empty-note { color: var(--ink-3); padding: var(--sp-6); text-align: center; }
.empty-note { padding: var(--sp-5); } .empty-note { padding: var(--sp-5); }
/* ── 누수 패널 ───────────────────────────────────────────────────────────── */ .leak-status-card { padding: var(--sp-5); transition: transform .12s ease, border-color .12s ease; }
.leak-status-card { border-left: 1px solid var(--line); padding: var(--sp-5); } .leak-status-card:hover { transform: translateY(-2px); border-color: var(--raise); }
.leak-status-card.leak { border-left: 6px solid var(--leak); background: var(--leak-bg); } .leak-status-card.leak { border-color: var(--leak); background: linear-gradient(180deg, var(--leak-bg), transparent 60%), var(--surface); }
.leak-status-card.offline { border-left: 6px solid var(--offline); background: var(--offline-bg); } .leak-status-card.offline { border-color: var(--offline); background: linear-gradient(180deg, var(--offline-bg), transparent 60%), var(--surface); }
.leak-thumbs { display: flex; flex-wrap: wrap; gap: var(--sp-2); } .leak-thumbs { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
.leak-thumbs a { display: inline-block; line-height: 0; } .leak-thumbs a { display: inline-block; line-height: 0; }
.leak-thumbs img { width: 64px; height: 64px; object-fit: cover; border-radius: var(--r-sm); .leak-thumbs img { width: 64px; height: 64px; object-fit: cover; border-radius: var(--r-sm); border: 1px solid var(--line); background: var(--surface-2); }
border: 1px solid var(--line); background: var(--surface-2); }
/* ── 모바일 요약 타일/링크 ──────────────────────────────────────────────── */
.mobile-quick { display: none; } .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-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); .quick-tile { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r); padding: 12px; text-decoration: none; color: var(--ink); min-height: 74px; }
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 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 strong { font-size: 1.5rem; font-weight: 800; line-height: 1; }
.quick-tile.danger { border-color: var(--leak); background: var(--leak-bg); } .quick-tile.danger { border-color: var(--leak); background: var(--leak-bg); }
.quick-tile.warn { border-color: var(--warn); background: var(--warn-bg); } .quick-tile.warn { border-color: var(--warn); background: var(--warn-bg); }
.quick-tile.ok { border-color: var(--normal); background: var(--normal-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; .quick-links { display: flex; gap: var(--sp-2); overflow-x: auto; -webkit-overflow-scrolling: touch; padding-bottom: 6px; margin-bottom: var(--sp-4); }
padding-bottom: 6px; margin-bottom: var(--sp-4); } .quick-links a { flex: 0 0 auto; color: var(--ink); background: var(--surface-2); border: 1px solid var(--line); text-decoration: none; border-radius: var(--r-pill); padding: 8px 13px; font-size: .8125rem; font-weight: 700; }
.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 { margin-bottom: var(--sp-6); }
.mobile-detail summary { display: none; } .mobile-detail summary { display: none; }
.refresh { float: right; font-size: .8125rem; color: var(--ink-2); font-weight: 400; }
.refresh-toggle { background: var(--surface-2); color: var(--ink-2); border: 1px solid var(--line); border-radius: var(--r-pill); padding: 3px 10px; font-size: .75rem; cursor: pointer; margin-left: 8px; }
.refresh { float: right; font-size: .8125rem; color: var(--ink-2); } .forecast { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin-top: var(--sp-3); padding-top: var(--sp-3); border-top: 1px solid var(--line-2); }
.forecast-chip { display: inline-flex; align-items: center; gap: .35em; border-radius: var(--r-pill); padding: 3px 10px; font-size: .75rem; font-weight: 700; line-height: 1.5; background: var(--warn-bg); color: var(--warn-ink); }
/* ── 예보(카드 상시 예방 신호) ───────────────────────────────────────────── */
.forecast { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin-top: var(--sp-2); }
.forecast-chip {
display: inline-flex; align-items: center; gap: .35em; border-radius: var(--r-pill);
padding: 2px 9px; font-size: .75rem; font-weight: 700; line-height: 1.5;
background: var(--warn-bg); color: var(--warn-ink);
}
.forecast-chip::before { content: ""; width: .45em; height: .45em; border-radius: 50%; background: currentColor; flex: none; } .forecast-chip::before { content: ""; width: .45em; height: .45em; border-radius: 50%; background: currentColor; flex: none; }
.forecast-chip.near { background: var(--warn-bg); color: var(--warn-ink); } .forecast-chip.near { background: var(--warn-bg); color: var(--warn-ink); }
.forecast-chip.over { background: var(--leak-bg); color: var(--leak-ink); } .forecast-chip.over { background: var(--leak-bg); color: var(--leak-ink); }
.forecast-note { font-size: .75rem; color: var(--ink-3); } .forecast-note { font-size: .75rem; color: var(--ink-3); }
/* ── 대응 필요 섹션(위험 시에만 노출: 조건 + 플레이북 + 담당자) ─────────────── */ .response-panel { background: linear-gradient(180deg, var(--leak-bg), transparent 60%), var(--surface); border: 1px solid var(--leak); border-radius: var(--r-lg); padding: var(--sp-5); margin-bottom: var(--sp-5); }
.response-panel {
background: var(--surface); border: 1px solid var(--line); border-left: 6px solid var(--leak);
border-radius: var(--r); padding: var(--sp-5); margin-bottom: var(--sp-5);
}
.response-panel h2 { margin: 0 0 var(--sp-3); display: flex; align-items: center; gap: var(--sp-2); } .response-panel h2 { margin: 0 0 var(--sp-3); display: flex; align-items: center; gap: var(--sp-2); }
.response-count { .response-count { font-size: .8125rem; font-weight: 700; color: var(--leak-ink); background: var(--leak-bg); border-radius: var(--r-pill); padding: 3px 11px; }
font-size: .8125rem; font-weight: 700; color: var(--leak-ink);
background: var(--leak-bg); border-radius: var(--r-pill); padding: 2px 10px;
}
.response-item { border-top: 1px solid var(--line-2); padding: var(--sp-3) 0; } .response-item { border-top: 1px solid var(--line-2); padding: var(--sp-3) 0; }
.response-item:first-of-type { border-top: none; padding-top: 0; } .response-item:first-of-type { border-top: none; padding-top: 0; }
.response-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-2); } .response-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-2); }
@ -352,77 +259,167 @@ tbody tr:hover td { background: var(--surface-2); }
.playbook-title { font-size: .8125rem; font-weight: 700; color: var(--ink-2); margin-bottom: 4px; } .playbook-title { font-size: .8125rem; font-weight: 700; color: var(--ink-2); margin-bottom: 4px; }
.playbook-steps { margin: 0; padding-left: 1.4em; } .playbook-steps { margin: 0; padding-left: 1.4em; }
.playbook-steps li { font-size: .875rem; line-height: 1.7; } .playbook-steps li { font-size: .875rem; line-height: 1.7; }
.response-contacts { .response-contacts { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3); margin-top: var(--sp-4); padding-top: var(--sp-3); border-top: 1px solid var(--line-2); }
display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3);
margin-top: var(--sp-4); padding-top: var(--sp-3); border-top: 1px solid var(--line-2);
}
.contacts-label { font-size: .8125rem; font-weight: 700; color: var(--ink-2); } .contacts-label { font-size: .8125rem; font-weight: 700; color: var(--ink-2); }
.contact { font-size: .875rem; } .contact { font-size: .875rem; }
.contact a { font-weight: 700; } .contact a { font-weight: 700; }
/* ── 폼 / 로그인 / 카드형 페이지 ────────────────────────────────────────── */ .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; position: relative; overflow: hidden; }
.login-box { .login-box::before { content: ""; position: absolute; inset: 0 0 auto 0; height: 3px; background: linear-gradient(90deg, var(--primary), var(--chart-hum)); }
background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg); .login-box h1 { color: var(--ink); margin-bottom: var(--sp-2); text-align: center; }
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); } .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 { margin-bottom: var(--sp-4); }
.form-group label { display: block; color: var(--ink-2); margin-bottom: 6px; } .form-group label { display: block; color: var(--ink-2); margin-bottom: 6px; }
.form-group input, input[type=text], input[type=password], textarea { .form-group input, input[type=text], input[type=password], textarea { width: 100%; padding: 11px 14px; border: 1px solid var(--line); border-radius: var(--r); font-size: .95rem; font-family: inherit; color: var(--ink); background: var(--surface-2); outline: none; }
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); } .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: 800; cursor: pointer; text-align: center; }
@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:hover { background: var(--primary-600); }
.btn:disabled { background: var(--offline); cursor: not-allowed; } .btn:disabled { background: var(--offline); cursor: not-allowed; opacity: .6; }
.btn.secondary { background: var(--surface); color: var(--primary); border: 1px solid var(--line); } .btn.secondary { background: var(--surface-2); color: var(--ink); border: 1px solid var(--line); }
/* 배너 — 성공 배너는 .banner-ok. (생 .ok 는 .badge.ok/.pill.ok/.quick-tile.ok 와 .error, .expired, .banner-ok { padding: 11px 14px; border-radius: var(--r); font-size: .875rem; margin-bottom: var(--sp-4); border: 1px solid var(--line); border-left-width: 4px; }
특이도 동률로 충돌해 정상 배지를 사각형으로 깨뜨리므로 네임스페이스한다.) */
.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); } .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); } .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); } .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, 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; } .secret { display: block; padding: 12px; font-size: 1.05rem; letter-spacing: .06em; }
/* ── 반응형 ──────────────────────────────────────────────────────────────── */ /* ===== 개요 벤토(존) — 관련 위젯 그룹핑으로 섹션 관계·위치 시각화 (샘플 콘텐츠 반영) ===== */
.zone { margin-top: var(--sp-8); }
.zone-label { display: flex; align-items: center; gap: var(--sp-3); font-size: .72rem; font-weight: 800; letter-spacing: .18em; text-transform: uppercase; color: var(--ink-3); margin-bottom: var(--sp-4); }
.zone-label::before { content: ""; width: 16px; height: 3px; border-radius: 2px; flex: none; background: linear-gradient(90deg, var(--primary), var(--chart-hum)); }
.zone-label b { color: var(--ink-2); font-weight: 800; letter-spacing: .04em; }
.zone-label .en { color: var(--ink-3); font-weight: 700; }
.zone-label::after { content: ""; flex: 1; height: 1px; background: linear-gradient(90deg, var(--line) 0%, var(--line-2) 60%, transparent 100%); }
.zone > h2:first-of-type { margin-top: var(--sp-2); }
.overview { display: grid; grid-template-columns: repeat(12, minmax(0,1fr)); gap: var(--sp-4); align-items: stretch; margin-bottom: var(--sp-4); }
.overview > * { min-width: 0; }
.ov-gauge { grid-column: span 3; }
.ov-kpis { grid-column: span 2; display: grid; grid-template-rows: 1fr 1fr; gap: var(--sp-4); }
.ov-donut { grid-column: span 3; }
.ov-board { grid-column: span 4; }
.ov-trend { grid-column: span 8; }
.ov-diag { grid-column: span 4; }
.ov-diag.panel { margin: 0; }
.ov-tile { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg); box-shadow: var(--shadow); padding: var(--sp-5); display: flex; flex-direction: column; }
.ov-tile h3 { font-size: .74rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--ink-2); margin: 0 0 var(--sp-3); display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); }
.ov-tile h3 .sub { font-size: .68rem; font-weight: 600; color: var(--ink-3); text-transform: none; letter-spacing: 0; }
/* 가동률 게이지 */
.gauge-body { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); flex: 1; justify-content: center; }
.gauge-ring { position: relative; width: 138px; max-width: 100%; }
.gauge-ring svg { width: 100%; height: auto; display: block; }
.gauge-ring .g-track { fill: none; stroke: var(--surface-2); stroke-width: 12; }
.gauge-ring .g-val { fill: none; stroke: var(--normal); stroke-width: 12; stroke-linecap: round; }
.gauge-ring.warn .g-val { stroke: var(--warn); }
.gauge-ring.crit .g-val { stroke: var(--leak); }
.gauge-num { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.gauge-num b { font-size: 2rem; font-weight: 800; line-height: 1; letter-spacing: -.02em; color: var(--ink); font-variant-numeric: tabular-nums; }
.gauge-num small { font-size: .68rem; color: var(--ink-3); margin-top: 3px; }
.gauge-foot { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); padding-top: var(--sp-3); margin-top: var(--sp-1); border-top: 1px solid var(--line-2); }
.gauge-foot .lbl { font-size: .7rem; color: var(--ink-3); }
.spark { width: 84px; height: 30px; display: block; }
.spark polyline { fill: none; stroke: var(--warn); stroke-width: 1.6; stroke-linejoin: round; stroke-linecap: round; }
.spark .sp-dot { fill: var(--warn); }
.g-badge { display: inline-flex; align-items: center; gap: .3em; font-size: .7rem; font-weight: 800; padding: 2px 9px; border-radius: var(--r-pill); background: var(--warn-bg); color: var(--warn-ink); }
.g-badge.ok { background: var(--normal-bg); color: var(--normal-ink); }
.g-badge.crit { background: var(--leak-bg); color: var(--leak-ink); }
/* 개요 KPI 소형 타일(듀얼) */
.ov-kpi { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg); box-shadow: var(--shadow); padding: var(--sp-4) var(--sp-5); position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: center; }
.ov-kpi::before { content: ""; position: absolute; inset: 0 0 auto 0; height: 3px; background: var(--primary); }
.ov-kpi.err::before { background: var(--leak); }
.ov-kpi.warn::before { background: var(--warn); }
.ov-kpi.ok::before { background: var(--normal); }
.ov-kpi .k-lbl { font-size: .72rem; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; color: var(--ink-2); }
.ov-kpi .k-val { font-size: clamp(1.7rem, 2.6vw, 2.2rem); font-weight: 800; line-height: 1.05; letter-spacing: -.02em; font-variant-numeric: tabular-nums; margin-top: 2px; }
.ov-kpi .k-val u { text-decoration: none; font-size: .9rem; color: var(--ink-3); font-weight: 700; margin-left: 1px; }
.ov-kpi.err .k-val { color: var(--leak-ink); }
.ov-kpi.warn .k-val { color: var(--warn-ink); }
.ov-kpi.ok .k-val { color: var(--normal-ink); }
/* 도넛 (경보 종류 구성) */
.donut-body { display: flex; align-items: center; gap: var(--sp-4); flex: 1; }
.donut-ring { position: relative; flex: none; width: 116px; }
.donut-ring svg { width: 100%; height: auto; display: block; }
.donut-ring .d-track { fill: none; stroke: var(--surface-2); stroke-width: 15; }
.donut-ring circle.seg { fill: none; stroke-width: 15; }
.donut-num { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.donut-num b { font-size: 1.5rem; font-weight: 800; line-height: 1; color: var(--ink); font-variant-numeric: tabular-nums; }
.donut-num small { font-size: .64rem; color: var(--ink-3); }
.donut-legend { display: grid; gap: 6px; flex: 1; min-width: 0; }
.dl { display: flex; align-items: center; gap: 7px; font-size: .76rem; color: var(--ink-2); }
.dl i { width: 9px; height: 9px; border-radius: 3px; flex: none; }
.dl .dl-c { margin-left: auto; font-weight: 800; color: var(--ink); font-variant-numeric: tabular-nums; }
.donut-empty { color: var(--ink-3); font-size: .82rem; text-align: center; flex: 1; align-self: center; }
/* 리더보드 (경보 최다 센서) */
.board { display: grid; gap: 9px; flex: 1; align-content: center; }
.board-row { display: grid; grid-template-columns: 1.1em minmax(0,1fr) auto; align-items: center; gap: var(--sp-3); }
.board-rank { font-size: .74rem; font-weight: 800; color: var(--ink-3); font-variant-numeric: tabular-nums; text-align: right; }
.board-row:nth-child(1) .board-rank { color: var(--warn-ink); }
.board-main { min-width: 0; }
.board-name { font-size: .82rem; font-weight: 600; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.board-bar { height: 6px; border-radius: var(--r-pill); background: var(--surface-2); margin-top: 4px; overflow: hidden; }
.board-bar > span { display: block; height: 100%; border-radius: var(--r-pill); background: linear-gradient(90deg, var(--primary), var(--chart-hum)); }
.board-row:nth-child(1) .board-bar > span { background: linear-gradient(90deg, var(--warn), var(--chart-temp)); }
.board-cnt { font-size: .95rem; font-weight: 800; color: var(--ink); font-variant-numeric: tabular-nums; }
.board-cnt small { font-size: .68rem; color: var(--ink-3); font-weight: 600; }
.board-empty { color: var(--ink-3); font-size: .82rem; text-align: center; align-self: center; flex: 1; }
/* 개요 트렌드 (경보 발생 vs 복구) */
.ov-trend .trend-svg { width: 100%; height: auto; display: block; }
.ov-trend .plot-bg { fill: var(--surface-2); stroke: var(--line); }
.ov-trend .grid-line { stroke: var(--chart-grid); }
.ov-trend .ax { fill: var(--ink-3); }
.ov-trend .line-alert { stroke: var(--leak); }
.ov-trend .line-recover { stroke: var(--normal); }
.ov-trend .dot-alert { fill: var(--leak); }
.mini-legend { display: flex; gap: var(--sp-4); flex-wrap: wrap; font-size: .78rem; color: var(--ink-2); margin-bottom: var(--sp-2); }
.mini-legend span { display: inline-flex; align-items: center; gap: 6px; }
.mini-legend i { width: 15px; height: 3px; border-radius: 2px; }
/* 추세 차트 기간 토글(링크) + 피크 마커 */
.chart-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); flex-wrap: wrap; margin-bottom: var(--sp-3); }
.range-toggle { display: inline-flex; gap: 2px; background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--r-pill); padding: 2px; }
.range-toggle a { font-size: .76rem; font-weight: 700; text-decoration: none; color: var(--ink-2); padding: 4px 13px; border-radius: var(--r-pill); }
.range-toggle a[aria-current="true"] { background: var(--primary); color: var(--on-primary); }
.chart-svg-wrap .peak-marker { fill: var(--warn); }
.chart-svg-wrap .peak-label { fill: var(--warn-ink); font-weight: 700; }
.chart-svg-wrap .peak-flag { fill: var(--surface-2); stroke: var(--warn); }
/* 개요 벤토 반응형 */
@media (max-width: 1080px) {
.ov-gauge, .ov-donut { grid-column: span 6; }
.ov-kpis { grid-column: span 6; grid-template-rows: none; grid-template-columns: 1fr 1fr; }
.ov-board { grid-column: span 6; }
.ov-trend, .ov-diag { grid-column: span 12; }
}
@media (max-width: 620px) {
.overview > * { grid-column: span 12 !important; }
.ov-kpis { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 768px) { @media (max-width: 768px) {
header { flex-direction: column; align-items: flex-start; } header { flex-direction: column; align-items: flex-start; position: static; }
.top-actions { margin-top: var(--sp-3); overflow-x: auto; flex-wrap: nowrap; -webkit-overflow-scrolling: touch; } .top-actions { margin-top: var(--sp-3); overflow-x: auto; flex-wrap: nowrap; -webkit-overflow-scrolling: touch; }
.top-actions a { flex: 0 0 auto; } .top-actions a { flex: 0 0 auto; }
.status-hero { grid-template-columns: 1fr; } .status-hero { grid-template-columns: 1fr; }
.status-clock { text-align: left; } .status-clock { text-align: left; }
.mobile-quick { display: block; } .mobile-quick { display: block; }
.ops-grid { grid-template-columns: 1fr; } .ops-grid { grid-template-columns: 1fr; }
.cards { grid-template-columns: 1fr; }
.report-grid { grid-template-columns: repeat(2, minmax(0,1fr)); } .report-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }
.cards { flex-direction: column; }
.card, .sensor-card { min-width: 0; } .card, .sensor-card { min-width: 0; }
header h1 { font-size: 1.1rem; } header h1 { font-size: 1.1rem; }
.card .val { font-size: 1.6rem; } th, td { padding: 9px 10px; }
th, td { padding: 8px 10px; }
td { font-size: .8125rem; } td { font-size: .8125rem; }
.mobile-detail { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r); overflow: hidden; } .mobile-detail { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg); overflow: hidden; }
.mobile-detail summary { display: flex; justify-content: space-between; align-items: center; gap: 10px; .mobile-detail summary { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 14px; color: var(--ink); font-weight: 700; cursor: pointer; list-style: none; }
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::-webkit-details-marker { display: none; }
.mobile-detail summary span { color: var(--ink-2); font-size: .8125rem; font-weight: 600; } .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; } .mobile-detail .table-wrap { margin-bottom: 0; border-top: 1px solid var(--line); border-radius: 0; }
@ -436,24 +433,22 @@ tbody tr:hover td { background: var(--surface-2); }
.col-hide-xs { display: none; } .col-hide-xs { display: none; }
.report-grid { grid-template-columns: 1fr; } .report-grid { grid-template-columns: 1fr; }
.login-box { padding: var(--sp-6) var(--sp-5); margin: 0 var(--sp-3); } .login-box { padding: var(--sp-6) var(--sp-5); margin: 0 var(--sp-3); }
/* 캡션/본문 하한 유지 — 이 밑으로 축소 금지 */
th, td { font-size: .8125rem; } th, td { font-size: .8125rem; }
} }
/* ── 인쇄 — 강제 라이트(월간 리포트·보안 증적은 종이 가독) ────────────────── */
@media print { @media print {
:root { :root {
--bg: #fff; --surface: #fff; --surface-2: #fff; --raise: #fff; --bg: #fff; --surface: #fff; --surface-2: #fff; --raise: #fff;
--ink: #000; --ink-2: #333; --ink-3: #444; --line: #999; --line-2: #ccc; --ink: #000; --ink-2: #333; --ink-3: #444; --line: #999; --line-2: #ccc;
--primary: #14315e; --on-primary: #fff; --primary-tint: #fff; --primary: #14315e; --on-primary: #fff; --primary-tint: #fff;
--normal-bg: #fff; --leak-bg: #fff; --warn-bg: #fff; --offline-bg: #fff; --normal-bg: #fff; --leak-bg: #fff; --warn-bg: #fff; --offline-bg: #fff;
--cold-bg: #fff; --dry-bg: #fff; --cold-bg: #fff; --dry-bg: #fff; --hot-bg: #fff; --humid-bg: #fff;
--shadow: none; --shadow-lg: none; --shadow: none; --shadow-lg: none;
--normal-ink:#0a6b39; --leak-ink:#b11f1f; --warn-ink:#8a5a00;
} }
body { background: #fff; color: #000; } body { background: #fff !important; color: #000; }
header, .top-actions, .refresh, .mobile-quick, .btn { display: none !important; } header, .top-actions, .refresh, .mobile-quick, .btn { display: none !important; }
.panel, .card, table, .chart-panel, .status-hero { box-shadow: none; border: 1px solid #999; .card::before, .status-hero::before, .login-box::before { display: none; }
page-break-inside: avoid; break-inside: avoid; } .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; } /* 흑백에서도 테두리로 구분 */ .badge, .pill { border: 1px solid currentColor; }
a { color: #000; text-decoration: underline; }
} }

View file

@ -136,16 +136,23 @@ foreach ($statuses as $s) {
} }
} }
// 추이 차트 기간(오늘/3일/7일) — 링크 토글(폐쇄망: 외부 라이브러리 없음, 서버 렌더)
$RANGE_DAYS = ['today' => 1, '3d' => 3, '7d' => 7];
$chart_range = (isset($_GET['range']) && isset($RANGE_DAYS[$_GET['range']])) ? $_GET['range'] : '3d';
$range_days = $RANGE_DAYS[$chart_range];
$chart_points = []; $chart_points = [];
if ($chart_sensor_id > 0) { if ($chart_sensor_id > 0) {
$chart_stmt = $db->prepare(" $chart_stmt = $db->prepare("
SELECT measured_at, temperature_c, humidity_percent SELECT measured_at, temperature_c, humidity_percent
FROM leak_sensor_metric FROM leak_sensor_metric
WHERE sensor_id = :sid WHERE sensor_id = :sid AND measured_at >= DATE_SUB(NOW(), INTERVAL :days DAY)
ORDER BY id DESC ORDER BY id DESC
LIMIT 100 LIMIT 600
"); ");
$chart_stmt->execute([':sid' => $chart_sensor_id]); $chart_stmt->bindValue(':sid', $chart_sensor_id, PDO::PARAM_INT);
$chart_stmt->bindValue(':days', $range_days, PDO::PARAM_INT);
$chart_stmt->execute();
$chart_rows = $chart_stmt->fetchAll(); $chart_rows = $chart_stmt->fetchAll();
// 오래된 → 최신 순으로 (그래프 좌→우) // 오래된 → 최신 순으로 (그래프 좌→우)
$chart_rows = array_reverse($chart_rows); $chart_rows = array_reverse($chart_rows);
@ -230,6 +237,13 @@ $ops_warn_count = count(array_filter(
$ops_checks, $ops_checks,
fn($c) => !$c['ok'] && $c['severity'] !== 'critical' fn($c) => !$c['ok'] && $c['severity'] !== 'critical'
)); ));
// 컴팩트 표시: 실패(확인 필요) 항목만 카드로 노출하고 정상 항목은 접는다(패널 길이 억제).
$ops_issues = array_values(array_filter($ops_checks, fn($c) => !$c['ok']));
$ops_ok = array_values(array_filter($ops_checks, fn($c) => $c['ok']));
usort($ops_issues, fn($a, $b) => ($a['severity'] === 'critical' ? 0 : 1) <=> ($b['severity'] === 'critical' ? 0 : 1));
$ops_ok_count = count($ops_ok);
$ops_pill = $ops_critical_count > 0 ? 'crit' : ($ops_warn_count > 0 ? 'warn' : 'ok');
$ops_pill_text = $ops_failed > 0 ? '확인 필요' : '정상';
// ── 상단 시스템 상태 집계 ────────────────────────────────── // ── 상단 시스템 상태 집계 ──────────────────────────────────
$offline_count = count(array_filter($statuses, fn($s) => $s['is_offline'])); $offline_count = count(array_filter($statuses, fn($s) => $s['is_offline']));
@ -268,6 +282,22 @@ foreach ($statuses as $s) {
} }
} }
// 온습도(SHT30) 센서 카드 목록: 기대 온습도 센서(METRIC_SENSOR_IDS)는 미수신도
// placeholder(null)로 항상 표시한다(누수 카드와 동일 원칙 — 최초 기동 전 SHT30 이
// 화면에서 조용히 빠지지 않게). 실제 온습도 데이터가 있는 센서도 포함하되, 순수
// 누수 센서(측정값 없음)는 아래 "누수 감지" 섹션에서만 표시한다(중복 방지).
$metric_statuses = [];
foreach (METRIC_SENSOR_IDS as $mid) {
$metric_statuses[(int)$mid] = null;
}
foreach ($statuses as $s) {
$sid = (int)$s['sensor_id'];
if (!empty($s['latest_metric']) || array_key_exists($sid, $metric_statuses)) {
$metric_statuses[$sid] = $s;
}
}
ksort($metric_statuses);
// 최근 누수 사고(에피소드) 20건 + 각 사고의 사진(leak_photo) — 테이블 없으면 graceful. // 최근 누수 사고(에피소드) 20건 + 각 사고의 사진(leak_photo) — 테이블 없으면 graceful.
$leak_incidents = []; $leak_incidents = [];
$leak_photos_by_log = []; // log_id => [ ['id'=>..,'log_id'=>..,'captured_at'=>..], ... ] $leak_photos_by_log = []; // log_id => [ ['id'=>..,'log_id'=>..,'captured_at'=>..], ... ]
@ -378,6 +408,93 @@ if (FORECAST_ENABLED) {
// critical 먼저, 그다음 warn // critical 먼저, 그다음 warn
usort($risk_items_all, fn($a, $b) => ($a['severity'] === 'critical' ? 0 : 1) <=> ($b['severity'] === 'critical' ? 0 : 1)); usort($risk_items_all, fn($a, $b) => ($a['severity'] === 'critical' ? 0 : 1) <=> ($b['severity'] === 'critical' ? 0 : 1));
} }
// ── 개요 벤토 위젯 데이터 (샘플 대시보드 콘텐츠 반영: 게이지·리더보드·이벤트율·도넛) ──
// 경보성/복구성 SMS 태그 묶음 — 태그 상수에서만 파생(집계 일원화). 상수는 신뢰값이므로
// $recent_alert_fail 쿼리와 동일하게 LIKE 절에 직접 삽입한다(사용자 입력 아님).
$OV_ALERT_TAGS = [
SMS_TAG_HIGH_TEMP, SMS_TAG_LOW_TEMP, SMS_TAG_HIGH_HUMIDITY, SMS_TAG_LOW_HUMIDITY,
SMS_TAG_CONDENSATION, SMS_TAG_DEVICE_OFFLINE, SMS_TAG_LEAK_ALERT, SMS_TAG_ESCALATION,
];
$OV_RECOVER_TAGS = [SMS_TAG_RECOVERY, SMS_TAG_DEVICE_ONLINE, SMS_TAG_LEAK_RECOVERY];
$ov_like = function (array $tags): string {
return '(' . implode(' OR ', array_map(
fn($t) => "message LIKE '" . $t . "%'", $tags
)) . ')';
};
// (1) 가동률 게이지 — 온라인 센서 비율 + 7일 일별 경보건수 스파크라인 + 상태 배지
$sensor_total = count($statuses);
$online_count = $sensor_total - $offline_count;
$uptime_pct = $sensor_total > 0 ? (int)round($online_count / $sensor_total * 100) : 100;
$spark_map = $db->query("
SELECT DATE(sent_at) d, COUNT(*) c
FROM leak_sms_log
WHERE sent_at >= DATE_SUB(CURDATE(), INTERVAL 6 DAY) AND " . $ov_like($OV_ALERT_TAGS) . "
GROUP BY DATE(sent_at)
")->fetchAll(PDO::FETCH_KEY_PAIR);
$spark_alerts = [];
for ($i = 6; $i >= 0; $i--) {
$spark_alerts[] = (int)($spark_map[date('Y-m-d', strtotime("-$i day"))] ?? 0);
}
$spark_today = end($spark_alerts);
reset($spark_alerts);
if ($alert_count > 0) { $ov_badge = 'crit'; $ov_badge_txt = '경보'; }
elseif ($offline_count > 0 || $spark_today > 0) { $ov_badge = 'warn'; $ov_badge_txt = '주의'; }
else { $ov_badge = 'ok'; $ov_badge_txt = '정상'; }
// (2) 경보 최다 센서 리더보드 (30일)
$board_rows = $db->query("
SELECT sensor_name, COUNT(*) c
FROM leak_sms_log
WHERE sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) AND " . $ov_like($OV_ALERT_TAGS) . "
GROUP BY sensor_name
ORDER BY c DESC, sensor_name ASC
LIMIT 6
")->fetchAll();
$board_max = 0;
foreach ($board_rows as $br) { $board_max = max($board_max, (int)$br['c']); }
// (3) 경보 발생 vs 복구 (7일 일별) — 이벤트율 라인차트
$trend_map = [];
foreach ($db->query("
SELECT DATE(sent_at) d,
SUM(" . $ov_like($OV_ALERT_TAGS) . ") alerts,
SUM(" . $ov_like($OV_RECOVER_TAGS) . ") recovers
FROM leak_sms_log
WHERE sent_at >= DATE_SUB(CURDATE(), INTERVAL 6 DAY)
GROUP BY DATE(sent_at)
")->fetchAll() as $tr) {
$trend_map[$tr['d']] = $tr;
}
$trend_days = []; $trend_alerts = []; $trend_recovers = [];
for ($i = 6; $i >= 0; $i--) {
$d = date('Y-m-d', strtotime("-$i day"));
$trend_days[] = $d;
$trend_alerts[] = (int)($trend_map[$d]['alerts'] ?? 0);
$trend_recovers[] = (int)($trend_map[$d]['recovers'] ?? 0);
}
$trend_max = max(1, max(array_merge($trend_alerts, $trend_recovers)));
// (4) 경보 종류 도넛 — $summary(30일) 재사용(추가 쿼리 없음)
$donut_segs = [
['label' => '고온', 'val' => (int)$summary['high_temp'], 'color' => '#ff8a5c'],
['label' => '고습', 'val' => (int)$summary['high_humidity'], 'color' => '#ffb23e'],
['label' => '누수', 'val' => (int)$summary['leak_alert'], 'color' => '#ff5470'],
['label' => '저온', 'val' => (int)$summary['low_temp'], 'color' => '#5fd6ee'],
['label' => '저습', 'val' => (int)$summary['low_humidity'], 'color' => '#b0b8ff'],
];
usort($donut_segs, fn($a, $b) => $b['val'] <=> $a['val']);
$donut_total = array_sum(array_column($donut_segs, 'val'));
// (5) 추이 차트 피크(기간 내 최고 온도) — $chart_points 재사용
$peak_i = null; $peak_v = null;
foreach ($chart_points as $ci => $cp) {
if ($cp['temp'] !== null && ($peak_v === null || $cp['temp'] > $peak_v)) {
$peak_v = (float)$cp['temp']; $peak_i = $ci;
}
}
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ko"> <html lang="ko">
@ -549,73 +666,214 @@ if (FORECAST_ENABLED) {
</nav> </nav>
</section> </section>
<div class="ops-grid"> <div class="zone" style="margin-top:var(--sp-6)">
<div> <div class="zone-label"><b>관제 개요</b> <span class="en">Overview</span></div>
<!-- 요약 카드 --> <div class="overview">
<div class="cards">
<div class="card"> <!-- (1) 가동률 게이지 + 7일 경보 스파크라인 + 상태 배지 -->
<div>등록 센서</div> <div class="ov-tile ov-gauge">
<div class="val"><?= count($statuses) ?>개</div> <h3>가동률 <span class="sub">온라인 센서</span></h3>
<div class="gauge-body">
<div class="gauge-ring <?= $ov_badge ?>">
<svg viewBox="0 0 120 120" role="img" aria-label="가동률 <?= $uptime_pct ?>%">
<g transform="rotate(-90 60 60)">
<circle class="g-track" cx="60" cy="60" r="52" pathLength="100"></circle>
<circle class="g-val" cx="60" cy="60" r="52" pathLength="100" stroke-dasharray="<?= $uptime_pct ?> 100"></circle>
</g>
</svg>
<div class="gauge-num"><b><?= $uptime_pct ?>%</b><small><?= $online_count ?> / <?= $sensor_total ?> 온라인</small></div>
</div> </div>
<div class="card"> <div class="gauge-foot">
<div>경보 센서</div> <span class="lbl">7일 경보</span>
<div class="val <?= $alert_count > 0 ? 'err-color' : 'ok-color' ?>"> <?php
<?= $alert_count ?>개 $sp_n = count($spark_alerts);
</div> $sp_max = max(1, max($spark_alerts));
</div> $sp_pts = [];
<div class="card"> foreach ($spark_alerts as $spi => $spv) {
<div>오프라인</div> $spx = $sp_n > 1 ? $spi * (84 / ($sp_n - 1)) : 42;
<div class="val <?= $offline_count > 0 ? 'warn-color' : 'ok-color' ?>"> $spy = 4 + (1 - $spv / $sp_max) * 22;
<?= $offline_count ?>개 $sp_pts[] = sprintf('%.1f,%.1f', $spx, $spy);
</div> }
</div> $sp_last = explode(',', end($sp_pts));
</div> ?>
<div class="cards flush"> <svg class="spark" viewBox="0 0 84 30" preserveAspectRatio="none" aria-hidden="true">
<div class="card"> <polyline points="<?= htmlspecialchars(implode(' ', $sp_pts)) ?>"></polyline>
<div>최근 온도</div> <circle class="sp-dot" cx="<?= htmlspecialchars($sp_last[0]) ?>" cy="<?= htmlspecialchars($sp_last[1]) ?>" r="2"></circle>
<div class="val compact"><?= htmlspecialchars(metric_display($latest_temp !== null ? (string)$latest_temp : null, '°C')) ?></div> </svg>
</div> <span class="g-badge <?= $ov_badge ?>"><?= htmlspecialchars($ov_badge_txt) ?></span>
<div class="card">
<div>최근 습도</div>
<div class="val compact"><?= htmlspecialchars(metric_display($latest_hum !== null ? (string)$latest_hum : null, '%')) ?></div>
</div>
<div class="card">
<div>현재 시각</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"> <!-- (2) 듀얼 KPI: 경보 센서 / 오프라인 -->
<h2>운영 자가진단</h2> <div class="ov-kpis">
<div class="diag-summary"> <div class="ov-kpi <?= $alert_count > 0 ? 'err' : 'ok' ?>">
실패 <?= $ops_failed ?>건 · 중요 <?= $ops_critical_count ?>건 · 주의 <?= $ops_warn_count ?>건 <span class="k-lbl">경보 센서</span>
<span class="k-val"><?= $alert_count ?><u>개</u></span>
</div> </div>
<div class="diag-list"> <div class="ov-kpi <?= $offline_count > 0 ? 'warn' : 'ok' ?>">
<?php foreach ($ops_checks as $check): <span class="k-lbl">오프라인</span>
$diag_class = $check['ok'] ? 'ok' : $check['severity']; <span class="k-val"><?= $offline_count ?><u>개</u></span>
// 색 외 채널: 심각도를 텍스트로도 구분(critical=중요, warn=주의)
$diag_status_text = $check['ok']
? '정상'
: ($check['severity'] === 'critical' ? '확인 필요 · 중요' : '확인 필요 · 주의');
?>
<div class="diag-item <?= htmlspecialchars($diag_class) ?>">
<i class="diag-dot"></i>
<div>
<strong><?= htmlspecialchars($check['label']) ?> · <?= htmlspecialchars($diag_status_text) ?></strong>
<span><?= htmlspecialchars($check['detail']) ?></span>
</div> </div>
</div> </div>
<!-- (3) 경보 종류 도넛 (30일, $summary 재사용) -->
<div class="ov-tile ov-donut">
<h3>경보 종류 <span class="sub">최근 30일</span></h3>
<?php if ($donut_total > 0): ?>
<div class="donut-body">
<div class="donut-ring">
<svg viewBox="0 0 120 120" role="img" aria-label="경보 종류 구성 총 <?= $donut_total ?>건">
<circle class="d-track" cx="60" cy="60" r="47" pathLength="100"></circle>
<g transform="rotate(-90 60 60)">
<?php $d_acc = 0; foreach ($donut_segs as $seg):
if ((int)$seg['val'] <= 0) { continue; }
$d_pct = $seg['val'] / $donut_total * 100; ?>
<circle class="seg" cx="60" cy="60" r="47" pathLength="100"
stroke="<?= htmlspecialchars($seg['color']) ?>"
stroke-dasharray="<?= sprintf('%.2f', $d_pct) ?> 100"
stroke-dashoffset="<?= sprintf('%.2f', -$d_acc) ?>"></circle>
<?php $d_acc += $d_pct; endforeach; ?>
</g>
</svg>
<div class="donut-num"><b><?= $donut_total ?></b><small>건</small></div>
</div>
<div class="donut-legend">
<?php foreach ($donut_segs as $seg): if ((int)$seg['val'] <= 0) { continue; } ?>
<span class="dl"><i style="background:<?= htmlspecialchars($seg['color']) ?>"></i><?= htmlspecialchars($seg['label']) ?><span class="dl-c"><?= (int)$seg['val'] ?></span></span>
<?php endforeach; ?>
</div>
</div>
<?php else: ?>
<div class="donut-empty">최근 30일 경보 없음</div>
<?php endif; ?>
</div>
<!-- (4) 경보 최다 센서 리더보드 (30일) -->
<div class="ov-tile ov-board">
<h3>경보 최다 센서 <span class="sub">30일 · TOP <?= count($board_rows) ?: 6 ?></span></h3>
<?php if (!empty($board_rows)): ?>
<div class="board">
<?php foreach ($board_rows as $b_rank => $br):
$b_w = $board_max > 0 ? (int)round((int)$br['c'] / $board_max * 100) : 0; ?>
<div class="board-row">
<span class="board-rank"><?= $b_rank + 1 ?></span>
<div class="board-main">
<div class="board-name"><?= htmlspecialchars($br['sensor_name']) ?></div>
<div class="board-bar"><span style="width:<?= $b_w ?>%"></span></div>
</div>
<span class="board-cnt"><?= (int)$br['c'] ?><small>건</small></span>
</div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<p class="panel-note">보안대책서 첨부용 점검표는 상단의 보안 증적 화면에서 내려받을 수 있습니다.</p> <?php else: ?>
</aside> <div class="board-empty">최근 30일 경보 발생 센서 없음</div>
<?php endif; ?>
</div> </div>
<!-- (5) 경보 발생 vs 복구 (7일 일별 이벤트율) -->
<div class="ov-tile ov-trend">
<h3>경보 발생 vs 복구 <span class="sub">최근 7일 · 일별</span></h3>
<div class="mini-legend">
<span><i style="background:var(--leak)"></i>경보 발생</span>
<span><i style="background:var(--normal)"></i>복구</span>
</div>
<?php
$tw = 640; $th = 200; $tpl = 40; $tpr = 16; $tpt = 16; $tpb = 34;
$tplot_w = $tw - $tpl - $tpr; $tplot_h = $th - $tpt - $tpb; $tn = count($trend_days);
$t_x = function ($i) use ($tpl, $tplot_w, $tn) { return $tn > 1 ? $tpl + $tplot_w * $i / ($tn - 1) : $tpl; };
$t_y = function ($v) use ($tpt, $tplot_h, $trend_max) { return $tpt + $tplot_h * (1 - $v / $trend_max); };
$t_line = function (array $vals) use ($t_x, $t_y) {
$pts = [];
foreach ($vals as $i => $v) { $pts[] = sprintf('%.1f,%.1f', $t_x($i), $t_y($v)); }
return implode(' ', $pts);
};
$t_peak_i = 0;
foreach ($trend_alerts as $i => $v) { if ($v > $trend_alerts[$t_peak_i]) { $t_peak_i = $i; } }
?>
<svg class="trend-svg" viewBox="0 0 <?= $tw ?> <?= $th ?>" preserveAspectRatio="xMidYMid meet" role="img" aria-label="경보 발생 vs 복구 7일 추이">
<rect x="<?= $tpl ?>" y="<?= $tpt ?>" width="<?= $tplot_w ?>" height="<?= $tplot_h ?>" class="plot-bg"></rect>
<?php for ($g = 0; $g <= 2; $g++):
$gy = $tpt + $tplot_h * $g / 2; $gv = $trend_max - $trend_max * $g / 2; ?>
<line x1="<?= $tpl ?>" y1="<?= sprintf('%.1f', $gy) ?>" x2="<?= $tpl + $tplot_w ?>" y2="<?= sprintf('%.1f', $gy) ?>" class="grid-line" stroke-dasharray="3,3"></line>
<text x="<?= $tpl - 6 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>" text-anchor="end" font-size="10" class="ax"><?= htmlspecialchars(rtrim(rtrim(number_format($gv, 1, '.', ''), '0'), '.')) ?></text>
<?php endfor; ?>
<polyline points="<?= htmlspecialchars($t_line($trend_recovers)) ?>" class="line-recover" fill="none" stroke-width="2" stroke-dasharray="6,3" stroke-linejoin="round" stroke-linecap="round"></polyline>
<polyline points="<?= htmlspecialchars($t_line($trend_alerts)) ?>" class="line-alert" fill="none" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"></polyline>
<?php if ($trend_alerts[$t_peak_i] > 0): ?>
<circle cx="<?= sprintf('%.1f', $t_x($t_peak_i)) ?>" cy="<?= sprintf('%.1f', $t_y($trend_alerts[$t_peak_i])) ?>" r="3.2" class="dot-alert"></circle>
<?php endif; ?>
<?php
$t_lbl_idx = $tn > 1 ? [0, intdiv($tn - 1, 2), $tn - 1] : [0];
foreach ($t_lbl_idx as $li):
$anchor = $li === 0 ? 'start' : ($li === $tn - 1 ? 'end' : 'middle'); ?>
<text x="<?= sprintf('%.1f', $t_x($li)) ?>" y="<?= $th - 12 ?>" text-anchor="<?= $anchor ?>" font-size="9" class="ax"><?= htmlspecialchars(substr($trend_days[$li], 5)) ?></text>
<?php endforeach; ?>
</svg>
</div>
<aside class="panel ov-diag" id="diagnostics-section">
<div class="diag-head">
<h2>운영 자가진단</h2>
<span class="diag-pill <?= $ops_pill ?>"><?= htmlspecialchars($ops_pill_text) ?></span>
</div>
<div class="diag-counts">
<span class="dc ok">정상<b><?= $ops_ok_count ?></b></span>
<span class="dc warn">주의<b><?= $ops_warn_count ?></b></span>
<span class="dc crit">중요<b><?= $ops_critical_count ?></b></span>
</div>
<?php if (!empty($ops_issues)): ?>
<ul class="diag-issues">
<?php foreach ($ops_issues as $check):
// 색 외 채널: 심각도를 텍스트로도 구분(critical=중요, warn=주의)
$sev_text = $check['severity'] === 'critical' ? '확인 필요 · 중요' : '확인 필요 · 주의';
$issue_detail = $check['detail']
. ($check['fix'] !== '' ? ' — ' . $check['fix'] : '');
?>
<li class="diag-issue <?= htmlspecialchars($check['severity']) ?>">
<i class="diag-dot"></i>
<div>
<strong><?= htmlspecialchars($check['label']) ?> · <?= htmlspecialchars($sev_text) ?></strong>
<span><?= htmlspecialchars($issue_detail) ?></span>
</div>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if (!empty($ops_ok)): ?>
<details class="diag-ok">
<summary>정상 항목 <?= $ops_ok_count ?>건 보기</summary>
<ul class="diag-ok-grid">
<?php foreach ($ops_ok as $check): ?>
<li><i></i><span><?= htmlspecialchars($check['label']) ?></span></li>
<?php endforeach; ?>
</ul>
</details>
<?php endif; ?>
<p class="panel-note">보안대책서 첨부용 점검표는 상단의 보안 증적 화면에서 내려받을 수 있습니다.</p>
</aside>
</div>
</div>
<div class="zone">
<div class="zone-label"><b>실시간 상태</b> <span class="en">Live</span></div>
<!-- 센서 상태 --> <!-- 센서 상태 -->
<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> <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 ($metric_statuses as $metric_sid => $s):
// 미수신 placeholder: 기대 온습도 센서(METRIC_SENSOR_IDS)가 아직 보고 전이면
// '데이터 없음' 카드로 항상 노출한다(누수 카드와 동일 — 화면에서 사라지지 않게).
if ($s === null): ?>
<div class="sensor-card">
<strong><?= (int)$metric_sid ?>번 센서 (온습도)</strong><br>
<div class="badge-row"><span class="badge offline">데이터 없음</span></div>
<div class="metric-time">아직 수신된 온습도 데이터가 없습니다.</div>
</div>
<?php continue; endif;
// 상태 우선순위: 오프라인 > 임계 경보 > 정상 // 상태 우선순위: 오프라인 > 임계 경보 > 정상
$status_code = $s['metric_status'] ?? 'normal'; $status_code = $s['metric_status'] ?? 'normal';
if ($s['is_offline']) { if ($s['is_offline']) {
@ -697,7 +955,7 @@ if (FORECAST_ENABLED) {
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($statuses)): ?> <?php if (empty($metric_statuses)): ?>
<p class="empty-note">아직 수신된 센서 데이터가 없습니다.</p> <p class="empty-note">아직 수신된 센서 데이터가 없습니다.</p>
<?php endif; ?> <?php endif; ?>
</div> </div>
@ -801,7 +1059,10 @@ if (FORECAST_ENABLED) {
</table> </table>
</div> </div>
</details> </details>
</div>
<div class="zone">
<div class="zone-label"><b>추세 분석</b> <span class="en">Trend</span></div>
<!-- 온습도 추이 차트 (인라인 SVG, 외부 라이브러리 없음) --> <!-- 온습도 추이 차트 (인라인 SVG, 외부 라이브러리 없음) -->
<h2 id="chart-section">온습도 추이 <h2 id="chart-section">온습도 추이
<?php if ($chart_sensor_id > 0): ?> <?php if ($chart_sensor_id > 0): ?>
@ -811,11 +1072,18 @@ if (FORECAST_ENABLED) {
<?php endif; ?> <?php endif; ?>
</h2> </h2>
<div class="chart-panel"> <div class="chart-panel">
<?php if (count($chart_points) >= 2): ?> <div class="chart-head">
<div class="chart-legend"> <div class="chart-legend" style="margin:0">
<span><i class="legend-swatch temp"></i> 온도(°C)</span> <span><i class="legend-swatch temp"></i> 온도(°C)</span>
<span><i class="legend-swatch hum"></i> 습도(%)</span> <span><i class="legend-swatch hum"></i> 습도(%)</span>
</div> </div>
<div class="range-toggle" role="group" aria-label="추이 기간 선택">
<?php foreach (['today' => '오늘', '3d' => '3일', '7d' => '7일'] as $rk => $rlabel): ?>
<a href="?range=<?= $rk ?>#chart-section"<?= $chart_range === $rk ? ' aria-current="true"' : '' ?>><?= htmlspecialchars($rlabel) ?></a>
<?php endforeach; ?>
</div>
</div>
<?php if (count($chart_points) >= 2): ?>
<div class="chart-svg-wrap"> <div class="chart-svg-wrap">
<?php <?php
// ── 인라인 SVG 라인 차트 (온도/습도 2계열) ────────────── // ── 인라인 SVG 라인 차트 (온도/습도 2계열) ──────────────
@ -975,13 +1243,30 @@ if (FORECAST_ENABLED) {
<text x="<?= sprintf('%.1f', $tx - 5) ?>" y="<?= sprintf('%.1f', $tyv - 5) ?>" <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> text-anchor="end" font-size="10" class="axis-temp"><?= htmlspecialchars($fmt($last_temp_v) . '℃') ?></text>
<?php endif; ?> <?php endif; ?>
<!-- 피크(기간 내 최고 온도) 마커 + 플래그 라벨 -->
<?php if ($peak_i !== null):
$pk_x = $x_at($peak_i); $pk_y = $y_temp($peak_v);
$pk_fw = 66;
$pk_fx = min(max($pk_x - $pk_fw / 2, $pad_l), $pad_l + $plot_w - $pk_fw);
?>
<line x1="<?= sprintf('%.1f', $pk_x) ?>" y1="<?= sprintf('%.1f', $pk_y) ?>"
x2="<?= sprintf('%.1f', $pk_x) ?>" y2="<?= $pad_t ?>" class="grid-line"></line>
<rect x="<?= sprintf('%.1f', $pk_fx) ?>" y="<?= $pad_t - 15 ?>" width="<?= $pk_fw ?>" height="14" rx="4" class="peak-flag"></rect>
<text x="<?= sprintf('%.1f', $pk_fx + $pk_fw / 2) ?>" y="<?= $pad_t - 4 ?>"
text-anchor="middle" font-size="10" class="peak-label"><?= htmlspecialchars('피크 ' . $fmt($peak_v) . '℃') ?></text>
<circle cx="<?= sprintf('%.1f', $pk_x) ?>" cy="<?= sprintf('%.1f', $pk_y) ?>" r="4" class="peak-marker"></circle>
<?php endif; ?>
</svg> </svg>
</div> </div>
<?php else: ?> <?php else: ?>
<div class="chart-empty">추이를 표시할 측정 데이터가 충분하지 않습니다 (2개 이상 필요).</div> <div class="chart-empty">추이를 표시할 측정 데이터가 충분하지 않습니다 (2개 이상 필요).</div>
<?php endif; ?> <?php endif; ?>
</div> </div>
</div>
<div class="zone">
<div class="zone-label"><b>운영 이력</b> <span class="en">History</span></div>
<section class="panel" id="summary-section"> <section class="panel" id="summary-section">
<h2>30일 운영 요약</h2> <h2>30일 운영 요약</h2>
<div class="report-grid"> <div class="report-grid">
@ -1156,6 +1441,7 @@ if (FORECAST_ENABLED) {
</table> </table>
</div> </div>
</details> </details>
</div>
</main> </main>