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:
parent
031a0c0fdf
commit
e392d6475b
2 changed files with 634 additions and 353 deletions
|
|
@ -1,212 +1,138 @@
|
|||
/* =============================================================================
|
||||
* app.css — 통합 모니터링 관리자 디자인 시스템 "Slate NOC Console"
|
||||
*
|
||||
* 단일 진실원(single source of truth). 7개 페이지가 이 파일 하나만 링크한다.
|
||||
* <link rel="stylesheet" href="assets/app.css">
|
||||
*
|
||||
* 설계 원칙:
|
||||
* - 모든 색/여백/반경/타이포는 :root CSS 변수. 페이지에 hex 리터럴 금지.
|
||||
* - 한글 시스템 폰트 우선(폐쇄망 — 웹폰트 로드 불가). 외부 리소스 0.
|
||||
* - 상태는 3중 인코딩: 색 + 도형(점/좌측레일/글리프) + 텍스트 라벨.
|
||||
* (색맹·흑백 인쇄에서도 정상/누수/경보/오프라인 구분됨 — WCAG 1.4.1)
|
||||
* - 명도 대비 WCAG AA(본문 4.5:1). 본문/캡션 하한 .8125rem, 라벨/배지/타임스탬프는 .75rem 허용.
|
||||
* - 라이트 우선 + prefers-color-scheme 다크 자동 + 인쇄는 강제 라이트(리포트/증적).
|
||||
* 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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
|
|
@ -549,73 +666,214 @@ if (FORECAST_ENABLED) {
|
|||
</nav>
|
||||
</section>
|
||||
|
||||
<div class="ops-grid">
|
||||
<div>
|
||||
<!-- 요약 카드 -->
|
||||
<div class="cards">
|
||||
<div class="card">
|
||||
<div>등록 센서</div>
|
||||
<div class="val"><?= count($statuses) ?>개</div>
|
||||
<div class="zone" style="margin-top:var(--sp-6)">
|
||||
<div class="zone-label"><b>관제 개요</b> <span class="en">Overview</span></div>
|
||||
<div class="overview">
|
||||
|
||||
<!-- (1) 가동률 게이지 + 7일 경보 스파크라인 + 상태 배지 -->
|
||||
<div class="ov-tile ov-gauge">
|
||||
<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 class="card">
|
||||
<div>경보 센서</div>
|
||||
<div class="val <?= $alert_count > 0 ? 'err-color' : 'ok-color' ?>">
|
||||
<?= $alert_count ?>개
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div>오프라인</div>
|
||||
<div class="val <?= $offline_count > 0 ? 'warn-color' : 'ok-color' ?>">
|
||||
<?= $offline_count ?>개
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cards flush">
|
||||
<div class="card">
|
||||
<div>최근 온도</div>
|
||||
<div class="val compact"><?= htmlspecialchars(metric_display($latest_temp !== null ? (string)$latest_temp : null, '°C')) ?></div>
|
||||
</div>
|
||||
<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 class="gauge-foot">
|
||||
<span class="lbl">7일 경보</span>
|
||||
<?php
|
||||
$sp_n = count($spark_alerts);
|
||||
$sp_max = max(1, max($spark_alerts));
|
||||
$sp_pts = [];
|
||||
foreach ($spark_alerts as $spi => $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));
|
||||
?>
|
||||
<svg class="spark" viewBox="0 0 84 30" preserveAspectRatio="none" aria-hidden="true">
|
||||
<polyline points="<?= htmlspecialchars(implode(' ', $sp_pts)) ?>"></polyline>
|
||||
<circle class="sp-dot" cx="<?= htmlspecialchars($sp_last[0]) ?>" cy="<?= htmlspecialchars($sp_last[1]) ?>" r="2"></circle>
|
||||
</svg>
|
||||
<span class="g-badge <?= $ov_badge ?>"><?= htmlspecialchars($ov_badge_txt) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="panel" id="diagnostics-section">
|
||||
<h2>운영 자가진단</h2>
|
||||
<div class="diag-summary">
|
||||
실패 <?= $ops_failed ?>건 · 중요 <?= $ops_critical_count ?>건 · 주의 <?= $ops_warn_count ?>건
|
||||
<!-- (2) 듀얼 KPI: 경보 센서 / 오프라인 -->
|
||||
<div class="ov-kpis">
|
||||
<div class="ov-kpi <?= $alert_count > 0 ? 'err' : 'ok' ?>">
|
||||
<span class="k-lbl">경보 센서</span>
|
||||
<span class="k-val"><?= $alert_count ?><u>개</u></span>
|
||||
</div>
|
||||
<div class="diag-list">
|
||||
<?php foreach ($ops_checks as $check):
|
||||
$diag_class = $check['ok'] ? 'ok' : $check['severity'];
|
||||
// 색 외 채널: 심각도를 텍스트로도 구분(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 class="ov-kpi <?= $offline_count > 0 ? 'warn' : 'ok' ?>">
|
||||
<span class="k-lbl">오프라인</span>
|
||||
<span class="k-val"><?= $offline_count ?><u>개</u></span>
|
||||
</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; ?>
|
||||
</div>
|
||||
<p class="panel-note">보안대책서 첨부용 점검표는 상단의 보안 증적 화면에서 내려받을 수 있습니다.</p>
|
||||
</aside>
|
||||
<?php else: ?>
|
||||
<div class="board-empty">최근 30일 경보 발생 센서 없음</div>
|
||||
<?php endif; ?>
|
||||
</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>
|
||||
<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';
|
||||
if ($s['is_offline']) {
|
||||
|
|
@ -697,7 +955,7 @@ if (FORECAST_ENABLED) {
|
|||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($statuses)): ?>
|
||||
<?php if (empty($metric_statuses)): ?>
|
||||
<p class="empty-note">아직 수신된 센서 데이터가 없습니다.</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
|
@ -801,7 +1059,10 @@ if (FORECAST_ENABLED) {
|
|||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="zone">
|
||||
<div class="zone-label"><b>추세 분석</b> <span class="en">Trend</span></div>
|
||||
<!-- 온습도 추이 차트 (인라인 SVG, 외부 라이브러리 없음) -->
|
||||
<h2 id="chart-section">온습도 추이
|
||||
<?php if ($chart_sensor_id > 0): ?>
|
||||
|
|
@ -811,11 +1072,18 @@ if (FORECAST_ENABLED) {
|
|||
<?php endif; ?>
|
||||
</h2>
|
||||
<div class="chart-panel">
|
||||
<?php if (count($chart_points) >= 2): ?>
|
||||
<div class="chart-legend">
|
||||
<div class="chart-head">
|
||||
<div class="chart-legend" style="margin:0">
|
||||
<span><i class="legend-swatch temp"></i> 온도(°C)</span>
|
||||
<span><i class="legend-swatch hum"></i> 습도(%)</span>
|
||||
</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">
|
||||
<?php
|
||||
// ── 인라인 SVG 라인 차트 (온도/습도 2계열) ──────────────
|
||||
|
|
@ -975,13 +1243,30 @@ if (FORECAST_ENABLED) {
|
|||
<text x="<?= sprintf('%.1f', $tx - 5) ?>" y="<?= sprintf('%.1f', $tyv - 5) ?>"
|
||||
text-anchor="end" font-size="10" class="axis-temp"><?= htmlspecialchars($fmt($last_temp_v) . '℃') ?></text>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- 피크(기간 내 최고 온도) 마커 + 플래그 라벨 -->
|
||||
<?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>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="chart-empty">추이를 표시할 측정 데이터가 충분하지 않습니다 (2개 이상 필요).</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="zone">
|
||||
<div class="zone-label"><b>운영 이력</b> <span class="en">History</span></div>
|
||||
<section class="panel" id="summary-section">
|
||||
<h2>30일 운영 요약</h2>
|
||||
<div class="report-grid">
|
||||
|
|
@ -1156,6 +1441,7 @@ if (FORECAST_ENABLED) {
|
|||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue