From 4f102efc4b68eac6c2d565bfda15b351b61dc0e3 Mon Sep 17 00:00:00 2001 From: seo Date: Fri, 26 Jun 2026 02:36:30 +0900 Subject: [PATCH] =?UTF-8?q?WiFi=20=ED=91=9C=EC=8B=9C=EA=B0=92=EA=B3=BC=20?= =?UTF-8?q?=EC=9E=94=EC=97=AC=20=EC=8B=9C=EA=B0=84=20=EC=84=A4=EB=AA=85=20?= =?UTF-8?q?=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 44 ++++++++-- config/config.php | 14 ++++ public/api.php | 192 +++++++++++++++++++++++++++++++++++++++++-- public/assets/app.js | 85 ++++++++++++++++++- public/index.php | 1 + 5 files changed, 316 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index ec75b2b..1e8b109 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - 라즈베리파이 저전압/스로틀링 현재 상태, 복구 중/반복 발생 판정, 부팅 후 이력, 최근 감지 시각, 지속시간, 최근 10분 통계 표시 - WiFi client 목록과 2.4G/5G client 수 표시 - 5G 외부 WiFi 모듈이 연결 시간을 제공하지 않는 경우 서버 관측 기반 연결 시간 보정 +- WiFi 호스트명이 비어 있으면 MAC 기반 임시명을 표시하고, MAC별 수동 호스트명을 DB에 저장해 이후에도 유지 +- WiFi 신호/송신 속도/수신 속도가 비어 있으면 대역, dBm, 송수신 속도, 5G RTL8822BU 특성을 기준으로 추정값 표시 - System Notice 최근 이력 표시 - process CPU/MEM 후보는 프로세스 상세 접힘 영역에서 표시 - `/etc/systemd/system/*.service` 기준 사용자 생성 서비스의 서비스명, 현재 상태, enable 상태, PID, restart 횟수, 최근 journal 로그를 진단 접힘 영역에서 표시 @@ -39,6 +41,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - `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=dmesg`: dmesg 로그 조회 - `public/api.php?action=reboot`: 확인 단어와 관리자 암호 재검증 후 sudo reboot 실행 @@ -64,6 +67,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - `system_notice_state`: notice 활성 상태와 기준값 - `system_notice_logs`: notice 발생 이력 - `wifi_observed_sessions`: 5G WiFi client의 최초/마지막 감지 시간 +- `wifi_client_aliases`: MAC별 수동 WiFi 호스트명 - `ha_notify_logs`: HA webhook 알림 발송 성공/실패 이력, 대상 서버, HTTP code, tag, 메타 정보 - `/home/seo/secret/control.php`: 앱 비밀번호, DB 설정, 배터리 설정, HA 알림 설정 - 배터리 용량 설정은 `/home/seo/secret/control.php`의 `battery.cell_capacity_mah`, `battery.parallel_cells`, `battery.nominal_voltage`, `battery.capacity_wh`를 사용합니다. @@ -86,6 +90,20 @@ WebSocket 장기 실행 프로세스에서는 1분 단위 단기 집계 결과 대시보드의 잔여 시간 값에는 Control UI 스타일의 커스텀 계산 근거 팝오버가 붙습니다. 기본 브라우저 `title` tooltip은 사용하지 않으며, 마우스 hover 또는 키보드 focus 중에도 상태 갱신이 들어오면 팝오버 내용과 위치가 즉시 갱신됩니다. `SOC 다중 시간창 추세`로 표시되면 실제 DB 방전 속도 기반이며, 계산 출처에 `장기 학습`이 포함되면 누적 기록에서 학습한 SOC/부하 구간별 방전 경험도 반영된 상태입니다. `배터리 용량과 최근 전력 평균`이면 SOC 추세가 부족해 용량 기반 fallback이 사용된 상태입니다. +팝오버는 계산 잔여 시간, 시간당 SOC 감소율, 추세 신뢰도, 최근 CPU 전력, 계산 출처, 반영 구간을 표시합니다. 각 후보별로 최근 30분/1시간/3시간/6시간/12시간/24시간 감소율, 신뢰도, 부하 보정 배율을 표시하고, 장기 학습 후보는 반영된 학습 구간 수와 SOC 구간 수를 함께 표시합니다. + +## 갱신 주기 + +- WebSocket 상태 갱신: 1초마다 +- WebSocket 최초 연결: 즉시 1회 상태 전송 +- WebSocket 끊김 시 HTTP fallback 상태 갱신: 2초마다 +- 탭 복귀: 즉시 1회 상태 갱신 +- 잔여 시간 커스텀 팝오버: 상태 갱신과 동일하며 hover/focus 중에도 내용과 위치를 계속 갱신 +- dmesg: 진단 패널에서 열었을 때만 1초마다 갱신, 닫으면 중지 +- WiFi 관측 보정 timer: 10초마다 `bin/wifi_observe.php` 실행 +- WebSocket 소스 변경 감지: 15초마다 확인 후 재시작 +- 팬 슬라이더 자동 적용 debounce: 약 450ms + ## HA 알림 구조 Control은 서버에서 Home Assistant webhook으로 알림 payload를 보내고, HA 자동화가 `notify.mobile_app_seocaegeonyi_z_fold7` 서비스로 Android 알림을 전달합니다. @@ -126,6 +144,10 @@ control-wifi-observe.service 이 작업은 팬 정책, 차트 로그 저장, HA 알림 발송과 분리되어 있으며 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과 링크 속도 사이를 추정합니다. + ## 처리 흐름 1. 화면 진입 시 로그인 세션과 remember token을 확인합니다. @@ -133,15 +155,17 @@ control-wifi-observe.service 3. WebSocket 연결이 성공하면 `status` 메시지로 갱신하고, 실패하면 HTTP fallback을 사용합니다. 4. 팬 조작은 상태 저장, 정책 적용, 로그 저장 순서로 처리합니다. 5. WiFi client 목록은 `iw station dump`와 dnsmasq lease를 조합하고, 5G 연결 시간이 `N/A`이면 MAC 기준 최초 감지 시간을 DB에 저장해 경과 시간을 계산합니다. -6. `control-wifi-observe.timer`는 사용자 접속과 무관하게 10초마다 5G 관측 세션을 갱신합니다. -7. WakeLock 버튼은 활성 상태를 초록색 버튼으로 표시합니다. -8. Reboot 버튼은 `재부팅` 단어 입력과 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다. -9. 시스템 상태는 `vcgencmd get_throttled`를 읽어 현재 저전압/스로틀링 여부와 부팅 후 이력, 최근 감지 시각, 현재/최근 지속시간을 표시합니다. -10. 저전압/스로틀링 상태 파일에는 최근 episode 시작/종료를 보관하고, 최근 10분 발생 횟수, 감지 누적시간, 감지 비율을 계산합니다. -11. 배터리 잔여 시간은 최근 24시간 SOC 방전 추세와 최대 45일 장기 학습 프로파일을 함께 평가하고, 현재 CPU 전력 변화로 보정합니다. -12. 배터리 SOC 또는 System Notice 조건이 맞으면 Control이 HA webhook으로 알림을 발송하고 `ha_notify_logs`에 결과를 저장합니다. -13. 대시보드는 최근 HA 알림 성공/실패 이력을 `ha_notify_logs`에서 읽어 진단 접힘 영역에 표시합니다. -14. 대시보드 첫 화면에는 팬/전원/스로틀링/WiFi/핵심 차트를 우선 배치하고, 상세 차트, 프로세스 후보, 사용자 서비스, HA 알림 이력, dmesg는 접힘 영역으로 분리합니다. +6. WiFi hostname이 비어 있으면 MAC 기반 임시명을 표시하고, 수동 저장한 MAC별 이름은 DB에서 우선 적용합니다. +7. WiFi 신호/송신 속도/수신 속도 값이 비어 있으면 실제로 존재하는 다른 무선 지표와 대역별 기준으로 추정값을 채웁니다. +8. `control-wifi-observe.timer`는 사용자 접속과 무관하게 10초마다 5G 관측 세션을 갱신합니다. +9. WakeLock 버튼은 활성 상태를 초록색 버튼으로 표시합니다. +10. Reboot 버튼은 `재부팅` 단어 입력과 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다. +11. 시스템 상태는 `vcgencmd get_throttled`를 읽어 현재 저전압/스로틀링 여부와 부팅 후 이력, 최근 감지 시각, 현재/최근 지속시간을 표시합니다. +12. 저전압/스로틀링 상태 파일에는 최근 episode 시작/종료를 보관하고, 최근 10분 발생 횟수, 감지 누적시간, 감지 비율을 계산합니다. +13. 배터리 잔여 시간은 최근 24시간 SOC 방전 추세와 최대 45일 장기 학습 프로파일을 함께 평가하고, 현재 CPU 전력 변화로 보정합니다. +14. 배터리 SOC 또는 System Notice 조건이 맞으면 Control이 HA webhook으로 알림을 발송하고 `ha_notify_logs`에 결과를 저장합니다. +15. 대시보드는 최근 HA 알림 성공/실패 이력을 `ha_notify_logs`에서 읽어 진단 접힘 영역에 표시합니다. +16. 대시보드 첫 화면에는 팬/전원/스로틀링/WiFi/핵심 차트를 우선 배치하고, 상세 차트, 프로세스 후보, 사용자 서비스, HA 알림 이력, dmesg는 접힘 영역으로 분리합니다. ## 주요 함수/모듈 @@ -154,6 +178,8 @@ control-wifi-observe.service - `apply_fan_policy()`: 팬 목표값 계산과 적용 - `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 신호/송신 속도/수신 속도 결측값 추정 - `send_ha_notify()`: HA webhook 알림 발송, 서울 우선/목포 fallback, 성공/실패 로그 저장 - `ha_notify_due()`: 동일 tag 기준 쿨다운 판정 - `clear_ha_notification()`: 같은 tag의 Android 알림 제거 명령 발송 diff --git a/config/config.php b/config/config.php index 0866f20..5b71044 100644 --- a/config/config.php +++ b/config/config.php @@ -171,6 +171,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, diff --git a/public/api.php b/public/api.php index 38755c0..7b85936 100644 --- a/public/api.php +++ b/public/api.php @@ -1982,6 +1982,56 @@ function dnsmasq_leases(): array 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)) { @@ -1991,7 +2041,7 @@ function iw_station_dump(string $iface): string 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 +function parse_live_wifi_rows(string $iface, string $band, string $text, array $leases, array $aliases): array { $rows = []; $cur = null; @@ -2006,16 +2056,24 @@ function parse_live_wifi_rows(string $iface, string $band, string $text, array $ $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' => $lease['hostname'] ?? 'N/A', - 'name' => $lease['hostname'] ?? $mac, + '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, @@ -2031,9 +2089,16 @@ function parse_live_wifi_rows(string $iface, string $band, string $text, array $ continue; } - if (preg_match('/^signal:\s+(.+)/', $t, $m)) $cur['signal'] = $m[1]; - elseif (preg_match('/^tx bitrate:\s+(.+)/', $t, $m)) $cur['tx_bitrate'] = $m[1]; - elseif (preg_match('/^rx bitrate:\s+(.+)/', $t, $m)) $cur['rx_bitrate'] = $m[1]; + 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]; @@ -2059,6 +2124,21 @@ function wifi_time_ms(string $value): int 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)) { @@ -2068,6 +2148,87 @@ function wifi_signal_dbm(string $value): int 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 = []; @@ -2246,16 +2407,18 @@ function apply_observed_wifi_connected_time(array $clients): array 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) + 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 [ @@ -2723,6 +2886,21 @@ function control_api_dispatch(): void ]); } + 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 2acf989..8317187 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -156,6 +156,9 @@ serviceEnabled: 'enabled', servicePid: 'pid', serviceRestarts: 'restarts', + wifiAliasTitle: 'WiFi Hostname', + wifiAliasPrompt: 'Enter a name to keep for this MAC address.', + wifiAliasSaved: 'WiFi hostname saved.', notifySuccess: 'success', notifyFailed: 'failed', show: 'Show', @@ -287,6 +290,9 @@ serviceEnabled: '활성화', servicePid: 'PID', serviceRestarts: '재시작', + wifiAliasTitle: 'WiFi 호스트명', + wifiAliasPrompt: '이 MAC 주소에 계속 표시할 이름을 입력하세요.', + wifiAliasSaved: 'WiFi 호스트명을 저장했습니다.', notifySuccess: '성공', notifyFailed: '실패', show: '보기', @@ -691,6 +697,9 @@ }; const lines = [sourceMap[remaining.source] || remaining.source || '-']; + if (remaining.seconds !== undefined && remaining.seconds !== null) { + lines.push(`계산 잔여: ${formatDurationSeconds(remaining.seconds)}`); + } if (remaining.drop_per_hour !== undefined && remaining.drop_per_hour !== null) { lines.push(`시간당 SOC 감소: ${Number(remaining.drop_per_hour).toFixed(3)}%`); } @@ -716,6 +725,16 @@ } return `${row.minutes}분`; }).join(', ')}`); + lines.push(''); + lines.push('세부 후보'); + remaining.windows.forEach(row => { + if (row.source === 'learned_profile') { + lines.push(`- 장기 학습: ${row.intervals || 0}구간 / SOC대 ${row.soc_buckets || 0}개 / 누적감소 ${Number(row.drop_percent || 0).toFixed(3)}% / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}%`); + } else { + const load = row.load_factor === null || row.load_factor === undefined ? '-' : `${Number(row.load_factor).toFixed(3)}x`; + lines.push(`- 최근 ${row.minutes}분: 감소 ${Number(row.drop_percent || 0).toFixed(3)}% / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}% / 부하보정 ${load}`); + } + }); } return lines.join('\n'); @@ -802,6 +821,59 @@ 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 ''; + const labels = { + alias: 'DB 저장 이름', + generated: 'MAC 기반 임시 이름', + estimated_from_rate: '속도 기반 추정', + estimated_from_signal: '신호 기반 추정', + estimated_from_rx: '수신 속도 기반 추정', + estimated_from_tx: '송신 속도 기반 추정', + estimated_default: '대역 기본 추정', + }; + return labels[source] || source; + } + + function wifiHostCell(row) { + const title = wifiValueTitle(row, 'hostname') || t('wifiAliasPrompt'); + return ``; + } + + function wifiMetricCell(row, key) { + const title = wifiValueTitle(row, key); + const className = title ? ' class="wifi-estimated"' : ''; + const titleAttr = title ? ` title="${attr(title)}"` : ''; + return `${escapeHtml(row[key] || '-')}`; + } + + 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') { @@ -930,12 +1002,12 @@ ? rows.map(row => ` ${td(row.band)} - ${td(row.hostname)} + ${wifiHostCell(row)} ${td(row.ip)} ${td(row.mac)} - ${td(row.signal)} - ${td(row.tx_bitrate)} - ${td(row.rx_bitrate)} + ${wifiMetricCell(row, 'signal')} + ${wifiMetricCell(row, 'tx_bitrate')} + ${wifiMetricCell(row, 'rx_bitrate')} ${td(wifiConnectedTime(row.connected_time))} ${td(row.inactive_time)} @@ -1744,6 +1816,11 @@ 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); diff --git a/public/index.php b/public/index.php index c592dd6..fc0de4c 100644 --- a/public/index.php +++ b/public/index.php @@ -104,6 +104,7 @@ html[data-theme="light"] .btn.secondary{background:#d9e2ef;color:#172033}html[da .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)}.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-estimated{color:var(--yellow)} .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}