From 52b04453e71ec527770c532be38f2417f12be3d3 Mon Sep 17 00:00:00 2001 From: seo Date: Mon, 20 Jul 2026 11:26:40 +0900 Subject: [PATCH] =?UTF-8?q?WiFi=20=EB=9D=BC=EC=9A=B0=ED=84=B0=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 70 +++- bin/wifi_observe.php | 33 ++ config/config.php | 34 ++ public/api.php | 524 +++++++++++++++++++++++++++ public/assets/app.js | 311 ++++++++++++++++ public/assets/site.webmanifest | 2 +- public/index.php | 38 +- public/manifest.json | 2 +- systemd/control-wifi-observe.service | 12 + systemd/control-wifi-observe.timer | 12 + 10 files changed, 1022 insertions(+), 16 deletions(-) create mode 100644 bin/wifi_observe.php create mode 100644 systemd/control-wifi-observe.service create mode 100644 systemd/control-wifi-observe.timer diff --git a/README.md b/README.md index 3ab1259..736bd23 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Control -팬 제어, 시스템 모니터링, WakeLock을 제공하는 PHP 기반 단일 관리 패널입니다. +팬 제어, 시스템 모니터링, WiFi 제어, WakeLock을 제공하는 PHP 기반 단일 관리 패널입니다. ## 프로젝트 성격 -Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 웹에서 관리하기 위한 내부 운영 도구입니다. 로그인 후 대시보드에서 온도, 팬 RPM, PWM, 저전압/스로틀링, notice를 확인하고 필요한 제어 명령을 실행합니다. 첫 화면은 운영 판단에 필요한 핵심 정보 위주로 구성하고, 상세 차트와 진단성 데이터는 접힘 영역에 둡니다. +Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 웹에서 관리하기 위한 내부 운영 도구입니다. 로그인 후 대시보드에서 온도, 팬 RPM, PWM, WiFi client, 저전압/스로틀링, notice를 확인하고 필요한 제어 명령을 실행합니다. 첫 화면은 운영 판단에 필요한 핵심 정보 위주로 구성하고, 상세 차트와 진단성 데이터는 접힘 영역에 둡니다. 상태 갱신은 WebSocket을 우선 사용하고, 연결 실패 시 HTTP fallback으로 전환합니다. 팬 정책 적용과 센서 수집은 백그라운드 작업과 API 호출을 통해 DB에 기록됩니다. @@ -17,6 +17,11 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - RP1 온도, 팬 효율, CPU 전력 상세 차트는 접힘 영역에서 확인 - UPS/배터리 잔여 시간 기능은 장치 제거 후 비활성 상태로 두고, 배터리 센서 조회와 장기 학습 계산은 돌리지 않음 - 라즈베리파이 저전압/스로틀링 현재 상태, 복구 중/반복 발생 판정, 부팅 후 이력, 최근 감지 시각, 지속시간, 최근 10분 통계 표시 +- WiFi client 목록과 2.4G/5G client 수 표시 +- 5G 외부 WiFi 모듈이 연결 시간을 제공하지 않는 경우 서버 관측 기반 연결 시간 보정 +- WiFi 호스트명이 비어 있으면 MAC 기반 임시명을 표시하고, MAC별 수동 호스트명을 DB에 저장해 이후에도 유지 +- WiFi 신호/송신 속도/수신 속도가 비어 있으면 대역, dBm, 송수신 속도, 5G RTL8822BU 특성을 기준으로 결측값 보정 +- WiFi client 신호는 7단계 한글 등급으로 표시하고, hover/focus/touch 시 커스텀 툴팁에서 실제 dBm을 표시 - System Notice 최근 이력 표시 - process CPU/MEM 후보는 프로세스 상세 접힘 영역에서 표시 - `/etc/systemd/system/*.service` 기준 사용자 생성 서비스의 서비스명, 현재 상태, enable 상태, PID, restart 횟수, 최근 journal 로그를 진단 접힘 영역에서 표시 @@ -30,9 +35,11 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 ## 주요 API -- `public/api.php?action=status`: 팬, 센서, 시스템, history, notice 상태 조회 +- `public/api.php?action=status`: 팬, 센서, 시스템, WiFi, history, notice 상태 조회 - `public/api.php?action=collect`: 센서 snapshot 수집 후 팬 정책 적용 - `public/api.php?action=fan`: 팬 모드와 PWM 저장/적용 +- `public/api.php?action=wifi`: 허용된 WiFi/DHCP service 제어 +- `public/api.php?action=wifi_alias`: MAC별 WiFi 호스트명 저장 - `public/api.php?action=settings`: 설정 가능한 Control 동작값 조회 - `public/api.php?action=settings_save`: 설정 모달 값 저장 - `public/api.php?action=settings_reset`: 설정 모달 값을 기본값으로 초기화 @@ -50,6 +57,8 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - `config/config.php`: DB, 인증, CSRF, shell 실행 공통 함수 - `apply_policy.php`: CLI/cron 팬 정책 적용 - `bin/control_ws.php`: WebSocket 서버. 핵심 PHP 파일 변경 감지 시 종료되고 systemd가 재시작해 새 코드를 로드 +- `bin/wifi_observe.php`: 5G WiFi 연결 시간 보정을 위한 시스템단 관측 CLI +- `systemd/control-wifi-observe.*`: WiFi 관측 CLI를 주기적으로 실행하는 systemd unit 예시 `asset-version.php`는 `public`의 PHP/JS/CSS/manifest/icon 파일과 `config`의 PHP 파일을 읽어 크기, 수정 시각, sha256 hash를 묶은 버전을 만듭니다. `asset-reload.js`는 화면 복귀, 포커스 복귀, 30초 간격으로 이 값을 다시 확인하고, 값이 달라지면 브라우저 Cache Storage를 비운 뒤 현재 화면을 다시 엽니다. @@ -61,6 +70,8 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - `/tmp/control-throttling-state.json`: `vcgencmd get_throttled` 기반 스로틀링 감지 상태, 최근 지속시간, 최근 episode 이력 - `system_notice_state`: notice 활성 상태와 기준값 - `system_notice_logs`: notice 발생 이력 +- `wifi_observed_sessions`: 5G WiFi client의 최초/마지막 감지 시간 +- `wifi_client_aliases`: MAC별 수동 WiFi 호스트명 - `app_settings`: 설정 모달에서 저장한 Control 동작값 오버라이드 - `/home/seo/secret/control.php`: 앱 비밀번호, DB 설정, 선택적 배터리 설정 - UPS/배터리 기능은 `/home/seo/secret/control.php`의 `battery.enabled`가 명시적으로 true일 때만 동작합니다. 현재 운영 기준은 UPS 제거 상태이므로 기본값은 false입니다. @@ -80,7 +91,7 @@ API 응답의 `battery.enabled`는 false, `battery.removed`는 true로 내려가 설정 가능한 항목은 다음 범위입니다. - 팬 자동 제어: 팬 시작 온도, 최대 온도, 즉시 최대 PWM 온도, RP1 온도 반영 비율, 팬 판단 온도 여유, 자동 상승/하강 PWM step -- 보안 정책: 자동 로그인 유지 기간, remember 쿠키 Secure/SameSite, CSRF 길이, remember 토큰 길이, User-Agent/IP 접두사 검증, 로그인 실패 잠금, 재부팅 허용 여부, 재부팅 확인 문구, 명령 timeout +- 보안 정책: 자동 로그인 유지 기간, remember 쿠키 Secure/SameSite, CSRF 길이, remember 토큰 길이, User-Agent/IP 접두사 검증, 로그인 실패 잠금, 재부팅 허용 여부, 재부팅 확인 문구, 명령 timeout, WiFi 조작 허용 여부 - 화면/진단 표시: 프로세스 후보 수, 사용자 서비스 로그 줄 수, 사용자 서비스 캐시 시간, 팬 이상 이력 수 - 저전압/스로틀링: 최근 판정창, 복구 중 유지 시간, 반복 발생 기준 횟수 - 팬 이상감지: 기준 표본 수, 최신 표본 제외 수, RPM/온도 이상 감지 차이, RPM/온도 복구 차이, RPM 감시 시작 PWM, alert 유지 중 반복 기록, 반복 기록 간격, 하강 변화 원인 후보 표시 여부 @@ -96,6 +107,7 @@ API 응답의 `battery.enabled`는 false, `battery.removed`는 true로 내려가 - 탭 복귀: 즉시 1회 상태 갱신 - UPS/배터리 잔여 시간: 현재 운영 구성에서는 표시하지 않음 - dmesg: 진단 패널에서 열었을 때만 1초마다 갱신, 닫으면 중지 +- WiFi 관측 보정 timer: 10초마다 `bin/wifi_observe.php` 실행 - WebSocket 소스 변경 감지: 15초마다 확인 후 재시작 - 팬 슬라이더 자동 적용 debounce: 약 450ms @@ -113,6 +125,29 @@ System Notice는 다음 조건으로 동작합니다. - System Notice 기록: alert 신규 진입 시 `system_notice_logs`에 기록합니다. `Alert 유지 중 반복 기록`을 켜면 유지 중에도 설정한 반복 기록 간격마다 다시 기록할 수 있습니다. - 저전압/스로틀링 표시: `vcgencmd get_throttled`의 현재 비트와 부팅 후 이력 비트를 읽어 현재 상태, 최근 감지, 지속시간, 최근 10분 통계를 표시합니다. 이 값은 System Notice와 별개이며, 팬/온도 alert 조건에는 직접 섞지 않습니다. +## WiFi 관측 자동화 + +5G 외부 WiFi 모듈은 `iw station dump`에 `connected time`을 제공하지 않는 경우가 있어, 서버가 MAC별 최초 감지 시각을 DB에 저장하고 경과 시간을 계산합니다. + +기존 대시보드 조회만으로도 보정은 가능하지만, 사용자가 접속하지 않은 시간에는 최초 감지가 늦어질 수 있습니다. 이를 막기 위해 다음 systemd timer가 10초마다 관측 CLI를 실행합니다. + +```bash +control-wifi-observe.timer +control-wifi-observe.service +``` + +실행 명령은 다음과 같습니다. + +```bash +/usr/bin/php /var/www/control/bin/wifi_observe.php +``` + +이 작업은 팬 정책, 차트 로그 저장, System Notice 기록과 분리되어 있으며 WiFi client 목록 확인과 `wifi_observed_sessions` 갱신만 수행합니다. + +호스트명은 dnsmasq lease 값을 우선 사용합니다. lease hostname이 `N/A`이면 MAC 주소 끝 6자리 기반 임시명 `기기-XXXXXX`을 표시하고, 표의 호스트명을 클릭해 저장한 수동 이름은 `wifi_client_aliases`에 보관해 이후에도 우선 표시합니다. + +신호/송신 속도/수신 속도는 실제 `iw station dump` 값이 있으면 그대로 사용합니다. 값이 비어 있으면 2.4G는 일반 802.11n 계열 링크 속도와 dBm 범위를 기준으로, 5G는 Realtek RTL8822BU/802.11ac 계열 동글의 최대 1300Mbps 특성을 참고해 dBm과 링크 속도 사이를 보정합니다. 화면의 신호 칸은 숫자 대신 `최상`, `매우 좋음`, `좋음`, `양호`, `보통`, `약함`, `매우 약함` 7단계 등급으로 표시하고, 마우스 hover/키보드 focus/모바일 touch 시 커스텀 툴팁으로 실제 dBm을 표시합니다. 툴팁 내용과 위치는 1초 상태 렌더마다 다시 갱신됩니다. + ## 처리 흐름 1. 화면 진입 시 로그인 세션과 remember token을 확인합니다. @@ -120,13 +155,17 @@ System Notice는 다음 조건으로 동작합니다. 3. 대시보드는 `status` snapshot을 렌더링합니다. 4. WebSocket 연결이 성공하면 `status` 메시지로 갱신하고, 실패하면 HTTP fallback을 사용합니다. 5. 팬 조작은 상태 저장, 정책 적용, 로그 저장 순서로 처리합니다. -6. WakeLock 버튼은 활성 상태를 초록색 버튼으로 표시합니다. -7. Reboot 버튼은 설정된 확인 문구와 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다. -8. 시스템 상태는 `vcgencmd get_throttled`를 읽어 현재 저전압/스로틀링 여부와 부팅 후 이력, 최근 감지 시각, 현재/최근 지속시간을 표시합니다. -9. 저전압/스로틀링 상태 파일에는 최근 episode 시작/종료를 보관하고, 최근 10분 발생 횟수, 감지 누적시간, 감지 비율을 계산합니다. -10. UPS/배터리 장치가 제거되어 배터리 센서 조회와 잔여 시간 학습 계산은 기본적으로 돌리지 않습니다. -11. System Notice 조건이 맞으면 Control이 `system_notice_logs`에 이력을 저장합니다. -12. 대시보드 첫 화면에는 팬/전원/스로틀링/핵심 차트를 우선 배치하고, 상세 차트, 프로세스 후보, 사용자 서비스, dmesg는 접힘 영역으로 분리합니다. +6. WiFi client 목록은 `iw station dump`와 dnsmasq lease를 조합하고, 5G 연결 시간이 `N/A`이면 MAC 기준 최초 감지 시간을 DB에 저장해 경과 시간을 계산합니다. +7. WiFi hostname이 비어 있으면 MAC 기반 임시명을 표시하고, 수동 저장한 MAC별 이름은 DB에서 우선 적용합니다. +8. WiFi 신호/송신 속도/수신 속도 값이 비어 있으면 실제로 존재하는 다른 무선 지표와 대역별 기준으로 값을 채웁니다. +9. `control-wifi-observe.timer`는 사용자 접속과 무관하게 10초마다 5G 관측 세션을 갱신합니다. +10. WakeLock 버튼은 활성 상태를 초록색 버튼으로 표시합니다. +11. Reboot 버튼은 설정된 확인 문구와 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다. +12. 시스템 상태는 `vcgencmd get_throttled`를 읽어 현재 저전압/스로틀링 여부와 부팅 후 이력, 최근 감지 시각, 현재/최근 지속시간을 표시합니다. +13. 저전압/스로틀링 상태 파일에는 최근 episode 시작/종료를 보관하고, 최근 10분 발생 횟수, 감지 누적시간, 감지 비율을 계산합니다. +14. UPS/배터리 장치가 제거되어 배터리 센서 조회와 잔여 시간 학습 계산은 기본적으로 돌리지 않습니다. +15. System Notice 조건이 맞으면 Control이 `system_notice_logs`에 이력을 저장합니다. +16. 대시보드 첫 화면에는 팬/전원/스로틀링/WiFi/핵심 차트를 우선 배치하고, 상세 차트, 프로세스 후보, 사용자 서비스, dmesg는 접힘 영역으로 분리합니다. ## 팬 판단 온도 @@ -147,6 +186,9 @@ System Notice는 다음 조건으로 동작합니다. - `apply_fan_policy()`: 팬 목표값 계산과 적용 - `fan_control_temp()`: CPU/RP1 혼합 팬 판단 온도 계산 - `json_out()`: API JSON 응답 표준화 +- `apply_observed_wifi_connected_time()`: 5G WiFi 연결 시간이 없는 client에 서버 관측 경과 시간 적용 +- `wifi_client_aliases()`, `save_wifi_client_alias()`: MAC별 WiFi 호스트명 조회와 저장 +- `apply_wifi_estimates()`: WiFi 신호/송신 속도/수신 속도 결측값 보정 - `battery_status()`: `CONTROL_BATTERY_ENABLED`가 true일 때만 배터리 센서를 읽고, 현재 기본 구성에서는 제거 상태를 반환 - `battery_trend_history()`: 배터리 기능을 다시 켤 때 최근 24시간 배터리 기록을 1분 단위로 집계 - `battery_profile_history()`: 배터리 기능을 다시 켤 때 최대 45일 배터리 기록을 약 5분 간격으로 샘플링하고 파일 캐시로 보관 @@ -163,6 +205,7 @@ System Notice는 다음 조건으로 동작합니다. - `sync_current_battery_remaining_history()`: 잔여 시간 차트 마지막 지점을 현재 snapshot 잔여 시간과 동기화 - `batteryRemainingTitle()`, `showBatteryTooltip()`, `updateBatteryTooltip()`: 잔여 시간 계산 근거 커스텀 팝오버 표시와 실시간 갱신 - `setting_definitions()`, `settings_payload()`, `save_settings()`, `reset_settings()`: 설정 모달 항목 정의, 조회, 저장, 초기화 +- `bin/wifi_observe.php`: 화면 접속 없이 `wifi_data()`를 호출해 5G 관측 세션을 선제 갱신 - `assets/wakelock.js`: WakeLock 버튼 상태와 Screen Wake Lock API 제어 - `customAlert()`, `customConfirm()`, `customPrompt()`: 대시보드 공통 확인/입력 dialog - `controlLang`, `controlTheme`: 재접속 후에도 유지되는 언어/테마 localStorage key @@ -179,13 +222,18 @@ System Notice는 다음 조건으로 동작합니다. - 재부팅 API는 CSRF, 로그인 세션, 설정된 확인 문구, 관리자 암호 재검증을 모두 요구합니다. - 로그인 화면은 CSRF 검사를 수행하고, 설정한 실패 횟수/집계 시간/잠금 시간에 따라 세션 단위 잠금을 적용합니다. - Remember login은 설정에 따라 Secure/SameSite, 토큰 길이, 만료 기간, User-Agent 고정, IPv4 접두사 고정을 조정할 수 있습니다. +- WiFi restart/reload 허용 여부와 root 명령 timeout은 설정 모달에서 조정합니다. - 앱 비밀번호와 DB/선택적 배터리 설정은 저장소 밖 secret 파일로 관리합니다. ## 운영 체크포인트 - 센서 수집 주기와 DB 증가량을 확인합니다. - UPS/배터리를 다시 붙일 때는 `/home/seo/secret/control.php`의 `battery.enabled`와 용량값을 먼저 확인합니다. +- WiFi client 표는 갱신 중에도 가로 스크롤 위치를 보존합니다. - 하드웨어 또는 OS 변경 후 fan sysfs 경로를 확인합니다. +- 2.4G 내장 WiFi는 `hostapd-24g.service` 개별 restart 직후 일부 IoT 단말이 WPA/EAPOL 재협상 루프에 들어갈 수 있으므로, 안정화된 상태에서는 개별 restart를 피하고 필요 시 전체 reboot 또는 채널 변경으로 재초기화합니다. +- 5G WiFi 연결 시간은 외부 모듈이 값을 제공하지 않을 때 서버가 처음 감지한 시각 기준으로 계산합니다. 현재 5G 목록에서 MAC이 사라지면 관측 세션을 즉시 종료하므로 재연결 시 0부터 다시 누적됩니다. +- `control-wifi-observe.timer`는 10초마다 `/var/www/control/bin/wifi_observe.php`를 실행해 사용자가 대시보드에 접속하지 않아도 5G 연결 시간 카운터를 시작하고 유지합니다. - WebSocket은 장기 실행 프로세스이므로 `public/api.php`, `config/config.php`, `bin/control_ws.php` 변경을 감지하면 15초 안에 종료되고 `control-websocket.service`가 새 프로세스로 재시작합니다. - WebSocket 장기 실행 중 DB 연결이 끊길 수 있으므로 reconnect 로그를 확인합니다. - SmartThings 재로드 자동화는 서울 전력분전반과 목포 누전차단기 전력 센서의 값이 10초 이상 변하지 않으면 통합을 재로드합니다. diff --git a/bin/wifi_observe.php b/bin/wifi_observe.php new file mode 100644 index 0000000..a0cbb1c --- /dev/null +++ b/bin/wifi_observe.php @@ -0,0 +1,33 @@ + true, + 'generated_at' => date('Y-m-d H:i:s'), + 'count24' => (int)($data['count24'] ?? 0), + 'count5' => (int)($data['count5'] ?? 0), + 'missing_connected_time_24g' => $missing24, + 'missing_connected_time_5g' => $missing5g, +], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; diff --git a/config/config.php b/config/config.php index 3705f1e..2015cf8 100644 --- a/config/config.php +++ b/config/config.php @@ -145,7 +145,10 @@ function setting_definitions(): array 'security.reboot_phrase' => ['group' => 'security', 'label' => '재부팅 확인 문구', 'type' => 'text', 'default' => '재부팅', 'min_length' => 2, 'max_length' => 32], 'security.allow_reboot' => ['group' => 'security', 'label' => '재부팅 실행 허용', 'type' => 'boolean', 'default' => 1], 'security.reboot_timeout_seconds' => ['group' => 'security', 'label' => '재부팅 명령 timeout', 'type' => 'number', 'default' => 5, 'min' => 2, 'max' => 30, 'step' => 1, 'unit' => '초'], + 'security.wifi_command_timeout_seconds' => ['group' => 'security', 'label' => 'WiFi 명령 timeout', 'type' => 'number', 'default' => 25, 'min' => 5, 'max' => 90, 'step' => 1, 'unit' => '초'], 'security.shell_default_timeout_seconds' => ['group' => 'security', 'label' => '기본 명령 timeout', 'type' => 'number', 'default' => 8, 'min' => 2, 'max' => 60, 'step' => 1, 'unit' => '초'], + 'security.allow_wifi_restart' => ['group' => 'security', 'label' => 'WiFi restart 허용', 'type' => 'boolean', 'default' => 1], + 'security.allow_wifi_reload' => ['group' => 'security', 'label' => 'WiFi reload 허용', 'type' => 'boolean', 'default' => 1], 'fan.auto_min_temp' => ['group' => 'fan', 'label' => '팬 시작 온도', 'type' => 'number', 'default' => 50, 'min' => 30, 'max' => 90, 'step' => 0.5, 'unit' => 'C'], 'fan.auto_max_temp' => ['group' => 'fan', 'label' => '팬 최대 온도', 'type' => 'number', 'default' => 80, 'min' => 45, 'max' => 100, 'step' => 0.5, 'unit' => 'C'], 'fan.full_temp' => ['group' => 'fan', 'label' => '즉시 최대 PWM 온도', 'type' => 'number', 'default' => 80, 'min' => 45, 'max' => 100, 'step' => 0.5, 'unit' => 'C'], @@ -268,6 +271,7 @@ function settings_payload(): array 'security' => '보안 정책', 'notify' => '알림 정책', 'display' => '화면/진단 표시', + 'wifi' => 'WiFi 진단', 'fan' => '팬 자동 제어', 'battery' => '배터리 예측', 'event' => '저전압/스로틀링', @@ -422,6 +426,20 @@ function bootstrap_db(): void COLLATE=utf8mb4_unicode_ci "); + $pdo->exec(" + CREATE TABLE IF NOT EXISTS wifi_client_aliases ( + mac VARCHAR(17) NOT NULL PRIMARY KEY, + hostname VARCHAR(255) NOT NULL, + note VARCHAR(255) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_updated_at (updated_at) + ) ENGINE=InnoDB + DEFAULT CHARSET=utf8mb4 + COLLATE=utf8mb4_unicode_ci + "); + $pdo->exec(" CREATE TABLE IF NOT EXISTS sensor_logs ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, @@ -490,6 +508,22 @@ function bootstrap_db(): void COLLATE=utf8mb4_unicode_ci "); + $pdo->exec(" + CREATE TABLE IF NOT EXISTS wifi_observed_sessions ( + mac VARCHAR(17) NOT NULL PRIMARY KEY, + band VARCHAR(16) NOT NULL, + iface VARCHAR(32) NOT NULL, + first_seen_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + last_seen_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + last_ip VARCHAR(64) NULL, + hostname VARCHAR(255) NULL, + INDEX idx_last_seen_at (last_seen_at), + INDEX idx_band (band) + ) ENGINE=InnoDB + DEFAULT CHARSET=utf8mb4 + COLLATE=utf8mb4_unicode_ci + "); + $pdo->exec(" CREATE TABLE IF NOT EXISTS remember_tokens ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, diff --git a/public/api.php b/public/api.php index 9ecb2cd..f57cb29 100644 --- a/public/api.php +++ b/public/api.php @@ -2523,6 +2523,475 @@ function fan_spike_history(int $limit = 100): array return $rows; } +function dnsmasq_leases(): array +{ + $map = []; + + foreach (@file('/var/lib/misc/dnsmasq.leases', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) { + $p = preg_split('/\s+/', trim($line)); + + if (count($p) >= 4) { + $mac = strtolower($p[1]); + + $map[$mac] = [ + 'expire_ts' => (int)$p[0], + 'mac' => $mac, + 'ip' => $p[2], + 'hostname' => $p[3] === '*' ? 'N/A' : $p[3], + ]; + } + } + + return $map; +} + +function wifi_client_aliases(): array +{ + try { + $rows = db()->query("SELECT mac, hostname FROM wifi_client_aliases")->fetchAll(); + } catch (Throwable) { + return []; + } + + $aliases = []; + foreach ($rows as $row) { + $mac = strtolower((string)($row['mac'] ?? '')); + $hostname = trim((string)($row['hostname'] ?? '')); + if (wifi_valid_mac($mac) && $hostname !== '') { + $aliases[$mac] = $hostname; + } + } + + return $aliases; +} + +function save_wifi_client_alias(string $mac, string $hostname): array +{ + $mac = strtolower(trim($mac)); + $hostname = trim(preg_replace('/\s+/', ' ', $hostname) ?? ''); + + if (!wifi_valid_mac($mac)) { + throw new InvalidArgumentException('bad_mac'); + } + if ($hostname === '' || mb_strlen($hostname) > 80) { + throw new InvalidArgumentException('bad_hostname'); + } + + $stmt = db()->prepare(" + INSERT INTO wifi_client_aliases (mac, hostname) + VALUES (:mac, :hostname) + ON DUPLICATE KEY UPDATE + hostname = VALUES(hostname), + updated_at = CURRENT_TIMESTAMP + "); + $stmt->execute([ + ':mac' => $mac, + ':hostname' => $hostname, + ]); + + return [ + 'mac' => $mac, + 'hostname' => $hostname, + ]; +} + +function iw_station_dump(string $iface): string +{ + if (!preg_match('/^[A-Za-z0-9_.:-]+$/', $iface)) { + return ''; + } + + return sh(['/usr/sbin/iw', 'dev', $iface, 'station', 'dump'], true, 4)['out']; +} + +function parse_live_wifi_rows(string $iface, string $band, string $text, array $leases, array $aliases): array +{ + $rows = []; + $cur = null; + + foreach (explode("\n", $text) as $line) { + $t = trim($line); + + if (preg_match('/^Station\s+([0-9a-f:]+)/i', $t, $m)) { + if ($cur !== null) { + $rows[] = $cur; + } + + $mac = strtolower($m[1]); + $lease = $leases[$mac] ?? []; + $leaseHostname = (string)($lease['hostname'] ?? 'N/A'); + $aliasHostname = $aliases[$mac] ?? null; + $hostname = $aliasHostname ?: $leaseHostname; + $hostnameSource = $aliasHostname ? 'alias' : ($leaseHostname === 'N/A' ? 'generated' : 'lease'); + $cur = [ + 'band' => $band, + 'iface' => $iface, + 'mac' => $mac, + 'ip' => $lease['ip'] ?? 'N/A', + 'hostname' => $hostname === 'N/A' ? '기기-' . strtoupper(substr(str_replace(':', '', $mac), -6)) : $hostname, + 'hostname_source' => $hostnameSource, + 'name' => $hostname === 'N/A' ? $mac : $hostname, + 'signal' => 'N/A', + 'tx_bitrate' => 'N/A', + 'rx_bitrate' => 'N/A', + 'signal_source' => 'missing', + 'tx_bitrate_source' => 'missing', + 'rx_bitrate_source' => 'missing', + 'connected_time' => 'N/A', + 'inactive_time' => 'N/A', + 'rx_bytes' => 0, + 'tx_bytes' => 0, + 'rx_packets' => 0, + 'tx_packets' => 0, + 'tx_failed' => 0, + ]; + continue; + } + + if ($cur === null) { + continue; + } + + if (preg_match('/^signal:\s+(.+)/', $t, $m)) { + $cur['signal'] = $m[1]; + $cur['signal_source'] = 'iw'; + } elseif (preg_match('/^tx bitrate:\s+(.+)/', $t, $m)) { + $cur['tx_bitrate'] = $m[1]; + $cur['tx_bitrate_source'] = 'iw'; + } elseif (preg_match('/^rx bitrate:\s+(.+)/', $t, $m)) { + $cur['rx_bitrate'] = $m[1]; + $cur['rx_bitrate_source'] = 'iw'; + } + elseif (preg_match('/^connected time:\s+(.+)/', $t, $m)) $cur['connected_time'] = $m[1]; + elseif (preg_match('/^inactive time:\s+(.+)/', $t, $m)) $cur['inactive_time'] = $m[1]; + elseif (preg_match('/^rx bytes:\s+(\d+)/', $t, $m)) $cur['rx_bytes'] = (int)$m[1]; + elseif (preg_match('/^tx bytes:\s+(\d+)/', $t, $m)) $cur['tx_bytes'] = (int)$m[1]; + elseif (preg_match('/^rx packets:\s+(\d+)/', $t, $m)) $cur['rx_packets'] = (int)$m[1]; + elseif (preg_match('/^tx packets:\s+(\d+)/', $t, $m)) $cur['tx_packets'] = (int)$m[1]; + elseif (preg_match('/^tx failed:\s+(\d+)/', $t, $m)) $cur['tx_failed'] = (int)$m[1]; + } + + if ($cur !== null) { + $rows[] = $cur; + } + + return $rows; +} + +function wifi_time_ms(string $value): int +{ + if (preg_match('/(\d+)/', $value, $m)) { + return (int)$m[1]; + } + + return PHP_INT_MAX; +} + +function wifi_unknown(mixed $value): bool +{ + $text = strtoupper(trim((string)$value)); + return $text === '' || $text === 'N/A' || $text === '-'; +} + +function wifi_bitrate_mbps(mixed $value): ?float +{ + if (preg_match('/([0-9]+(?:\.[0-9]+)?)/', (string)$value, $m)) { + return (float)$m[1]; + } + + return null; +} + +function wifi_signal_dbm(string $value): int +{ + if (preg_match('/-?\d+/', $value, $m)) { + return (int)$m[0]; + } + + return -999; +} + +function estimate_wifi_rate_from_signal(string $band, int $dbm): float +{ + if ($band === '5G') { + if ($dbm >= -50) return 1300.0; + if ($dbm >= -58) return 866.7; + if ($dbm >= -66) return 585.0; + if ($dbm >= -73) return 390.0; + if ($dbm >= -80) return 173.3; + return 54.0; + } + + if ($dbm >= -50) return 300.0; + if ($dbm >= -58) return 144.4; + if ($dbm >= -66) return 72.2; + if ($dbm >= -73) return 39.0; + if ($dbm >= -80) return 19.5; + return 6.5; +} + +function estimate_wifi_signal_from_rate(string $band, float $mbps): int +{ + if ($band === '5G') { + if ($mbps >= 900) return -48; + if ($mbps >= 600) return -55; + if ($mbps >= 350) return -63; + if ($mbps >= 150) return -71; + if ($mbps >= 54) return -79; + return -84; + } + + if ($mbps >= 250) return -48; + if ($mbps >= 120) return -56; + if ($mbps >= 65) return -64; + if ($mbps >= 30) return -72; + if ($mbps >= 10) return -80; + return -86; +} + +function format_estimated_mbps(float $mbps): string +{ + $value = abs($mbps - round($mbps)) < 0.05 + ? (string)(int)round($mbps) + : number_format($mbps, 1); + + return $value . ' MBit/s'; +} + +function apply_wifi_estimates(array $clients): array +{ + foreach ($clients as &$client) { + $band = (string)($client['band'] ?? '2.4G'); + $signalDbm = wifi_signal_dbm((string)($client['signal'] ?? '')); + $txMbps = wifi_bitrate_mbps($client['tx_bitrate'] ?? null); + $rxMbps = wifi_bitrate_mbps($client['rx_bitrate'] ?? null); + + if (wifi_unknown($client['signal'] ?? null)) { + $sourceRate = max($txMbps ?? 0.0, $rxMbps ?? 0.0); + $signalDbm = $sourceRate > 0 + ? estimate_wifi_signal_from_rate($band, $sourceRate) + : ($band === '5G' ? -67 : -70); + $client['signal'] = $signalDbm . ' dBm'; + $client['signal_source'] = $sourceRate > 0 ? 'estimated_from_rate' : 'estimated_default'; + } + + if (wifi_unknown($client['tx_bitrate'] ?? null)) { + $base = $rxMbps ?? estimate_wifi_rate_from_signal($band, $signalDbm); + $client['tx_bitrate'] = format_estimated_mbps($base); + $client['tx_bitrate_source'] = $rxMbps === null ? 'estimated_from_signal' : 'estimated_from_rx'; + } + + if (wifi_unknown($client['rx_bitrate'] ?? null)) { + $base = $txMbps ?? estimate_wifi_rate_from_signal($band, $signalDbm); + $client['rx_bitrate'] = format_estimated_mbps($base); + $client['rx_bitrate_source'] = $txMbps === null ? 'estimated_from_signal' : 'estimated_from_tx'; + } + } + unset($client); + + return $clients; +} + +function dedupe_wifi_clients(array $clients): array +{ + $deduped = []; + + foreach ($clients as $client) { + $mac = strtolower((string)($client['mac'] ?? '')); + $key = $mac !== '' && $mac !== 'n/a' + ? 'mac:' . $mac + : 'row:' . ($client['band'] ?? '') . ':' . ($client['ip'] ?? '') . ':' . ($client['hostname'] ?? ''); + + if (!isset($deduped[$key])) { + $deduped[$key] = $client; + continue; + } + + $currentInactive = wifi_time_ms((string)($deduped[$key]['inactive_time'] ?? '')); + $nextInactive = wifi_time_ms((string)($client['inactive_time'] ?? '')); + $currentSignal = wifi_signal_dbm((string)($deduped[$key]['signal'] ?? '')); + $nextSignal = wifi_signal_dbm((string)($client['signal'] ?? '')); + + if ($nextInactive < $currentInactive || ($nextInactive === $currentInactive && $nextSignal > $currentSignal)) { + $deduped[$key] = $client; + } + } + + return array_values($deduped); +} + +function wifi_connected_time_missing(mixed $value): bool +{ + $text = strtoupper(trim((string)$value)); + return $text === '' || $text === 'N/A' || $text === '-'; +} + +function wifi_valid_mac(string $mac): bool +{ + return preg_match('/^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/', strtolower($mac)) === 1; +} + +function prune_observed_wifi_sessions(PDO $pdo, array $visibleMacs): void +{ + $visibleMacs = array_values(array_unique(array_filter( + array_map(static fn($mac) => strtolower((string)$mac), $visibleMacs), + static fn($mac) => wifi_valid_mac($mac) + ))); + + if ($visibleMacs === []) { + $pdo->exec("DELETE FROM wifi_observed_sessions WHERE band = '5G'"); + return; + } + + $placeholders = implode(',', array_fill(0, count($visibleMacs), '?')); + $stmt = $pdo->prepare(" + DELETE FROM wifi_observed_sessions + WHERE band = '5G' + AND mac NOT IN ($placeholders) + "); + $stmt->execute($visibleMacs); +} + +function observed_wifi_duration_seconds(array $clients): array +{ + $targets = []; + $visible5gMacs = []; + + foreach ($clients as $client) { + $band = (string)($client['band'] ?? ''); + $mac = strtolower((string)($client['mac'] ?? '')); + + if ($band !== '5G' || !wifi_valid_mac($mac)) { + continue; + } + + $visible5gMacs[] = $mac; + + if (!wifi_connected_time_missing($client['connected_time'] ?? null)) { + continue; + } + + $targets[$mac] = [ + 'mac' => $mac, + 'band' => $band, + 'iface' => (string)($client['iface'] ?? ''), + 'ip' => (string)($client['ip'] ?? ''), + 'hostname' => (string)($client['hostname'] ?? ''), + ]; + } + + try { + $pdo = db(); + $pdo->beginTransaction(); + prune_observed_wifi_sessions($pdo, $visible5gMacs); + + if ($targets === []) { + $pdo->commit(); + return []; + } + + $upsert = $pdo->prepare(" + INSERT INTO wifi_observed_sessions + (mac, band, iface, first_seen_at, last_seen_at, last_ip, hostname) + VALUES + (:mac, :band, :iface, NOW(3), NOW(3), :last_ip, :hostname) + ON DUPLICATE KEY UPDATE + band = VALUES(band), + iface = VALUES(iface), + last_seen_at = NOW(3), + last_ip = VALUES(last_ip), + hostname = VALUES(hostname) + "); + + foreach ($targets as $target) { + $upsert->execute([ + ':mac' => $target['mac'], + ':band' => $target['band'], + ':iface' => $target['iface'], + ':last_ip' => $target['ip'] === 'N/A' ? null : $target['ip'], + ':hostname' => $target['hostname'] === 'N/A' ? null : $target['hostname'], + ]); + } + + $placeholders = implode(',', array_fill(0, count($targets), '?')); + $stmt = $pdo->prepare(" + SELECT + mac, + GREATEST(0, TIMESTAMPDIFF(SECOND, first_seen_at, NOW(3))) AS observed_seconds + FROM wifi_observed_sessions + WHERE mac IN ($placeholders) + "); + $stmt->execute(array_keys($targets)); + $rows = $stmt->fetchAll(); + + $pdo->commit(); + + $seconds = []; + foreach ($rows as $row) { + $seconds[strtolower((string)$row['mac'])] = (int)$row['observed_seconds']; + } + + return $seconds; + } catch (Throwable) { + if (isset($pdo) && $pdo instanceof PDO && $pdo->inTransaction()) { + $pdo->rollBack(); + } + + return []; + } +} + +function apply_observed_wifi_connected_time(array $clients): array +{ + $observedSeconds = observed_wifi_duration_seconds($clients); + + if ($observedSeconds === []) { + return $clients; + } + + foreach ($clients as &$client) { + $mac = strtolower((string)($client['mac'] ?? '')); + + if (($client['band'] ?? '') !== '5G' + || !wifi_connected_time_missing($client['connected_time'] ?? null) + || !array_key_exists($mac, $observedSeconds) + ) { + continue; + } + + $client['connected_time'] = (string)$observedSeconds[$mac]; + $client['connected_time_source'] = 'observed'; + } + unset($client); + + return $clients; +} + +function wifi_data(): array +{ + $leases = dnsmasq_leases(); + $aliases = wifi_client_aliases(); + $clients = []; + + foreach (['wlan0' => '2.4G', 'wlan1' => '5G'] as $iface => $band) { + $clients = array_merge( + $clients, + parse_live_wifi_rows($iface, $band, iw_station_dump($iface), $leases, $aliases) + ); + } + + $clients = dedupe_wifi_clients($clients); + $clients = apply_wifi_estimates($clients); + $clients = apply_observed_wifi_connected_time($clients); + + return [ + 'clients' => $clients, + 'count24' => count(array_filter($clients, fn($c) => $c['band'] === '2.4G')), + 'count5' => count(array_filter($clients, fn($c) => $c['band'] === '5G')), + 'leases' => array_values($leases), + ]; +} + function action_rows(int $limit = 80): array { $limit = max(1, min(300, $limit)); @@ -2870,6 +3339,7 @@ function collect_snapshot(bool $applyFan = true): array 'system' => $system, 'battery' => $battery, + 'wifi' => wifi_data(), 'history' => $history, 'processes' => $processes, 'custom_services' => custom_systemd_services(), @@ -3018,6 +3488,60 @@ function control_api_dispatch(): void ]); } + if ($action === 'wifi') { + $verb = (string)($_POST['verb'] ?? ''); + $unit = (string)($_POST['unit'] ?? ''); + $allowedUnits = [ + 'hostapd-24g.service', + 'hostapd-5g.service', + 'dnsmasq.service', + ]; + + if ( + !in_array($unit, $allowedUnits, true) + || !in_array($verb, ['restart', 'reload'], true) + || ($verb === 'restart' && !(bool)setting_value('security.allow_wifi_restart')) + || ($verb === 'reload' && !(bool)setting_value('security.allow_wifi_reload')) + ) { + json_out([ + 'ok' => false, + 'error' => 'bad_wifi_request', + ], 400); + } + + $result = sh(['/usr/bin/systemctl', $verb, $unit], true, (int)setting_value('security.wifi_command_timeout_seconds')); + $out = $result['out']; + + add_fan_action( + 'wifi_' . $verb, + null, + null, + $unit . "\n" . mb_substr($out, 0, 1000), + true + ); + + json_out([ + 'ok' => true, + 'output' => $out, + 'data' => collect_snapshot(false), + ]); + } + + if ($action === 'wifi_alias') { + $alias = save_wifi_client_alias( + (string)($_POST['mac'] ?? ''), + (string)($_POST['hostname'] ?? '') + ); + + json_out([ + 'ok' => true, + 'data' => [ + 'alias' => $alias, + 'status' => collect_snapshot(false), + ], + ]); + } + if ($action === 'collect') { json_out([ 'ok' => true, diff --git a/public/assets/app.js b/public/assets/app.js index 6b007be..aa03b02 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -12,6 +12,10 @@ fanSliderWrap: $('#fanSliderWrap'), fanSliderValue: $('#fanSliderValue'), fanModeOptions: $('#fanModeOptions'), + wifi24: $('#wifi24'), + wifi5: $('#wifi5'), + wifiTable: $('#wifiTable'), + signalTooltip: $('#signalTooltip'), statusHost: $('#statusHost'), statusLoad: $('#statusLoad'), statusUsers: $('#statusUsers'), @@ -80,6 +84,10 @@ theme: 'dark', batteryTooltipOpen: false, batteryTooltipText: '', + signalTooltipOpen: false, + signalTooltipTarget: null, + signalTooltipKey: '', + signalTooltipText: '', scanScrollHoldUntil: 0, settingsPayload: null, settingsOpen: false, @@ -98,6 +106,9 @@ fanStatus: 'Fan Status', cpuTemp: 'CPU Temp', fanRpm: 'Fan RPM', + wifiStatus: 'WiFi Status', + clients24: '2.4G Clients', + clients5: '5G Clients', systemStatus: 'System Status', loadAvg: 'Load Avg', activeUsers: 'Active Users', @@ -138,6 +149,7 @@ cpuWatt: 'CPU Watt', remaining: 'Remaining', batteryVoltage: 'Battery Voltage', + wifiClients: 'WiFi Clients', deviceName: 'Device', address: 'Address', paired: 'Paired', @@ -148,8 +160,13 @@ channel: 'Channel', security: 'Security', lastSeen: 'Last Seen', + band: 'Band', hostname: 'Hostname', signal: 'Signal', + txRate: 'TX Rate', + rxRate: 'RX Rate', + connected: 'Connected', + inactive: 'Inactive', systemNotice: 'System Notice', processDetails: 'Process Details', diagnostics: 'Diagnostics', @@ -161,6 +178,9 @@ serviceEnabled: 'enabled', servicePid: 'pid', serviceRestarts: 'restarts', + wifiAliasTitle: 'WiFi Hostname', + wifiAliasPrompt: 'Enter a name to keep for this MAC address.', + wifiAliasSaved: 'WiFi hostname saved.', show: 'Show', hide: 'Hide', translateButton: 'Translate', @@ -178,6 +198,7 @@ dialogInput: 'Input', dialogOk: 'OK', dialogCancel: 'Cancel', + noWifiClients: 'No connected WiFi clients', noProcessActivity: 'No process activity', noCause: 'No candidate', recordedReason: 'Recorded reason', @@ -232,6 +253,9 @@ fanStatus: '팬 상태', cpuTemp: 'CPU 온도', fanRpm: '팬 RPM', + wifiStatus: 'WiFi 상태', + clients24: '2.4G 접속', + clients5: '5G 접속', systemStatus: '시스템 상태', loadAvg: '부하 평균', activeUsers: '활성 사용자', @@ -272,6 +296,7 @@ cpuWatt: 'CPU 전력', remaining: '잔여 시간', batteryVoltage: '배터리 전압', + wifiClients: 'WiFi 클라이언트', deviceName: '장치', address: '주소', paired: '페어링', @@ -282,8 +307,13 @@ channel: '채널', security: '보안', lastSeen: '마지막 감지', + band: '대역', hostname: '호스트명', signal: '신호', + txRate: '송신 속도', + rxRate: '수신 속도', + connected: '연결 시간', + inactive: '비활성', systemNotice: '시스템 알림', processDetails: '프로세스 상세', diagnostics: '진단', @@ -295,6 +325,9 @@ serviceEnabled: '활성화', servicePid: 'PID', serviceRestarts: '재시작', + wifiAliasTitle: 'WiFi 호스트명', + wifiAliasPrompt: '이 MAC 주소에 계속 표시할 이름을 입력하세요.', + wifiAliasSaved: 'WiFi 호스트명을 저장했습니다.', show: '보기', hide: '숨기기', translateButton: '번역', @@ -312,6 +345,7 @@ dialogInput: '입력', dialogOk: '확인', dialogCancel: '취소', + noWifiClients: '연결된 WiFi 클라이언트 없음', noProcessActivity: '프로세스 활동 없음', noCause: '원인 후보 없음', recordedReason: '기록된 이유', @@ -1041,11 +1075,103 @@ els.batteryRuntimeTooltip.setAttribute('aria-hidden', 'true'); } + function signalTooltipTarget() { + if (state.signalTooltipKey && typeof document.querySelector === 'function') { + const selector = `[data-signal-key="${cssEscape(state.signalTooltipKey)}"]`; + const fresh = document.querySelector(selector); + if (fresh) { + state.signalTooltipTarget = fresh; + return fresh; + } + } + return state.signalTooltipTarget?.isConnected ? state.signalTooltipTarget : null; + } + + function signalTooltipText(target = signalTooltipTarget()) { + if (!target) return '-'; + const label = target.dataset.signalLabel || target.textContent || '-'; + const dbm = target.dataset.signalDbm || '-'; + return `${label}\n${dbm}`; + } + + function positionSignalTooltip() { + if (!state.signalTooltipOpen || !els.signalTooltip) { + return; + } + const target = signalTooltipTarget(); + if (!target) { + hideSignalTooltip(); + return; + } + + const targetRect = target.getBoundingClientRect(); + const tip = els.signalTooltip; + const margin = 14; + tip.style.left = '0px'; + tip.style.top = '0px'; + const tipRect = tip.getBoundingClientRect(); + let left = targetRect.left + (targetRect.width / 2) - (tipRect.width / 2); + 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 updateSignalTooltip() { + if (!els.signalTooltip) return; + state.signalTooltipText = signalTooltipText(); + els.signalTooltip.textContent = state.signalTooltipText || '-'; + if (state.signalTooltipOpen) { + requestAnimationFrame(positionSignalTooltip); + } + } + + function showSignalTooltip(target) { + if (!els.signalTooltip || !target || target.classList.contains('unknown')) return; + state.signalTooltipOpen = true; + state.signalTooltipTarget = target; + state.signalTooltipKey = target.dataset.signalKey || ''; + els.signalTooltip.dataset.open = '1'; + els.signalTooltip.setAttribute('aria-hidden', 'false'); + updateSignalTooltip(); + } + + function hideSignalTooltip() { + if (!els.signalTooltip) return; + state.signalTooltipOpen = false; + state.signalTooltipTarget = null; + state.signalTooltipKey = ''; + delete els.signalTooltip.dataset.open; + els.signalTooltip.setAttribute('aria-hidden', 'true'); + } + + function cssEscape(value) { + if (window.CSS?.escape) { + return window.CSS.escape(value); + } + return String(value).replace(/["\\]/g, '\\$&'); + } + function renderTop(data) { setText(els.updated, data.generated_at || '-'); setText(els.temp, Number(data.system?.temp_c || 0).toFixed(1) + '°C'); setText(els.fanRpm, Number(data.fan?.rpm || 0).toLocaleString() + ' RPM'); setText(els.fanPercent, Number(data.fan?.percent || 0).toFixed(1) + '%'); + setText(els.wifi24, data.wifi?.count24 ?? 0); + setText(els.wifi5, data.wifi?.count5 ?? 0); state.latestFanPwm = Math.max(0, Math.min(255, Number(data.fan?.pwm || 0))); if (!isFanEditing()) { @@ -1064,6 +1190,126 @@ return `${escapeHtml(v)}`; } + function attr(v) { + return escapeHtml(v).replaceAll("'", '''); + } + + function wifiValueTitle(row, key) { + const source = row?.[`${key}_source`]; + if (!source || source === 'iw' || source === 'lease') return ''; + if (source.startsWith('estimated_')) return ''; + const labels = { + alias: 'DB 저장 이름', + generated: 'MAC 기반 임시 이름', + }; + return labels[source] || source; + } + + function wifiHostCell(row) { + const title = wifiValueTitle(row, 'hostname') || t('wifiAliasPrompt'); + return ``; + } + + function wifiMetricCell(row, key) { + if (key !== 'signal') { + return `${escapeHtml(row[key] || '-')}`; + } + + const dbm = wifiSignalDbm(row[key]); + return `${signalBadgeHtml(dbm, row[key] || '', `client:${row.mac || row.ip || row.hostname || ''}`)}`; + } + + function wifiSignalDbm(value) { + const match = String(value ?? '').match(/-?\d+(?:\.\d+)?/); + return match ? Number(match[0]) : null; + } + + function signalQuality(dbm) { + if (dbm === null || !Number.isFinite(dbm)) return 'unknown'; + if (dbm >= -45) return { className: 'best', label: '최상' }; + if (dbm >= -55) return { className: 'excellent', label: '매우 좋음' }; + if (dbm >= -65) return { className: 'good', label: '좋음' }; + if (dbm >= -72) return { className: 'fine', label: '양호' }; + if (dbm >= -80) return { className: 'normal', label: '보통' }; + if (dbm >= -88) return { className: 'weak', label: '약함' }; + return { className: 'bad', label: '매우 약함' }; + } + + function signalDbmText(dbm, raw = '') { + if (dbm !== null && Number.isFinite(dbm)) { + return `${Number(dbm).toFixed(Number.isInteger(dbm) ? 0 : 1)} dBm`; + } + return String(raw || '').trim() || '-'; + } + + function signalBadgeHtml(dbm, raw = '', key = '') { + const quality = signalQuality(dbm); + if (typeof quality === 'string') { + return `-`; + } + + const keyAttr = key ? ` data-signal-key="${attr(key)}"` : ''; + return `${escapeHtml(quality.label)}`; + } + + async function editWifiHostname(mac, currentName) { + if (!mac) return; + const value = await window.customPrompt(`${t('wifiAliasPrompt')}\n${mac}`, { + title: t('wifiAliasTitle'), + defaultValue: currentName || '', + okText: t('dialogOk'), + }); + if (value === null) return; + + const hostname = String(value || '').trim(); + if (!hostname) return; + + try { + const data = await api('wifi_alias', { mac, hostname }); + notice(t('wifiAliasSaved'), 'success'); + render(data.status || await api('status')); + } catch (e) { + console.error(e); + notice(e.message || 'WiFi alias failed', 'error'); + } + } + + function parseWifiDurationSeconds(value) { + const text = String(value ?? '').trim(); + if (text === '' || text.toUpperCase() === 'N/A') { + return null; + } + + if (/^\d+(?:\.\d+)?$/.test(text)) { + return Number(text); + } + + let total = 0; + let matched = false; + const pattern = /(\d+(?:\.\d+)?)\s*(days?|d|hours?|hrs?|h|minutes?|mins?|min|m|seconds?|secs?|sec|s|milliseconds?|msecs?|msec|ms)\b/gi; + let match; + + while ((match = pattern.exec(text)) !== null) { + const amount = Number(match[1]); + const unit = match[2].toLowerCase(); + matched = true; + + if (unit === 'd' || unit.startsWith('day')) { + total += amount * 86400; + } else if (unit === 'h' || unit.startsWith('hour') || unit.startsWith('hr')) { + total += amount * 3600; + } else if (unit === 'm' || unit.startsWith('min')) { + total += amount * 60; + } else if (unit === 'ms' || unit.startsWith('msec') || unit.startsWith('millisecond')) { + total += amount / 1000; + } else { + total += amount; + } + } + + return matched ? total : null; + } + function formatDhms(seconds) { if (seconds === null || seconds === undefined || !Number.isFinite(Number(seconds))) { return null; @@ -1091,6 +1337,10 @@ return parts.join(' '); } + function wifiConnectedTime(value) { + return formatDhms(parseWifiDurationSeconds(value)) || value; + } + function eventStateText(eventState, activeKey, normalKey, recoveringKey, repeatedKey, seenKey, notSeenKey) { if (!eventState || eventState.available === false) { return t('na'); @@ -1145,6 +1395,31 @@ return formatted ? `${formatted} ${t('ago')}` : '-'; } + function renderWifi(data) { + const rows = data.wifi?.clients || []; + if (!els.wifiTable) return; + const tableWrap = els.wifiTable.closest('.table-wrap'); + const scrollLeft = tableWrap ? tableWrap.scrollLeft : 0; + els.wifiTable.innerHTML = rows.length + ? rows.map(row => ` + + ${td(row.band)} + ${wifiHostCell(row)} + ${td(row.ip)} + ${td(row.mac)} + ${wifiMetricCell(row, 'signal')} + ${wifiMetricCell(row, 'tx_bitrate')} + ${wifiMetricCell(row, 'rx_bitrate')} + ${td(wifiConnectedTime(row.connected_time))} + ${td(row.inactive_time)} + + `).join('') + : `${escapeHtml(t('noWifiClients'))}`; + if (tableWrap) { + tableWrap.scrollLeft = scrollLeft; + } + } + function renderSystemStatus(data) { const system = data.system || {}; const disk = system.disk || {}; @@ -1712,7 +1987,9 @@ } renderTop(data); renderSystemStatus(data); + renderWifi(data); renderCharts(data); + updateSignalTooltip(); state.fanCauseTick = (state.fanCauseTick + 1) % 2; @@ -1937,12 +2214,46 @@ els.dmesgToggle?.addEventListener('click', () => { setDmesgOpen(!state.dmesgOpen); }); + els.wifiTable?.addEventListener('click', event => { + const button = event.target?.closest?.('.wifi-host-edit'); + if (!button) return; + editWifiHostname(button.dataset.mac || '', button.dataset.hostname || ''); + }); 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); + document.addEventListener('mouseover', event => { + const target = event.target?.closest?.('.wifi-signal[data-signal-dbm]'); + if (target) showSignalTooltip(target); + }); + document.addEventListener('mouseout', event => { + const target = event.target?.closest?.('.wifi-signal[data-signal-dbm]'); + if (!target) return; + const related = event.relatedTarget; + if (related && target.contains(related)) return; + hideSignalTooltip(); + }); + document.addEventListener('focusin', event => { + const target = event.target?.closest?.('.wifi-signal[data-signal-dbm]'); + if (target) showSignalTooltip(target); + }); + document.addEventListener('focusout', event => { + const target = event.target?.closest?.('.wifi-signal[data-signal-dbm]'); + if (target) hideSignalTooltip(); + }); + document.addEventListener('pointerdown', event => { + const target = event.target?.closest?.('.wifi-signal[data-signal-dbm]'); + if (target) { + showSignalTooltip(target); + return; + } + hideSignalTooltip(); + }); + window.addEventListener('resize', positionSignalTooltip); + window.addEventListener('scroll', positionSignalTooltip, true); [els.secondaryChartDetails, els.processDetails, els.diagnosticDetails].forEach(node => { node?.addEventListener('toggle', resizeChartsSoon); }); diff --git a/public/assets/site.webmanifest b/public/assets/site.webmanifest index 4a9c037..65ab6e3 100644 --- a/public/assets/site.webmanifest +++ b/public/assets/site.webmanifest @@ -1,7 +1,7 @@ { "name": "Seoul Control Center", "short_name": "Seoul Control", - "description": "Fan and system control panel", + "description": "Fan and WiFi control panel", "start_url": "/", "scope": "/", "display": "standalone", diff --git a/public/index.php b/public/index.php index 395cf0c..7e965d9 100644 --- a/public/index.php +++ b/public/index.php @@ -127,7 +127,9 @@ html[data-theme="light"] .btn.secondary{background:#d9e2ef;color:#172033}html[da .select:focus,.slider:focus{outline:2px solid rgba(59,130,246,.65);outline-offset:2px} .btn:disabled{opacity:.55;cursor:not-allowed} .status-list{display:grid;gap:10px}.status-row{display:grid;grid-template-columns:112px minmax(0,1fr);gap:12px;align-items:baseline;padding:10px 12px;background:var(--card2);border-radius:14px}.status-key{color:var(--sub);font-size:13px}.status-value{overflow:visible;white-space:normal;word-break:break-word;font-variant-numeric:tabular-nums} - .table-wrap{max-width:100%;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;border-radius:16px;border:1px solid var(--line)}.table-wrap.compact{border-radius:12px} +.table-wrap{max-width:100%;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;border-radius:16px;border:1px solid var(--line)}.table-wrap.compact{border-radius:12px}.wifi-table{width:100%;min-width:1080px;table-layout:fixed;border-collapse:collapse}.wifi-table th,.wifi-table td{padding:11px 13px;border-bottom:1px solid rgba(128,145,170,.18);text-align:left;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.wifi-table th{position:sticky;top:0;background:var(--table-head);z-index:1;font-weight:500}.wifi-table tr:hover{background:rgba(128,145,170,.09)}.wifi-table .col-band{width:72px}.wifi-table .col-host{width:240px}.wifi-table .col-ip{width:132px}.wifi-table .col-mac{width:170px}.wifi-table .col-signal{width:100px}.wifi-table .col-rate{width:120px}.wifi-table .col-time{width:130px} +.wifi-host-edit{appearance:none;border:0;background:transparent;color:inherit;font:inherit;max-width:100%;padding:0;margin:0;cursor:pointer;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-decoration:underline;text-decoration-style:dotted;text-underline-offset:3px}.wifi-host-edit:hover{color:var(--blue)} +.wifi-signal{font-weight:700;font-variant-numeric:tabular-nums;cursor:help;text-decoration:underline;text-decoration-style:dotted;text-decoration-thickness:1px;text-underline-offset:3px}.wifi-signal.best{color:#16a34a}.wifi-signal.excellent{color:#22c55e}.wifi-signal.good{color:#38bdf8}.wifi-signal.fine{color:#0ea5e9}.wifi-signal.normal{color:#f59e0b}.wifi-signal.weak{color:#fb7185}.wifi-signal.bad{color:#ef4444}.wifi-signal.unknown{color:var(--sub);text-decoration:none;cursor:default} .chart-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}.chart-box{height:280px;background:var(--input);border:1px solid rgba(84,101,128,.58);border-radius:16px;padding:12px 12px 18px}.chart-box h3{margin:0 0 8px;color:var(--sub);font-size:13px}.chart-canvas{position:relative;height:calc(100% - 27px);min-height:0}.chart-canvas canvas{width:100%!important;height:100%!important}.chart-secondary{margin-top:14px} .details-panel{border:1px solid rgba(84,101,128,.52);border-radius:16px;background:rgba(128,145,170,.06);padding:0;margin-top:14px;overflow:hidden}.details-panel>summary{cursor:pointer;list-style:none;display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:48px;padding:0 14px;color:var(--text);font-weight:500}.details-panel>summary::-webkit-details-marker{display:none}.details-panel>summary::after{content:"+";display:grid;place-items:center;width:24px;height:24px;border:1px solid var(--line);border-radius:999px;color:var(--sub);font-weight:700}.details-panel[open]>summary::after{content:"-"}.details-body{padding:0 14px 14px}.section-note{margin:0;color:var(--sub);font-size:12px} .resource-card{margin-top:18px}.resource-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:12px}.resource-head h2{margin:0}.baseline-pill{color:var(--sub);font-size:12px;font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap}.spike-log-box{margin-top:16px}.spike-log-box h3{margin:0 0 9px;color:var(--text);font-size:14px;font-weight:500}.spike-log-list{display:grid;gap:8px;max-height:260px;overflow:auto;padding-right:4px}.spike-log-list.compact{max-height:180px}.spike-log-item{display:grid;gap:4px;padding:10px 12px;border:1px solid var(--notice-border);background:var(--notice-bg);border-radius:12px}.spike-log-item.latest{border:1px solid var(--notice-border-strong);background:var(--notice-bg-strong);box-shadow:0 0 0 1px var(--notice-border) inset;}.spike-log-item.alert{border-color:rgba(255,95,87,.58);background:rgba(150,38,38,.18)}.spike-log-item.alert strong{color:#ffb4ad}.spike-log-item strong{font-size:13px;font-weight:500;color:var(--notice-title)}.spike-log-item span{font-size:12px;color:var(--notice-copy);font-variant-numeric:tabular-nums}.spike-log-empty{padding:10px 12px;border-radius:12px;background:var(--card2);color:var(--sub);font-size:13px}.service-list{display:grid;gap:10px;max-height:520px;overflow:auto;padding-right:4px}.service-item{border:1px solid var(--line);border-radius:12px;background:var(--card2);padding:12px;display:grid;gap:9px}.service-head{display:flex;justify-content:space-between;gap:10px;align-items:flex-start}.service-title{display:grid;gap:3px;min-width:0}.service-name{font-size:14px;color:var(--text);font-weight:600;word-break:break-word}.service-desc,.service-meta{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums;word-break:break-word}.service-state{border:1px solid var(--line);border-radius:999px;padding:4px 9px;font-size:12px;color:var(--sub);white-space:nowrap}.service-state.active{color:#bbf7d0;border-color:rgba(34,197,94,.35);background:rgba(22,163,74,.14)}.service-state.failed{color:#fecaca;border-color:rgba(239,68,68,.4);background:rgba(185,28,28,.16)}.service-log{margin:0;max-height:160px;overflow:auto;border:1px solid var(--line);border-radius:10px;background:var(--code-bg);color:var(--mono-text);padding:10px;font:11px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre-wrap;word-break:break-word}.dmesg-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.dmesg-meta{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums}.dmesg-log{margin:10px 0 0;max-height:420px;overflow:auto;border:1px solid var(--line);border-radius:12px;background:var(--code-bg);color:var(--mono-text);padding:12px;font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre-wrap;word-break:break-word}.dmesg-log[hidden]{display:none} @@ -138,7 +140,7 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da} .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} .settings-layer{align-items:stretch}.settings-box{width:min(980px,100%);max-height:calc(100vh - 44px);display:grid;grid-template-rows:auto minmax(0,1fr) auto;padding:0;overflow:hidden}.settings-head{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;padding:18px 20px;border-bottom:1px solid var(--line)}.settings-sub{margin:0;color:var(--sub);font-size:13px}.settings-body{overflow:auto;padding:16px 20px;display:grid;gap:14px}.settings-group{border:1px solid var(--line);border-radius:16px;background:var(--card2);padding:14px}.settings-group h4{margin:0 0 12px;font-size:15px}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.settings-field{display:grid;gap:7px;min-width:0}.settings-label{display:flex;align-items:center;justify-content:space-between;gap:8px;color:var(--sub);font-size:13px}.settings-label span:last-child{white-space:nowrap;color:var(--sub)}.settings-control{display:flex;align-items:center;gap:9px}.settings-control input,.settings-select{width:100%;height:42px;border-radius:12px;border:1px solid var(--line);background:var(--input);color:var(--text);padding:0 12px;outline:none}.settings-control input:focus,.settings-select:focus{outline:2px solid rgba(59,130,246,.65);outline-offset:2px}.settings-toggle{display:grid;grid-template-columns:1fr 1fr;align-items:center;width:100%;height:42px;border:1px solid var(--line);border-radius:12px;background:var(--input);padding:4px;cursor:pointer;gap:4px}.settings-toggle input{position:absolute;opacity:0;pointer-events:none}.settings-toggle span{display:grid;place-items:center;min-width:0;height:32px;border-radius:10px;color:var(--sub);font-weight:700;font-size:12px;transition:background .12s ease,color .12s ease}.settings-toggle[data-checked="1"] .settings-toggle-on,.settings-toggle[data-checked="0"] .settings-toggle-off{background:var(--blue);color:#fff}.settings-toggle[data-checked="0"] .settings-toggle-off{background:#64748b}.settings-default{font-size:12px;color:var(--sub);white-space:nowrap}.settings-actions{display:flex;justify-content:flex-end;gap:10px;padding:14px 20px;border-top:1px solid var(--line)} @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}} +@media(max-width:1100px){.layout{grid-template-columns:1fr}.chart-grid,.resource-grid,.wifi-scan-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}} @media(max-width:720px){.settings-grid{grid-template-columns:1fr}.settings-head{align-items:stretch;flex-direction:column}.settings-actions{display:grid;grid-template-columns:1fr 1fr}.settings-box{max-height:calc(100vh - 24px)}} @@ -147,7 +149,7 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da}
+
+

WiFi Status

+
+
2.4G Clients
-
+
5G Clients
-
+
+
+

System Status

@@ -237,6 +247,27 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da}
+
+

WiFi Clients

+
+ + + + + + + + + + + + + + +
BandHostnameIPMACSignalTX RateRX RateConnectedInactive
+
+
+ @@ -304,6 +335,7 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da}
+