diff --git a/README.md b/README.md index 716daf4..89cc57e 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ System Notice는 다음 조건으로 동작합니다. ## WiFi 관측 자동화 -5G 외부 WiFi 모듈과 LAN 클라이언트는 장치별 연결 시간이 직접 제공되지 않는 경우가 있어, 서버가 MAC별 최초 감지 시각을 DB에 저장하고 경과 시간을 계산합니다. +5G 외부 WiFi 모듈과 LAN 클라이언트는 장치별 연결 시간이 직접 제공되지 않는 경우가 있어, 서버가 MAC별 최초 감지 시각을 DB에 저장하고 경과 시간을 계산합니다. DHCP 클라이언트 목록은 연결 시간이 가장 긴 장치부터 표시합니다. 기존 대시보드 조회만으로도 보정은 가능하지만, 사용자가 접속하지 않은 시간에는 최초 감지가 늦어질 수 있습니다. 이를 막기 위해 다음 systemd timer가 10초마다 관측 CLI를 실행합니다. @@ -156,7 +156,7 @@ control-wifi-observe.service 3. 대시보드는 `status` snapshot을 렌더링합니다. 4. WebSocket 연결이 성공하면 `status` 메시지로 갱신하고, 실패하면 HTTP fallback을 사용합니다. 5. 팬 조작은 상태 저장, 정책 적용, 로그 저장 순서로 처리합니다. -6. WiFi client 목록은 `iw station dump`와 dnsmasq lease를 조합하고, LAN client는 `bridge fdb show br br0`의 USB LAN 포트 MAC을 dnsmasq lease와 매칭합니다. 5G 또는 LAN 연결 시간이 비어 있으면 MAC 기준 최초 감지 시간을 DB에 저장해 경과 시간을 계산합니다. +6. WiFi client 목록은 `iw station dump`와 dnsmasq lease를 조합하고, LAN client는 `bridge fdb show br br0`의 USB LAN 포트 MAC을 dnsmasq lease와 매칭합니다. 5G 또는 LAN 연결 시간이 비어 있으면 MAC 기준 최초 감지 시간을 DB에 저장해 경과 시간을 계산합니다. LAN client의 속도 칸은 무선 송수신 속도 대신 USB LAN 인터페이스의 현재 협상 링크 속도를 표시합니다. 7. WiFi hostname이 비어 있으면 MAC 기반 임시명을 표시하고, 수동 저장한 MAC별 이름은 DB에서 우선 적용합니다. 8. WiFi 신호/송신 속도/수신 속도 값이 비어 있으면 실제로 존재하는 다른 무선 지표와 대역별 기준으로 값을 채웁니다. 9. `control-wifi-observe.timer`는 사용자 접속과 무관하게 10초마다 5G/LAN 관측 세션을 갱신합니다. diff --git a/public/api.php b/public/api.php index 4bf7530..1396abb 100644 --- a/public/api.php +++ b/public/api.php @@ -2713,9 +2713,29 @@ function lan_fdb_macs(string $iface = 'eth1'): array return array_keys($macs); } +function lan_link_speed_label(string $iface = 'eth1'): string +{ + $speed = trim((string)@file_get_contents('/sys/class/net/' . $iface . '/speed')); + if ($speed === '' || !ctype_digit($speed) || (int)$speed <= 0) { + return '-'; + } + + $mbps = (int)$speed; + if ($mbps >= 1000 && $mbps % 1000 === 0) { + return (int)($mbps / 1000) . ' Gbps'; + } + + if ($mbps >= 1000) { + return rtrim(rtrim(number_format($mbps / 1000, 1), '0'), '.') . ' Gbps'; + } + + return $mbps . ' Mbps'; +} + function lan_client_rows(array $leases, array $aliases, string $iface = 'eth1'): array { $rows = []; + $linkSpeed = lan_link_speed_label($iface); foreach (lan_fdb_macs($iface) as $mac) { $lease = $leases[$mac] ?? []; @@ -2733,10 +2753,10 @@ function lan_client_rows(array $leases, array $aliases, string $iface = 'eth1'): 'hostname_source' => $hostnameSource, 'name' => $hostname === 'N/A' ? $mac : $hostname, 'signal' => '-', - 'tx_bitrate' => '-', + 'tx_bitrate' => $linkSpeed, 'rx_bitrate' => '-', 'signal_source' => 'lan', - 'tx_bitrate_source' => 'lan', + 'tx_bitrate_source' => 'lan_link', 'rx_bitrate_source' => 'lan', 'connected_time' => 'N/A', 'inactive_time' => '-', @@ -2903,6 +2923,42 @@ function wifi_connected_time_missing(mixed $value): bool return $text === '' || $text === 'N/A' || $text === '-'; } +function wifi_connected_seconds(mixed $value): ?float +{ + $text = strtolower(trim((string)$value)); + if ($text === '' || $text === 'n/a' || $text === '-') { + return null; + } + + if (is_numeric($text)) { + return (float)$text; + } + + $total = 0.0; + $matched = false; + if (preg_match_all('/([0-9]+(?:\.[0-9]+)?)\s*(days?|d|hours?|hrs?|h|minutes?|mins?|min|m|seconds?|secs?|sec|s|milliseconds?|msecs?|msec|ms)\b/i', $text, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + $amount = (float)$match[1]; + $unit = strtolower($match[2]); + $matched = true; + + if ($unit === 'd' || str_starts_with($unit, 'day')) { + $total += $amount * 86400; + } elseif ($unit === 'h' || str_starts_with($unit, 'hour') || str_starts_with($unit, 'hr')) { + $total += $amount * 3600; + } elseif ($unit === 'm' || str_starts_with($unit, 'min')) { + $total += $amount * 60; + } elseif ($unit === 'ms' || str_starts_with($unit, 'msec') || str_starts_with($unit, 'millisecond')) { + $total += $amount / 1000; + } else { + $total += $amount; + } + } + } + + return $matched ? $total : null; +} + function wifi_valid_mac(string $mac): bool { return preg_match('/^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/', strtolower($mac)) === 1; @@ -2996,7 +3052,8 @@ function observed_wifi_duration_seconds(array $clients): array ]); } - $placeholders = implode(',', array_fill(0, count($targets), '?')); + $targetMacs = array_values(array_unique(array_column($targets, 'mac'))); + $placeholders = implode(',', array_fill(0, count($targetMacs), '?')); $stmt = $pdo->prepare(" SELECT mac, @@ -3004,7 +3061,7 @@ function observed_wifi_duration_seconds(array $clients): array FROM wifi_observed_sessions WHERE mac IN ($placeholders) "); - $stmt->execute(array_values(array_unique(array_column($targets, 'mac')))); + $stmt->execute($targetMacs); $rows = $stmt->fetchAll(); $pdo->commit(); @@ -3050,6 +3107,31 @@ function apply_observed_wifi_connected_time(array $clients): array return $clients; } +function sort_clients_by_connected_time(array $clients): array +{ + usort($clients, static function (array $a, array $b): int { + $aSeconds = wifi_connected_seconds($a['connected_time'] ?? null); + $bSeconds = wifi_connected_seconds($b['connected_time'] ?? null); + + if ($aSeconds === null && $bSeconds === null) { + return strcmp((string)($a['band'] ?? ''), (string)($b['band'] ?? '')) + ?: strcmp((string)($a['hostname'] ?? ''), (string)($b['hostname'] ?? '')); + } + + if ($aSeconds === null) { + return 1; + } + + if ($bSeconds === null) { + return -1; + } + + return $bSeconds <=> $aSeconds; + }); + + return $clients; +} + function wifi_data(): array { $leases = dnsmasq_leases(); @@ -3072,6 +3154,7 @@ function wifi_data(): array $clients = dedupe_wifi_clients($clients); $clients = apply_wifi_estimates($clients); $clients = apply_observed_wifi_connected_time($clients); + $clients = sort_clients_by_connected_time($clients); return [ 'clients' => $clients, diff --git a/public/assets/app.js b/public/assets/app.js index 84225b1..ebe0fee 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -108,10 +108,10 @@ fanStatus: 'Fan Status', cpuTemp: 'CPU Temp', fanRpm: 'Fan RPM', - wifiStatus: 'WiFi Status', - clients24: '2.4G Clients', - clients5: '5G Clients', - clientsLan: 'LAN Clients', + wifiStatus: 'DHCP Status', + clients24: '2.4G DHCP', + clients5: '5G DHCP', + clientsLan: 'LAN DHCP', systemStatus: 'System Status', loadAvg: 'Load Avg', activeUsers: 'Active Users', @@ -152,7 +152,7 @@ cpuWatt: 'CPU Watt', remaining: 'Remaining', batteryVoltage: 'Battery Voltage', - wifiClients: 'WiFi Clients', + wifiClients: 'DHCP Clients', deviceName: 'Device', address: 'Address', paired: 'Paired', @@ -201,7 +201,7 @@ dialogInput: 'Input', dialogOk: 'OK', dialogCancel: 'Cancel', - noWifiClients: 'No connected WiFi clients', + noWifiClients: 'No connected DHCP clients', noProcessActivity: 'No process activity', noCause: 'No candidate', recordedReason: 'Recorded reason', @@ -256,10 +256,10 @@ fanStatus: '팬 상태', cpuTemp: 'CPU 온도', fanRpm: '팬 RPM', - wifiStatus: 'WiFi 상태', - clients24: '2.4G 접속', - clients5: '5G 접속', - clientsLan: 'LAN 접속', + wifiStatus: 'DHCP 상태', + clients24: '2.4G DHCP', + clients5: '5G DHCP', + clientsLan: 'LAN DHCP', systemStatus: '시스템 상태', loadAvg: '부하 평균', activeUsers: '활성 사용자', @@ -300,7 +300,7 @@ cpuWatt: 'CPU 전력', remaining: '잔여 시간', batteryVoltage: '배터리 전압', - wifiClients: 'WiFi 클라이언트', + wifiClients: 'DHCP 클라이언트', deviceName: '장치', address: '주소', paired: '페어링', @@ -349,7 +349,7 @@ dialogInput: '입력', dialogOk: '확인', dialogCancel: '취소', - noWifiClients: '연결된 WiFi 클라이언트 없음', + noWifiClients: '연결된 DHCP 클라이언트 없음', noProcessActivity: '프로세스 활동 없음', noCause: '원인 후보 없음', recordedReason: '기록된 이유', @@ -1224,6 +1224,14 @@ return `${signalBadgeHtml(dbm, row[key] || '', `client:${row.mac || row.ip || row.hostname || ''}`)}`; } + function wifiRateCells(row) { + if (row?.band === 'LAN') { + return `${escapeHtml(wifiCellValue(row, 'tx_bitrate'))}`; + } + + return `${wifiMetricCell(row, 'tx_bitrate')}${wifiMetricCell(row, 'rx_bitrate')}`; + } + function wifiSignalDbm(value) { const match = String(value ?? '').match(/-?\d+(?:\.\d+)?/); return match ? Number(match[0]) : null; @@ -1430,8 +1438,7 @@ ${td(row.ip)} ${td(row.mac)} ${wifiMetricCell(row, 'signal')} - ${wifiMetricCell(row, 'tx_bitrate')} - ${wifiMetricCell(row, 'rx_bitrate')} + ${wifiRateCells(row)} ${td(wifiConnectedCellValue(row))} ${td(wifiCellValue(row, 'inactive_time'))} diff --git a/public/index.php b/public/index.php index f3d7f79..d63feb8 100644 --- a/public/index.php +++ b/public/index.php @@ -199,11 +199,11 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da}
-

WiFi Status

+

DHCP Status

-
2.4G Clients
-
-
5G Clients
-
-
LAN Clients
-
+
2.4G DHCP
-
+
5G DHCP
-
+
LAN DHCP
-
@@ -249,7 +249,7 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da}
-

WiFi Clients

+

DHCP Clients