diff --git a/README.md b/README.md index 3b21791..ec75b2b 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 WebSocket 장기 실행 프로세스에서는 1분 단위 단기 집계 결과를 55초 동안, 약 5분 간격 장기 학습 샘플을 10분 동안 재사용해 DB 부담을 줄입니다. 장기 학습 샘플은 파일 캐시로도 보관해 다른 PHP 프로세스에서도 빠르게 재사용합니다. -대시보드의 잔여 시간 값에는 계산 근거 tooltip이 붙습니다. `SOC 다중 시간창 추세`로 표시되면 실제 DB 방전 속도 기반이며, 계산 출처에 `장기 학습`이 포함되면 누적 기록에서 학습한 SOC/부하 구간별 방전 경험도 반영된 상태입니다. `배터리 용량과 최근 전력 평균`이면 SOC 추세가 부족해 용량 기반 fallback이 사용된 상태입니다. +대시보드의 잔여 시간 값에는 Control UI 스타일의 커스텀 계산 근거 팝오버가 붙습니다. 기본 브라우저 `title` tooltip은 사용하지 않으며, 마우스 hover 또는 키보드 focus 중에도 상태 갱신이 들어오면 팝오버 내용과 위치가 즉시 갱신됩니다. `SOC 다중 시간창 추세`로 표시되면 실제 DB 방전 속도 기반이며, 계산 출처에 `장기 학습`이 포함되면 누적 기록에서 학습한 SOC/부하 구간별 방전 경험도 반영된 상태입니다. `배터리 용량과 최근 전력 평균`이면 SOC 추세가 부족해 용량 기반 fallback이 사용된 상태입니다. ## HA 알림 구조 @@ -165,6 +165,7 @@ control-wifi-observe.service - `battery_learned_profile_candidate()`: 누적 기록에서 SOC/부하 구간별 장기 방전 프로파일 후보 계산 - `battery_remaining_estimate()`: SOC 다중 시간창 방전 추세, 장기 학습 프로파일, 현재 부하 보정으로 잔여 시간 계산 - `battery_power_fallback_estimate()`: SOC 추세가 부족할 때 배터리 용량과 최근 CPU 전력 평균으로 잔여 시간 대체 계산 +- `batteryRemainingTitle()`, `showBatteryTooltip()`, `updateBatteryTooltip()`: 잔여 시간 계산 근거 커스텀 팝오버 표시와 실시간 갱신 - `bin/ha_notify_channel.php`: Android 알림 채널 제거 명령 CLI - `bin/wifi_observe.php`: 화면 접속 없이 `wifi_data()`를 호출해 5G 관측 세션을 선제 갱신 - `assets/wakelock.js`: WakeLock 버튼 상태와 Screen Wake Lock API 제어 @@ -185,7 +186,7 @@ control-wifi-observe.service - 센서 수집 주기와 DB 증가량을 확인합니다. - 배터리팩 변경 시 `/home/seo/secret/control.php`의 `battery` 값을 먼저 갱신합니다. 현재 기준은 `2200mAh x 8 parallel = 17600mAh`, nominal `65.12Wh`이며, SOC 추세가 부족한 경우의 fallback 계산에 사용됩니다. -- 잔여 시간이 갑자기 흔들리면 tooltip의 `계산 출처`, `반영 구간`, `시간당 SOC 감소`, `추세 신뢰도`, `최근 CPU 전력`을 먼저 확인합니다. +- 잔여 시간이 갑자기 흔들리면 커스텀 팝오버의 `계산 출처`, `반영 구간`, `시간당 SOC 감소`, `추세 신뢰도`, `최근 CPU 전력`을 먼저 확인합니다. - 하드웨어 또는 OS 변경 후 fan sysfs 경로를 확인합니다. - 2.4G 내장 WiFi는 `hostapd-24g.service` 개별 restart 직후 일부 IoT 단말이 WPA/EAPOL 재협상 루프에 들어갈 수 있으므로, 안정화된 상태에서는 개별 restart를 피하고 필요 시 전체 reboot 또는 채널 변경으로 재초기화합니다. - 5G WiFi 연결 시간은 외부 모듈이 값을 제공하지 않을 때 서버가 처음 감지한 시각 기준으로 계산합니다. 현재 5G 목록에서 MAC이 사라지면 관측 세션을 즉시 종료하므로 재연결 시 0부터 다시 누적됩니다. diff --git a/public/assets/app.js b/public/assets/app.js index 2a5d3b5..2acf989 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -32,6 +32,7 @@ statusBatteryVoltage: $('#statusBatteryVoltage'), statusBatterySoc: $('#statusBatterySoc'), statusBatteryRemaining: $('#statusBatteryRemaining'), + batteryRuntimeTooltip: $('#batteryRuntimeTooltip'), spikeLogList: $('#spikeLogList'), haNotifyList: $('#haNotifyList'), customServiceList: $('#customServiceList'), @@ -75,6 +76,8 @@ rebootRequesting: false, lang: 'en', theme: 'dark', + batteryTooltipOpen: false, + batteryTooltipText: '', }; const storageKeys = { @@ -718,6 +721,62 @@ return lines.join('\n'); } + function positionBatteryTooltip() { + if (!state.batteryTooltipOpen || !els.statusBatteryRemaining || !els.batteryRuntimeTooltip) { + return; + } + + const targetRect = els.statusBatteryRemaining.getBoundingClientRect(); + const tip = els.batteryRuntimeTooltip; + const margin = 14; + tip.style.left = '0px'; + tip.style.top = '0px'; + const tipRect = tip.getBoundingClientRect(); + let left = targetRect.left; + let top = targetRect.bottom + 10; + + if (left + tipRect.width > window.innerWidth - margin) { + left = window.innerWidth - tipRect.width - margin; + } + if (left < margin) { + left = margin; + } + if (top + tipRect.height > window.innerHeight - margin) { + top = targetRect.top - tipRect.height - 10; + } + if (top < margin) { + top = margin; + } + + tip.style.left = `${Math.round(left)}px`; + tip.style.top = `${Math.round(top)}px`; + } + + function updateBatteryTooltip(text = state.batteryTooltipText) { + state.batteryTooltipText = text || ''; + if (!els.batteryRuntimeTooltip) return; + + els.batteryRuntimeTooltip.textContent = state.batteryTooltipText || '-'; + if (state.batteryTooltipOpen) { + requestAnimationFrame(positionBatteryTooltip); + } + } + + function showBatteryTooltip() { + if (!els.batteryRuntimeTooltip) return; + state.batteryTooltipOpen = true; + els.batteryRuntimeTooltip.dataset.open = '1'; + els.batteryRuntimeTooltip.setAttribute('aria-hidden', 'false'); + updateBatteryTooltip(); + } + + function hideBatteryTooltip() { + if (!els.batteryRuntimeTooltip) return; + state.batteryTooltipOpen = false; + delete els.batteryRuntimeTooltip.dataset.open; + els.batteryRuntimeTooltip.setAttribute('aria-hidden', 'true'); + } + function renderTop(data) { setText(els.updated, data.generated_at || '-'); setText(els.temp, Number(data.system?.temp_c || 0).toFixed(1) + '°C'); @@ -912,7 +971,8 @@ setText(els.statusBatterySoc, battery.percent === null || battery.percent === undefined ? '-' : `${Number(battery.percent).toFixed(2)}%`); setText(els.statusBatteryRemaining, battery.remaining?.display || '-'); if (els.statusBatteryRemaining) { - els.statusBatteryRemaining.title = batteryRemainingTitle(battery.remaining); + els.statusBatteryRemaining.removeAttribute('title'); + updateBatteryTooltip(batteryRemainingTitle(battery.remaining)); } if (els.statusUsers) { const names = String(activeUsers.names || '').trim(); @@ -1684,6 +1744,12 @@ els.dmesgToggle?.addEventListener('click', () => { setDmesgOpen(!state.dmesgOpen); }); + els.statusBatteryRemaining?.addEventListener('mouseenter', showBatteryTooltip); + els.statusBatteryRemaining?.addEventListener('mouseleave', hideBatteryTooltip); + els.statusBatteryRemaining?.addEventListener('focus', showBatteryTooltip); + els.statusBatteryRemaining?.addEventListener('blur', hideBatteryTooltip); + window.addEventListener('resize', positionBatteryTooltip); + window.addEventListener('scroll', positionBatteryTooltip, true); [els.secondaryChartDetails, els.processDetails, els.diagnosticDetails].forEach(node => { node?.addEventListener('toggle', resizeChartsSoon); }); diff --git a/public/index.php b/public/index.php index 58bbd92..c592dd6 100644 --- a/public/index.php +++ b/public/index.php @@ -110,6 +110,7 @@ html[data-theme="light"] .btn.secondary{background:#d9e2ef;color:#172033}html[da html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da}html[data-theme="light"] .service-item{background:#e8f0f8;border-color:#b7c8dd}html[data-theme="light"] .service-name{color:#0f1f35}html[data-theme="light"] .service-desc,html[data-theme="light"] .service-meta{color:#42526a}html[data-theme="light"] .service-state{background:#f8fbff;border-color:#aebed2;color:#304057}html[data-theme="light"] .service-state.active{background:#d8f3e3;border-color:#7fc99c;color:#146c39}html[data-theme="light"] .service-state.failed{background:#ffe2e2;border-color:#e38b8b;color:#9f1d1d}html[data-theme="light"] .service-log,html[data-theme="light"] .dmesg-log{background:#f7fafc;border-color:#b9c8da;color:#0f2742} .resource-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.resource-box{min-width:0}.resource-box h3{margin:0 0 9px;color:var(--sub);font-size:14px;font-weight:500}.resource-table{width:100%;min-width:620px;table-layout:fixed;border-collapse:collapse;border:1px solid var(--line);border-radius:14px;overflow:hidden}.resource-table th,.resource-table td{padding:9px 10px;border-bottom:1px solid rgba(128,145,170,.18);background:var(--input);text-align:left;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.resource-table th{background:var(--table-head);color:var(--sub);font-weight:500}.resource-table .pid{width:72px}.resource-table .metric{width:82px}.resource-table .service{width:150px}.resource-table .cmd{width:auto} .notice{position:fixed;right:20px;bottom:20px;min-width:220px;max-width:420px;padding:14px 16px;border-radius:14px;font-weight:500;background:var(--table-head);color:var(--text);border:1px solid var(--line);box-shadow:var(--shadow);z-index:99}.notice:empty{display:none}.notice[data-type=success]{border-color:rgba(53,196,107,.5)}.notice[data-type=error]{border-color:rgba(255,95,87,.5)} +.status-value.interactive{cursor:help;text-decoration:underline;text-decoration-style:dotted;text-decoration-thickness:1px;text-underline-offset:4px}.runtime-tooltip{position:fixed;left:0;top:0;z-index:110;display:none;width:min(360px,calc(100vw - 28px));padding:14px 16px;border:1px solid rgba(84,101,128,.78);border-radius:16px;background:var(--card);color:var(--text);box-shadow:0 18px 48px rgba(0,0,0,.38);font-size:13px;line-height:1.5;white-space:pre-wrap;pointer-events:none}.runtime-tooltip[data-open="1"]{display:block}.runtime-tooltip::before{content:"";position:absolute;left:18px;top:-7px;width:12px;height:12px;border-left:1px solid rgba(84,101,128,.78);border-top:1px solid rgba(84,101,128,.78);background:var(--card);transform:rotate(45deg)}html[data-theme="light"] .runtime-tooltip{background:#f8fbff;border-color:#aebed2;color:#172033;box-shadow:0 18px 44px rgba(28,43,64,.18)}html[data-theme="light"] .runtime-tooltip::before{background:#f8fbff;border-color:#aebed2} .dialog-layer{position:fixed;inset:0;display:none;align-items:center;justify-content:center;padding:22px;background:rgba(4,7,12,.68);backdrop-filter:blur(8px);z-index:120}.dialog-layer[data-open="1"]{display:flex}.dialog-box{width:min(430px,100%);border:1px solid rgba(84,101,128,.78);border-radius:20px;background:var(--card);box-shadow:0 24px 70px rgba(0,0,0,.42);padding:20px}.dialog-box h3{margin:0 0 8px;font-size:19px;font-weight:500}.dialog-message{margin:0 0 16px;color:var(--sub);line-height:1.5;white-space:pre-wrap}.dialog-input{width:100%;height:48px;margin-bottom:14px;border-radius:14px;border:1px solid var(--line);background:var(--input);color:var(--text);padding:0 14px;outline:none}.dialog-input:focus{outline:2px solid rgba(59,130,246,.65);outline-offset:2px}.dialog-actions{display:flex;justify-content:flex-end;gap:10px}.dialog-actions .btn{min-width:84px}.dialog-actions .btn.red{background:#c62828} @media(max-width:1320px){.chart-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} @media(max-width:1100px){.layout{grid-template-columns:1fr}.chart-grid,.resource-grid{grid-template-columns:1fr}.topbar{align-items:flex-start;flex-direction:column}.topbar-right{width:100%}.topbar-right .btn{flex:1}.stat-grid{grid-template-columns:1fr}.resource-head{align-items:flex-start;flex-direction:column}.baseline-pill{text-align:left;white-space:normal}} @@ -194,7 +195,7 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da}
Throttle 10m
-
Battery V
-
Battery SOC
-
-
Remaining
-
+
Remaining
-
@@ -315,6 +316,7 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da}
+