From e392d6475be13f619669bc9f223c84d4071354ff Mon Sep 17 00:00:00 2001 From: cw2007 Date: Mon, 6 Jul 2026 10:58:39 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EA=B4=80=EC=A0=9C=20=EA=B0=9C=EC=9A=94=20=EB=B2=A4=ED=86=A0=20?= =?UTF-8?q?=EC=9E=AC=ED=8E=B8=20+=20=EC=83=98=ED=94=8C=20=EB=8C=80?= =?UTF-8?q?=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EC=BD=98=ED=85=90=EC=B8=A0=20?= =?UTF-8?q?=EC=9C=84=EC=A0=AF=205=EC=A2=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 샘플 대시보드(고객지원/방문분석) 콘텐츠를 누수·온습도 도메인으로 이식하고, 평면 나열이던 섹션을 관계·위치가 드러나는 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). --- php/assets/app.css | 585 ++++++++++++++++++++++----------------------- php/dashboard.php | 402 ++++++++++++++++++++++++++----- 2 files changed, 634 insertions(+), 353 deletions(-) diff --git a/php/assets/app.css b/php/assets/app.css index 464f188..d09b1b9 100644 --- a/php/assets/app.css +++ b/php/assets/app.css @@ -1,212 +1,138 @@ /* ============================================================================= - * app.css — 통합 모니터링 관리자 디자인 시스템 "Slate NOC Console" - * - * 단일 진실원(single source of truth). 7개 페이지가 이 파일 하나만 링크한다. - * - * - * 설계 원칙: - * - 모든 색/여백/반경/타이포는 :root CSS 변수. 페이지에 hex 리터럴 금지. - * - 한글 시스템 폰트 우선(폐쇄망 — 웹폰트 로드 불가). 외부 리소스 0. - * - 상태는 3중 인코딩: 색 + 도형(점/좌측레일/글리프) + 텍스트 라벨. - * (색맹·흑백 인쇄에서도 정상/누수/경보/오프라인 구분됨 — WCAG 1.4.1) - * - 명도 대비 WCAG AA(본문 4.5:1). 본문/캡션 하한 .8125rem, 라벨/배지/타임스탬프는 .75rem 허용. - * - 라이트 우선 + prefers-color-scheme 다크 자동 + 인쇄는 강제 라이트(리포트/증적). + * app.css — 통합 모니터링 관리자 디자인 시스템 "Slate NOC Console" (Dark) + * 다크 우선 관제 콘솔. 초대형 tabular 수치가 히어로. 7개 페이지 공용. + * 상태 3중 인코딩(색+도형+텍스트) 유지. 인쇄는 강제 라이트. * ============================================================================= */ -/* ── 디자인 토큰 (라이트) ─────────────────────────────────────────────────── */ :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; + --bg: #0f1533; + --surface: #191f42; + --surface-2: #232a55; + --raise: #2b3366; + --ink: #e9edff; + --ink-2: #98a2cf; + --ink-3: #6f7cae; + --line: #2b3364; + --line-2: #212850; - /* 브랜드 (절제된 스틸블루 — 저채도라 저온 시맨틱/링크와 분리) */ - --primary: #2b5bab; - --primary-600: #234f8f; - --on-primary: #ffffff; - --primary-tint: #e9eff8; + --primary: #6c7bff; + --primary-600: #8f9bff; + --on-primary: #0b1030; + --primary-tint: #1d2450; - /* 헤더(앱바) — 라이트: 브랜드 primary 배경 + 흰 잉크 */ - --header-bg: var(--primary); - --header-ink: var(--on-primary); + --header-bg: #141a3d; + --header-ink: var(--ink); - /* 상태 시맨틱 — 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; + --normal: #35d6a0; --normal-ink: #74ecc0; --normal-bg: rgba(53,214,160,.13); + --leak: #ff5470; --leak-ink: #ff90a5; --leak-bg: rgba(255,84,112,.14); + --warn: #ffb23e; --warn-ink: #ffcd7b; --warn-bg: rgba(255,178,62,.13); + --offline:#7885b8; --offline-ink:#9aa6d4; --offline-bg:rgba(120,133,184,.14); - /* 지표 칩 — 서로/브랜드와 뚜렷이 구분 */ - --hot-ink: var(--leak-ink); --hot-bg: var(--leak-bg); - --cold-ink: #0b5a6e; --cold-bg: #e1f1f4; /* 티일(저온) — 스틸블루와 다름 */ + --hot-ink: #ff9a7a; --hot-bg: rgba(255,120,90,.14); + --cold-ink: #5fd6ee; --cold-bg: rgba(63,199,230,.13); --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: #c2410c; /* 온도=따뜻 */ - --chart-hum: #0e7490; /* 습도=차가움(티일) */ + --chart-temp: #ff8a5c; + --chart-hum: #3fc7e6; --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); + --r-sm: 6px; --r: 10px; --r-lg: 16px; --r-pill: 999px; + --shadow: 0 1px 2px rgba(0,0,0,.4); + --shadow-lg: 0 12px 32px rgba(0,0,0,.45); + --ring: 0 0 0 3px rgba(108,123,255,.45); } -/* ── 다크 (관제 야간/개인 선호) — 같은 토큰을 명도만 조정 ──────────────────── */ -@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; + background: + radial-gradient(1200px 500px at 15% -8%, rgba(108,123,255,.10), transparent 60%), + radial-gradient(1000px 460px at 92% -4%, rgba(63,199,230,.07), transparent 60%), + var(--bg); + 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 { font-variant-numeric: tabular-nums; } -a { color: var(--primary); } +.val, .metric-chip, .report-stat strong, td.num, .num, .status-clock, +.card .val, .quick-tile strong { font-variant-numeric: tabular-nums; } +a { color: var(--primary-600); } :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); } +h1 { font-size: 1.375rem; font-weight: 800; line-height: 1.25; letter-spacing: -.01em; } +h2 { font-size: 1.0625rem; font-weight: 700; color: var(--ink); margin: var(--sp-6) 0 var(--sp-3); letter-spacing: .01em; } .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); } +.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 { - background: var(--header-bg); - color: var(--header-ink); - padding: var(--sp-4) var(--sp-6); + 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); + 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 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); } +.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); } +.top-actions a:hover { background: var(--raise); color: var(--ink); border-color: var(--primary); } -/* ── 레이아웃 ─────────────────────────────────────────────────────────────── */ -.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); } +.container { max-width: 1360px; margin: var(--sp-6) auto var(--sp-8); padding: 0 var(--sp-5); } +.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); } +.cards { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: var(--sp-4); margin-bottom: var(--sp-4); } +.cards.flush { 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); } -/* ── 패널·카드 ───────────────────────────────────────────────────────────── */ -.panel, .card, .chart-panel, .leak-status-card, .empty-note { - background: var(--surface); border: 1px solid var(--line); border-radius: var(--r); -} +.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 { 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 { 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 .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); 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; } +.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::before { content: ""; position: absolute; inset: 0 0 auto 0; height: 3px; background: linear-gradient(90deg, var(--normal), var(--chart-hum)); } +.status-hero h2 { font-size: 1.4rem; font-weight: 800; display: flex; align-items: center; gap: var(--sp-3); } +.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); } +.status-hero p { color: var(--ink-2); font-size: .95rem; margin-top: 4px; } +.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.warning::before { background: var(--warn); } +.status-hero.danger::before { background: var(--leak); } +.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; } +@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); } } +@media (prefers-reduced-motion: reduce) { .status-hero.danger h2::before { animation: none; } } +.status-clock { text-align: right; font-size: .8125rem; color: var(--ink-3); white-space: nowrap; font-variant-numeric: tabular-nums; } -/* ── 센서 상태 카드 (좌측레일 + 점 + 배지 = 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); } +.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:hover { transform: translateY(-2px); border-color: var(--raise); } +.sensor-card strong { font-size: 1rem; font-weight: 700; } +.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.offline { border-color: var(--offline); background: linear-gradient(180deg, var(--offline-bg), transparent 60%), var(--surface); } +.badge-row { margin-top: var(--sp-3); } -/* 배지/알약 공통 — 앞에 점 글리프(도형 중복), 텍스트 라벨 필수 */ -.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, .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::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); } @@ -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_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 { 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; } .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); } +.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-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; } +.diag-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); margin: 0 0 var(--sp-3); } +.diag-head h2 { margin: 0; } +.diag-pill { font-size: .75rem; font-weight: 700; padding: 4px 11px; border-radius: var(--r-pill); white-space: nowrap; } +.diag-pill.ok { background: var(--normal-bg); color: var(--normal-ink); } +.diag-pill.warn { background: var(--warn-bg); color: var(--warn-ink); } +.diag-pill.crit { background: var(--leak-bg); color: var(--leak-ink); } +.diag-counts { display: flex; gap: var(--sp-2); margin-bottom: var(--sp-3); } +.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; } +.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; } +.dc.ok b { color: var(--normal-ink); } +.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-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 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); } -table { - width: 100%; border-collapse: collapse; background: var(--surface); - border: 1px solid var(--line); border-radius: var(--r); overflow: hidden; -} +.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; margin-bottom: var(--sp-6); border-radius: var(--r-lg); } +table { width: 100%; border-collapse: collapse; background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg); 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); } +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; } +td { padding: 10px 14px; border-bottom: 1px solid var(--line-2); font-size: .875rem; color: var(--ink); } +tbody tr:last-child td { border-bottom: none; } 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 { 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 { width: 16px; 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-status-card { padding: var(--sp-5); transition: transform .12s ease, border-color .12s ease; } +.leak-status-card:hover { transform: translateY(-2px); border-color: var(--raise); } +.leak-status-card.leak { border-color: var(--leak); background: linear-gradient(180deg, var(--leak-bg), transparent 60%), var(--surface); } +.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 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); } +.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 { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r); padding: 12px; text-decoration: none; color: var(--ink); min-height: 74px; } .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.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; } +.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(--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; } -/* ── 모바일 접힘(disclosure) ────────────────────────────────────────────── */ .mobile-detail { margin-bottom: var(--sp-6); } .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-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 { 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-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.over { background: var(--leak-bg); color: var(--leak-ink); } .forecast-note { font-size: .75rem; color: var(--ink-3); } -/* ── 대응 필요 섹션(위험 시에만 노출: 조건 + 플레이북 + 담당자) ─────────────── */ -.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 { 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 h2 { margin: 0 0 var(--sp-3); display: flex; align-items: center; gap: var(--sp-2); } -.response-count { - font-size: .8125rem; font-weight: 700; color: var(--leak-ink); - background: var(--leak-bg); border-radius: var(--r-pill); padding: 2px 10px; -} +.response-count { font-size: .8125rem; font-weight: 700; color: var(--leak-ink); background: var(--leak-bg); border-radius: var(--r-pill); padding: 3px 11px; } .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-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-steps { margin: 0; padding-left: 1.4em; } .playbook-steps li { font-size: .875rem; line-height: 1.7; } -.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); -} +.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); } .contacts-label { font-size: .8125rem; font-weight: 700; color: var(--ink-2); } .contact { font-size: .875rem; } .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; -} -.login-box h1 { color: var(--primary); margin-bottom: var(--sp-2); text-align: center; } +.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::before { content: ""; position: absolute; inset: 0 0 auto 0; height: 3px; background: linear-gradient(90deg, var(--primary), var(--chart-hum)); } +.login-box h1 { color: var(--ink); 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, 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; } .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; -} +@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; } .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); } +.btn:disabled { background: var(--offline); cursor: not-allowed; opacity: .6; } +.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: 10px 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: 11px 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, 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; } -/* ── 반응형 ──────────────────────────────────────────────────────────────── */ +/* ===== 개요 벤토(존) — 관련 위젯 그룹핑으로 섹션 관계·위치 시각화 (샘플 콘텐츠 반영) ===== */ +.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) { - 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 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; } + .cards { 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; } + th, td { padding: 9px 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 { 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; padding: 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; } @@ -436,24 +433,22 @@ tbody tr:hover td { background: var(--surface-2); } .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; + --cold-bg: #fff; --dry-bg: #fff; --hot-bg: #fff; --humid-bg: #fff; --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; } - .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; } + .card::before, .status-hero::before, .login-box::before { display: none; } + .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; } } diff --git a/php/dashboard.php b/php/dashboard.php index 75abfbc..98587c2 100644 --- a/php/dashboard.php +++ b/php/dashboard.php @@ -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 = []; if ($chart_sensor_id > 0) { $chart_stmt = $db->prepare(" SELECT measured_at, temperature_c, humidity_percent 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 - 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 = array_reverse($chart_rows); @@ -230,6 +237,13 @@ $ops_warn_count = count(array_filter( $ops_checks, 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'])); @@ -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. $leak_incidents = []; $leak_photos_by_log = []; // log_id => [ ['id'=>..,'log_id'=>..,'captured_at'=>..], ... ] @@ -378,6 +408,93 @@ if (FORECAST_ENABLED) { // critical 먼저, 그다음 warn 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; + } +} ?> @@ -549,73 +666,214 @@ if (FORECAST_ENABLED) { -
-
- -
-
-
등록 센서
-
개
+
+
관제 개요 Overview
+
+ + +
+

가동률 온라인 센서

+
+
+ + + + + + +
% / 온라인
-
-
경보 센서
-
- 개 -
-
-
-
오프라인
-
- 개 -
-
-
-
-
-
최근 온도
-
-
-
-
최근 습도
-
-
-
-
현재 시각
-
+
+ 7일 경보 + $spv) { + $spx = $sp_n > 1 ? $spi * (84 / ($sp_n - 1)) : 42; + $spy = 4 + (1 - $spv / $sp_max) * 22; + $sp_pts[] = sprintf('%.1f,%.1f', $spx, $spy); + } + $sp_last = explode(',', end($sp_pts)); + ?> + +
-