From 4f6b8c8ebb744aa35003cffb7b7aae022f8c5f52 Mon Sep 17 00:00:00 2001 From: seo Date: Fri, 26 Jun 2026 18:26:46 +0900 Subject: [PATCH] =?UTF-8?q?Control=20WiFi=20=EA=B4=80=EC=B8=A1=EA=B0=92=20?= =?UTF-8?q?=EC=9C=A0=EC=A7=80=EC=99=80=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20?= =?UTF-8?q?=EB=B3=B4=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 15 +++- config/config.php | 30 +++++++ public/api.php | 186 ++++++++++++++++++++++++++++++++++++++++--- public/assets/app.js | 51 ++++++++++-- public/index.php | 2 +- 5 files changed, 260 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 7138398..32fcffa 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - `system_notice_state`: notice 활성 상태와 기준값 - `system_notice_logs`: notice 발생 이력 - `wifi_observed_sessions`: 5G WiFi client의 최초/마지막 감지 시간 +- `wifi_scan_observations`: 주변 WiFi AP의 BSSID별 마지막 성공 스캔 관측값 - `wifi_client_aliases`: MAC별 수동 WiFi 호스트명 - `app_settings`: 설정 모달에서 저장한 Control 동작값 오버라이드 - `ha_notify_logs`: HA webhook 알림 발송 성공/실패 이력, 대상 서버, HTTP code, tag, 메타 정보 @@ -117,7 +118,7 @@ WebSocket 장기 실행 프로세스에서는 1분 단위 단기 집계 결과 - 팬 자동 제어: 팬 시작 온도, 최대 온도, 즉시 최대 PWM 온도, 자동 상승/하강 PWM step - 보안 정책: 자동 로그인 유지 기간, remember 쿠키 Secure/SameSite, CSRF 길이, remember 토큰 길이, User-Agent/IP 접두사 검증, 로그인 실패 잠금, 재부팅 허용 여부, 재부팅 확인 문구, 명령 timeout, WiFi 조작 허용 여부 -- WiFi 진단: 주변 WiFi 스캔 켜기/끄기, 스캔 캐시 시간, 명령 timeout, 표시 개수, 원문 일부 표시 여부 +- WiFi 진단: 주변 WiFi 스캔 켜기/끄기, 스캔 캐시 시간, 명령 timeout, 표시 개수, 관측 보관 시간, 원문 일부 표시 여부 - 알림 정책: 배터리 긴급/위험/경고/복구 기준, 배터리 낮음/복구 알림 쿨다운, 시스템 유의사항 알림 쿨다운 - 화면/진단 표시: 프로세스 후보 수, 사용자 서비스 로그 줄 수, 사용자 서비스 캐시 시간, 팬 이상 이력 수, HA 알림 이력 수 - 저전압/스로틀링: 최근 판정창, 복구 중 유지 시간, 반복 발생 기준 횟수 @@ -134,7 +135,7 @@ WebSocket 장기 실행 프로세스에서는 1분 단위 단기 집계 결과 - 탭 복귀: 즉시 1회 상태 갱신 - 잔여 시간 커스텀 팝오버: 상태 갱신과 동일하며 hover/focus 중에도 내용과 위치를 계속 갱신 - dmesg: 진단 패널에서 열었을 때만 1초마다 갱신, 닫으면 중지 -- 주변 WiFi 스캔: 상태 snapshot에 포함되지만 파일 캐시를 사용하며 기본 60초마다 실제 `iw dev scan` 명령을 다시 시도 +- 주변 WiFi 스캔: 상태 snapshot에 포함되지만 파일 캐시를 사용하며 기본 60초마다 실제 `iw dev scan` 명령을 다시 시도, 실패 시 DB의 마지막 성공 관측 목록 표시 - WiFi 관측 보정 timer: 10초마다 `bin/wifi_observe.php` 실행 - WebSocket 소스 변경 감지: 15초마다 확인 후 재시작 - 팬 슬라이더 자동 적용 debounce: 약 450ms @@ -194,7 +195,9 @@ control-wifi-observe.service 신호/송신 속도/수신 속도는 실제 `iw station dump` 값이 있으면 그대로 표시합니다. 값이 비어 있으면 2.4G는 일반 802.11n 계열 링크 속도와 dBm 범위를 기준으로, 5G는 Realtek RTL8822BU/802.11ac 계열 동글의 최대 1300Mbps 특성을 참고해 dBm과 링크 속도 사이를 보정합니다. 화면에는 별도 표시 없이 숫자만 보여 N/A 노출을 줄입니다. -주변 WiFi 스캔은 대시보드 snapshot에 포함되지만 기본 60초 파일 캐시를 사용합니다. `iw dev wlan0 scan`, `iw dev wlan1 scan`을 시도하고 SSID, BSSID, 주파수, 채널, 신호, 보안, 마지막 감지, WiFi 세대, 채널 폭, 지원 속도, capability를 표시합니다. AP 모드 또는 드라이버 제한으로 스캔이 실패하면 해당 인터페이스와 실패 사유를 같은 섹션에 표시합니다. +주변 WiFi 스캔은 대시보드 snapshot에 포함되지만 기본 60초 파일 캐시를 사용합니다. `iw dev wlan0 scan`, `iw dev wlan1 scan`을 시도하고 SSID, BSSID, 주파수, 채널, 신호, 보안, 마지막 관측, WiFi 세대, 채널 폭, 지원 속도, capability를 표시합니다. AP 모드 또는 드라이버 제한으로 스캔이 실패하면 해당 인터페이스와 실패 사유를 같은 섹션에 표시합니다. + +스캔에 성공한 AP는 `wifi_scan_observations`에 BSSID 기준으로 저장합니다. 다음 스캔이 실패하거나 특정 대역이 AP 모드라 스캔할 수 없어도 목록을 비우지 않고, 설정된 관측 보관 시간 안의 마지막 성공 관측값을 계속 표시합니다. live 스캔 결과는 `방금`으로 표시하고, DB 관측값은 마지막 관측 시각과 경과 시간을 함께 표시합니다. ## 처리 흐름 @@ -205,7 +208,7 @@ control-wifi-observe.service 5. WiFi client 목록은 `iw station dump`와 dnsmasq lease를 조합하고, 5G 연결 시간이 `N/A`이면 MAC 기준 최초 감지 시간을 DB에 저장해 경과 시간을 계산합니다. 6. WiFi hostname이 비어 있으면 MAC 기반 임시명을 표시하고, 수동 저장한 MAC별 이름은 DB에서 우선 적용합니다. 7. WiFi 신호/송신 속도/수신 속도 값이 비어 있으면 실제로 존재하는 다른 무선 지표와 대역별 기준으로 값을 채웁니다. -8. 주변 WiFi 스캔은 캐시 주기를 확인한 뒤 `iw dev scan`을 시도하고, 성공 결과 또는 실패 사유를 client 표 아래에 표시합니다. +8. 주변 WiFi 스캔은 캐시 주기를 확인한 뒤 `iw dev scan`을 시도하고, 성공 결과를 DB에 저장한 뒤 live 값과 마지막 관측값을 client 표 아래에 표시합니다. 9. `control-wifi-observe.timer`는 사용자 접속과 무관하게 10초마다 5G 관측 세션을 갱신합니다. 10. WakeLock 버튼은 활성 상태를 초록색 버튼으로 표시합니다. 11. Reboot 버튼은 설정된 확인 문구와 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다. @@ -231,6 +234,7 @@ control-wifi-observe.service - `wifi_client_aliases()`, `save_wifi_client_alias()`: MAC별 WiFi 호스트명 조회와 저장 - `apply_wifi_estimates()`: WiFi 신호/송신 속도/수신 속도 결측값 보정 - `wifi_scan_data()`, `parse_wifi_scan_networks()`: 주변 WiFi AP 스캔, 실패 사유 정리, 파일 캐시 구성 +- `save_wifi_scan_observations()`, `wifi_scan_observed_networks()`, `merge_wifi_scan_networks()`: 주변 WiFi 마지막 성공 관측값 저장, 조회, live/DB 결과 병합 - `send_ha_notify()`: HA webhook 알림 발송, 서울 우선/목포 fallback, 성공/실패 로그 저장 - `ha_notify_due()`: 동일 tag 기준 쿨다운 판정 - `clear_ha_notification()`: 같은 tag의 Android 알림 제거 명령 발송 @@ -276,6 +280,7 @@ control-wifi-observe.service - 센서 수집 주기와 DB 증가량을 확인합니다. - 배터리팩 변경 시 `/home/seo/secret/control.php`의 `battery` 값을 먼저 갱신합니다. 현재 기준은 `2200mAh x 8 parallel = 17600mAh`, nominal `65.12Wh`이며, SOC 추세가 부족한 경우의 fallback 계산에 사용됩니다. - 잔여 시간이 갑자기 흔들리면 커스텀 팝오버의 `계산 출처`, `반영 구간`, `시간당 SOC 감소`, `추세 신뢰도`, `최근 CPU 전력`을 먼저 확인합니다. +- WiFi client 표와 주변 WiFi 스캔 표는 갱신 중에도 가로 스크롤 위치를 보존합니다. - 하드웨어 또는 OS 변경 후 fan sysfs 경로를 확인합니다. - 2.4G 내장 WiFi는 `hostapd-24g.service` 개별 restart 직후 일부 IoT 단말이 WPA/EAPOL 재협상 루프에 들어갈 수 있으므로, 안정화된 상태에서는 개별 restart를 피하고 필요 시 전체 reboot 또는 채널 변경으로 재초기화합니다. - 5G WiFi 연결 시간은 외부 모듈이 값을 제공하지 않을 때 서버가 처음 감지한 시각 기준으로 계산합니다. 현재 5G 목록에서 MAC이 사라지면 관측 세션을 즉시 종료하므로 재연결 시 0부터 다시 누적됩니다. @@ -288,3 +293,5 @@ control-wifi-observe.service - Reboot API 사용 전 웹 서버 실행 계정의 sudoers에 `/usr/sbin/reboot` 비밀번호 없는 실행 권한이 제한적으로 설정되어 있는지 확인합니다. - 저전압/스로틀링이 `N/A`로 보이면 웹 서버 실행 계정의 `/dev/vcio` 접근 권한과 `/usr/bin/vcgencmd get_throttled` sudoers 허용 여부를 확인합니다. - 언어/테마 표시가 예상과 다르면 브라우저 localStorage의 `controlLang`, `controlTheme` 값을 확인합니다. +- `/mnt/synology-web`는 원격 Synology NFS 마운트이며 `sec=sys` 숫자 UID/GID 매핑을 사용합니다. 로컬 `seo`의 기본 UID/GID만으로는 `/mnt/synology-web/log`, `/mnt/synology-web/report`, `/mnt/synology-web/config`에 쓸 수 없고, `synologyweb` primary group으로 실행해야 합니다. `/home/seo/log.sh`는 시작 시 `sg synologyweb`으로 재실행하도록 구성합니다. +- `/mnt/synology-docker/homeassistant/config`는 현재 일반 `seo`와 `synologyweb` primary group 모두 쓰기 가능합니다. diff --git a/config/config.php b/config/config.php index cdefcd2..6ffa8d8 100644 --- a/config/config.php +++ b/config/config.php @@ -157,6 +157,7 @@ function setting_definitions(): array 'wifi.scan_cache_seconds' => ['group' => 'wifi', 'label' => '주변 WiFi 스캔 캐시', 'type' => 'number', 'default' => 60, 'min' => 10, 'max' => 600, 'step' => 5, 'unit' => '초'], 'wifi.scan_timeout_seconds' => ['group' => 'wifi', 'label' => '주변 WiFi 스캔 timeout', 'type' => 'number', 'default' => 8, 'min' => 3, 'max' => 30, 'step' => 1, 'unit' => '초'], 'wifi.scan_max_networks' => ['group' => 'wifi', 'label' => '주변 WiFi 표시 개수', 'type' => 'number', 'default' => 80, 'min' => 10, 'max' => 300, 'step' => 10, 'unit' => '개'], + 'wifi.scan_observed_hours' => ['group' => 'wifi', 'label' => '주변 WiFi 관측 보관', 'type' => 'number', 'default' => 72, 'min' => 1, 'max' => 720, 'step' => 1, 'unit' => '시간'], 'wifi.scan_include_raw' => ['group' => 'wifi', 'label' => '주변 WiFi 원문 일부 표시', 'type' => 'boolean', 'default' => 0], 'fan.auto_min_temp' => ['group' => 'fan', 'label' => '팬 시작 온도', 'type' => 'number', 'default' => 50, 'min' => 30, 'max' => 90, 'step' => 0.5, 'unit' => 'C'], @@ -530,6 +531,35 @@ function bootstrap_db(): void COLLATE=utf8mb4_unicode_ci "); + $pdo->exec(" + CREATE TABLE IF NOT EXISTS wifi_scan_observations ( + bssid VARCHAR(17) NOT NULL PRIMARY KEY, + iface VARCHAR(32) NOT NULL, + band VARCHAR(16) NOT NULL, + ssid VARCHAR(255) NULL, + freq INT NULL, + channel INT NULL, + signal_dbm DECIMAL(6,2) NULL, + signal_text VARCHAR(32) NULL, + security_text VARCHAR(255) NULL, + capability_text VARCHAR(255) NULL, + rates_text VARCHAR(255) NULL, + channel_width VARCHAR(64) NULL, + ht TINYINT(1) NOT NULL DEFAULT 0, + vht TINYINT(1) NOT NULL DEFAULT 0, + he TINYINT(1) NOT NULL DEFAULT 0, + first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_last_seen_at (last_seen_at), + INDEX idx_band (band), + INDEX idx_iface (iface) + ) 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 4a47b43..45cf8f0 100644 --- a/public/api.php +++ b/public/api.php @@ -2860,6 +2860,171 @@ function wifi_scan_failure_reason(int $code, string $out): string return '스캔 결과가 비어 있습니다. AP 모드, 드라이버 제한, 권한 문제 중 하나일 수 있습니다.'; } +function save_wifi_scan_observations(array $networks): void +{ + if ($networks === []) { + return; + } + + try { + $stmt = db()->prepare(" + INSERT INTO wifi_scan_observations + (bssid, iface, band, ssid, freq, channel, signal_dbm, signal_text, security_text, capability_text, rates_text, channel_width, ht, vht, he, first_seen_at, last_seen_at) + VALUES + (:bssid, :iface, :band, :ssid, :freq, :channel, :signal_dbm, :signal_text, :security_text, :capability_text, :rates_text, :channel_width, :ht, :vht, :he, NOW(), NOW()) + ON DUPLICATE KEY UPDATE + iface = VALUES(iface), + band = VALUES(band), + ssid = VALUES(ssid), + freq = VALUES(freq), + channel = VALUES(channel), + signal_dbm = VALUES(signal_dbm), + signal_text = VALUES(signal_text), + security_text = VALUES(security_text), + capability_text = VALUES(capability_text), + rates_text = VALUES(rates_text), + channel_width = VALUES(channel_width), + ht = VALUES(ht), + vht = VALUES(vht), + he = VALUES(he), + last_seen_at = NOW() + "); + + foreach ($networks as $network) { + $bssid = strtolower((string)($network['bssid'] ?? '')); + if (!wifi_valid_mac($bssid)) { + continue; + } + + $stmt->execute([ + ':bssid' => $bssid, + ':iface' => mb_substr((string)($network['iface'] ?? ''), 0, 32), + ':band' => mb_substr((string)($network['band'] ?? ''), 0, 16), + ':ssid' => mb_substr((string)($network['ssid'] ?? ''), 0, 255), + ':freq' => is_numeric($network['freq'] ?? null) ? (int)$network['freq'] : null, + ':channel' => is_numeric($network['channel'] ?? null) ? (int)$network['channel'] : null, + ':signal_dbm' => is_numeric($network['signal_dbm'] ?? null) ? (float)$network['signal_dbm'] : null, + ':signal_text' => mb_substr((string)($network['signal'] ?? ''), 0, 32), + ':security_text' => mb_substr((string)($network['security'] ?? ''), 0, 255), + ':capability_text' => mb_substr((string)($network['capability'] ?? ''), 0, 255), + ':rates_text' => mb_substr((string)($network['rates'] ?? ''), 0, 255), + ':channel_width' => mb_substr((string)($network['channel_width'] ?? ''), 0, 64), + ':ht' => !empty($network['ht']) ? 1 : 0, + ':vht' => !empty($network['vht']) ? 1 : 0, + ':he' => !empty($network['he']) ? 1 : 0, + ]); + } + } catch (Throwable) { + return; + } +} + +function wifi_scan_observed_networks(int $hours, int $limit): array +{ + try { + $hours = max(1, min(720, $hours)); + $limit = max(1, min(500, $limit)); + $stmt = db()->query(" + SELECT + bssid, + iface, + band, + ssid, + freq, + channel, + signal_dbm, + signal_text, + security_text, + capability_text, + rates_text, + channel_width, + ht, + vht, + he, + first_seen_at, + last_seen_at, + TIMESTAMPDIFF(SECOND, last_seen_at, NOW()) AS observed_age_seconds + FROM wifi_scan_observations + WHERE last_seen_at >= DATE_SUB(NOW(), INTERVAL {$hours} HOUR) + ORDER BY + FIELD(band, '2.4G', '5G', '6G', '기타'), + signal_dbm DESC, + last_seen_at DESC + LIMIT {$limit} + "); + } catch (Throwable) { + return []; + } + + $rows = []; + foreach ($stmt->fetchAll() as $row) { + $signalDbm = is_numeric($row['signal_dbm'] ?? null) ? (float)$row['signal_dbm'] : null; + $rows[] = [ + 'bssid' => (string)($row['bssid'] ?? ''), + 'iface' => (string)($row['iface'] ?? ''), + 'band' => (string)($row['band'] ?? ''), + 'ssid' => (string)($row['ssid'] ?? ''), + 'freq' => is_numeric($row['freq'] ?? null) ? (int)$row['freq'] : null, + 'channel' => is_numeric($row['channel'] ?? null) ? (int)$row['channel'] : null, + 'signal_dbm' => $signalDbm, + 'signal' => (string)($row['signal_text'] ?? ($signalDbm === null ? 'N/A' : number_format($signalDbm, 1) . ' dBm')), + 'security' => (string)($row['security_text'] ?? ''), + 'capability' => (string)($row['capability_text'] ?? ''), + 'rates' => (string)($row['rates_text'] ?? ''), + 'channel_width' => (string)($row['channel_width'] ?? ''), + 'ht' => !empty($row['ht']), + 'vht' => !empty($row['vht']), + 'he' => !empty($row['he']), + 'first_seen_at' => (string)($row['first_seen_at'] ?? ''), + 'last_seen_at' => (string)($row['last_seen_at'] ?? ''), + 'observed_age_seconds' => is_numeric($row['observed_age_seconds'] ?? null) ? (int)$row['observed_age_seconds'] : null, + 'observed_source' => 'db', + ]; + } + + return $rows; +} + +function merge_wifi_scan_networks(array $freshNetworks, array $observedNetworks): array +{ + $merged = []; + + foreach ($observedNetworks as $network) { + $bssid = strtolower((string)($network['bssid'] ?? '')); + if ($bssid !== '') { + $merged[$bssid] = $network; + } + } + + foreach ($freshNetworks as $network) { + $bssid = strtolower((string)($network['bssid'] ?? '')); + if ($bssid === '') { + continue; + } + $network['observed_source'] = 'live'; + $network['last_seen_at'] = date('Y-m-d H:i:s'); + $network['observed_age_seconds'] = 0; + $merged[$bssid] = $network; + } + + $rows = array_values($merged); + usort($rows, static function (array $a, array $b): int { + $bandOrder = ['2.4G' => 0, '5G' => 1, '6G' => 2, '기타' => 3]; + $bandCompare = ($bandOrder[$a['band'] ?? '기타'] ?? 9) <=> ($bandOrder[$b['band'] ?? '기타'] ?? 9); + if ($bandCompare !== 0) { + return $bandCompare; + } + $sourceCompare = (($a['observed_source'] ?? '') === 'live' ? 0 : 1) <=> (($b['observed_source'] ?? '') === 'live' ? 0 : 1); + if ($sourceCompare !== 0) { + return $sourceCompare; + } + + return (float)($b['signal_dbm'] ?? -999) <=> (float)($a['signal_dbm'] ?? -999); + }); + + return $rows; +} + function wifi_scan_data(): array { if (!(bool)setting_value('wifi.scan_enabled')) { @@ -2868,7 +3033,7 @@ function wifi_scan_data(): array 'cached' => false, 'generated_at' => null, 'interfaces' => [], - 'networks' => [], + 'networks' => wifi_scan_observed_networks((int)setting_value('wifi.scan_observed_hours'), (int)setting_value('wifi.scan_max_networks')), 'errors' => [], ]; } @@ -2908,24 +3073,19 @@ function wifi_scan_data(): array } } - usort($networks, static function (array $a, array $b): int { - $bandOrder = ['2.4G' => 0, '5G' => 1, '6G' => 2, '기타' => 3]; - $bandCompare = ($bandOrder[$a['band'] ?? '기타'] ?? 9) <=> ($bandOrder[$b['band'] ?? '기타'] ?? 9); - if ($bandCompare !== 0) { - return $bandCompare; - } - - return (float)($b['signal_dbm'] ?? -999) <=> (float)($a['signal_dbm'] ?? -999); - }); - $max = (int)setting_value('wifi.scan_max_networks'); + save_wifi_scan_observations($networks); + $observedNetworks = wifi_scan_observed_networks((int)setting_value('wifi.scan_observed_hours'), $max); + $mergedNetworks = merge_wifi_scan_networks($networks, $observedNetworks); $payload = [ 'enabled' => true, 'cached' => false, 'generated_at' => date('Y-m-d H:i:s'), 'interfaces' => array_values($interfaces), - 'networks' => array_slice($networks, 0, $max), - 'total_found' => count($networks), + 'networks' => array_slice($mergedNetworks, 0, $max), + 'live_found' => count($networks), + 'total_found' => count($mergedNetworks), + 'observed_hours' => (int)setting_value('wifi.scan_observed_hours'), 'errors' => $errors, ]; diff --git a/public/assets/app.js b/public/assets/app.js index a0d7f72..5407a26 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -151,6 +151,8 @@ wifiScanEmpty: 'No nearby WiFi scan results.', wifiScanCached: 'cached', wifiScanFresh: 'fresh', + wifiScanDisabledWithHistory: 'scan disabled, showing last observed networks', + observed: 'Observed', ssid: 'SSID', channel: 'Channel', security: 'Security', @@ -301,6 +303,8 @@ wifiScanEmpty: '주변 WiFi 스캔 결과가 없습니다.', wifiScanCached: '캐시', wifiScanFresh: '실시간', + wifiScanDisabledWithHistory: '스캔 꺼짐, 마지막 관측 목록 표시', + observed: '관측', ssid: 'SSID', channel: '채널', security: '보안', @@ -1301,6 +1305,8 @@ 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 => ` @@ -1316,28 +1322,40 @@ `).join('') : `${escapeHtml(t('noWifiClients'))}`; + if (tableWrap) { + tableWrap.scrollLeft = scrollLeft; + } renderWifiScan(data.wifi?.scan || null); } function renderWifiScan(scan) { if (!els.wifiScan) return; - if (!scan || scan.enabled === false) { + const previousScroll = {}; + els.wifiScan.querySelectorAll('.wifi-scan-band').forEach(section => { + const band = section.dataset.band || ''; + const wrap = section.querySelector('.table-wrap'); + if (band && wrap) previousScroll[band] = wrap.scrollLeft; + }); + + const networks = Array.isArray(scan?.networks) ? scan.networks : []; + if (!scan || (scan.enabled === false && networks.length === 0)) { els.wifiScan.innerHTML = `
${escapeHtml(t('wifiScanDisabled'))}
`; return; } - const networks = Array.isArray(scan.networks) ? scan.networks : []; const errors = Array.isArray(scan.errors) ? scan.errors : []; - const status = `${scan.cached ? t('wifiScanCached') : t('wifiScanFresh')} · ${escapeHtml(scan.generated_at || '-')}`; + const status = scan.enabled === false + ? `${t('wifiScanDisabledWithHistory')} · ${scan.observed_hours || '-'}h` + : `${scan.cached ? t('wifiScanCached') : t('wifiScanFresh')} · ${escapeHtml(scan.generated_at || '-')}`; const groups = ['2.4G', '5G'].map(band => { const bandRows = networks.filter(row => row.band === band); return ` -
+

${escapeHtml(band)} ${bandRows.length}${scan.total_found ? ` / ${scan.total_found}` : ''}

- + ${bandRows.length ? bandRows.map(row => { const dbm = wifiSignalDbm(row.signal); @@ -1347,7 +1365,7 @@ - + @@ -1373,6 +1391,27 @@ ${errorHtml}
${groups}
`; + requestAnimationFrame(() => { + els.wifiScan.querySelectorAll('.wifi-scan-band').forEach(section => { + const band = section.dataset.band || ''; + const wrap = section.querySelector('.table-wrap'); + if (band && wrap && previousScroll[band] !== undefined) { + wrap.scrollLeft = previousScroll[band]; + } + }); + }); + } + + function wifiObservedText(row) { + if (row?.observed_source === 'live') { + return row.last_seen_ms === null || row.last_seen_ms === undefined + ? '방금' + : `방금 (${row.last_seen_ms}ms)`; + } + if (row?.observed_age_seconds !== null && row?.observed_age_seconds !== undefined) { + return `${timeAgo(row.observed_age_seconds)} · ${row.last_seen_at || '-'}`; + } + return row?.last_seen_at || '-'; } function wifiStandardText(row) { diff --git a/public/index.php b/public/index.php index 0b5cf40..a83c1f1 100644 --- a/public/index.php +++ b/public/index.php @@ -373,7 +373,7 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da} - +
${escapeHtml(t('ssid'))}BSSID${escapeHtml(t('channel'))}${escapeHtml(t('signal'))}${escapeHtml(t('security'))}${escapeHtml(t('lastSeen'))}WiFi속도기능
${escapeHtml(t('ssid'))}BSSID${escapeHtml(t('channel'))}${escapeHtml(t('signal'))}${escapeHtml(t('security'))}${escapeHtml(t('observed'))}WiFi속도기능
${escapeHtml(row.channel ?? '-')} ${row.freq ? `(${escapeHtml(row.freq)}MHz)` : ''} ${escapeHtml(row.signal || '-')} ${escapeHtml(row.security || '-')}${row.last_seen_ms === null || row.last_seen_ms === undefined ? '-' : `${escapeHtml(row.last_seen_ms)}ms`}${escapeHtml(wifiObservedText(row))} ${escapeHtml(wifiStandardText(row))} ${escapeHtml(row.channel_width || '-')} ${escapeHtml(row.rates || '-')}