Refine DHCP client display

This commit is contained in:
seo
2026-07-20 23:32:21 +09:00
parent 2261d3ebe4
commit 34bb82b750
4 changed files with 115 additions and 25 deletions
+2 -2
View File
@@ -128,7 +128,7 @@ System Notice는 다음 조건으로 동작합니다.
## WiFi 관측 자동화 ## WiFi 관측 자동화
5G 외부 WiFi 모듈과 LAN 클라이언트는 장치별 연결 시간이 직접 제공되지 않는 경우가 있어, 서버가 MAC별 최초 감지 시각을 DB에 저장하고 경과 시간을 계산합니다. 5G 외부 WiFi 모듈과 LAN 클라이언트는 장치별 연결 시간이 직접 제공되지 않는 경우가 있어, 서버가 MAC별 최초 감지 시각을 DB에 저장하고 경과 시간을 계산합니다. DHCP 클라이언트 목록은 연결 시간이 가장 긴 장치부터 표시합니다.
기존 대시보드 조회만으로도 보정은 가능하지만, 사용자가 접속하지 않은 시간에는 최초 감지가 늦어질 수 있습니다. 이를 막기 위해 다음 systemd timer가 10초마다 관측 CLI를 실행합니다. 기존 대시보드 조회만으로도 보정은 가능하지만, 사용자가 접속하지 않은 시간에는 최초 감지가 늦어질 수 있습니다. 이를 막기 위해 다음 systemd timer가 10초마다 관측 CLI를 실행합니다.
@@ -156,7 +156,7 @@ control-wifi-observe.service
3. 대시보드는 `status` snapshot을 렌더링합니다. 3. 대시보드는 `status` snapshot을 렌더링합니다.
4. WebSocket 연결이 성공하면 `status` 메시지로 갱신하고, 실패하면 HTTP fallback을 사용합니다. 4. WebSocket 연결이 성공하면 `status` 메시지로 갱신하고, 실패하면 HTTP fallback을 사용합니다.
5. 팬 조작은 상태 저장, 정책 적용, 로그 저장 순서로 처리합니다. 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에서 우선 적용합니다. 7. WiFi hostname이 비어 있으면 MAC 기반 임시명을 표시하고, 수동 저장한 MAC별 이름은 DB에서 우선 적용합니다.
8. WiFi 신호/송신 속도/수신 속도 값이 비어 있으면 실제로 존재하는 다른 무선 지표와 대역별 기준으로 값을 채웁니다. 8. WiFi 신호/송신 속도/수신 속도 값이 비어 있으면 실제로 존재하는 다른 무선 지표와 대역별 기준으로 값을 채웁니다.
9. `control-wifi-observe.timer`는 사용자 접속과 무관하게 10초마다 5G/LAN 관측 세션을 갱신합니다. 9. `control-wifi-observe.timer`는 사용자 접속과 무관하게 10초마다 5G/LAN 관측 세션을 갱신합니다.
+87 -4
View File
@@ -2713,9 +2713,29 @@ function lan_fdb_macs(string $iface = 'eth1'): array
return array_keys($macs); 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 function lan_client_rows(array $leases, array $aliases, string $iface = 'eth1'): array
{ {
$rows = []; $rows = [];
$linkSpeed = lan_link_speed_label($iface);
foreach (lan_fdb_macs($iface) as $mac) { foreach (lan_fdb_macs($iface) as $mac) {
$lease = $leases[$mac] ?? []; $lease = $leases[$mac] ?? [];
@@ -2733,10 +2753,10 @@ function lan_client_rows(array $leases, array $aliases, string $iface = 'eth1'):
'hostname_source' => $hostnameSource, 'hostname_source' => $hostnameSource,
'name' => $hostname === 'N/A' ? $mac : $hostname, 'name' => $hostname === 'N/A' ? $mac : $hostname,
'signal' => '-', 'signal' => '-',
'tx_bitrate' => '-', 'tx_bitrate' => $linkSpeed,
'rx_bitrate' => '-', 'rx_bitrate' => '-',
'signal_source' => 'lan', 'signal_source' => 'lan',
'tx_bitrate_source' => 'lan', 'tx_bitrate_source' => 'lan_link',
'rx_bitrate_source' => 'lan', 'rx_bitrate_source' => 'lan',
'connected_time' => 'N/A', 'connected_time' => 'N/A',
'inactive_time' => '-', 'inactive_time' => '-',
@@ -2903,6 +2923,42 @@ function wifi_connected_time_missing(mixed $value): bool
return $text === '' || $text === 'N/A' || $text === '-'; 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 function wifi_valid_mac(string $mac): bool
{ {
return preg_match('/^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/', strtolower($mac)) === 1; 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(" $stmt = $pdo->prepare("
SELECT SELECT
mac, mac,
@@ -3004,7 +3061,7 @@ function observed_wifi_duration_seconds(array $clients): array
FROM wifi_observed_sessions FROM wifi_observed_sessions
WHERE mac IN ($placeholders) WHERE mac IN ($placeholders)
"); ");
$stmt->execute(array_values(array_unique(array_column($targets, 'mac')))); $stmt->execute($targetMacs);
$rows = $stmt->fetchAll(); $rows = $stmt->fetchAll();
$pdo->commit(); $pdo->commit();
@@ -3050,6 +3107,31 @@ function apply_observed_wifi_connected_time(array $clients): array
return $clients; 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 function wifi_data(): array
{ {
$leases = dnsmasq_leases(); $leases = dnsmasq_leases();
@@ -3072,6 +3154,7 @@ function wifi_data(): array
$clients = dedupe_wifi_clients($clients); $clients = dedupe_wifi_clients($clients);
$clients = apply_wifi_estimates($clients); $clients = apply_wifi_estimates($clients);
$clients = apply_observed_wifi_connected_time($clients); $clients = apply_observed_wifi_connected_time($clients);
$clients = sort_clients_by_connected_time($clients);
return [ return [
'clients' => $clients, 'clients' => $clients,
+21 -14
View File
@@ -108,10 +108,10 @@
fanStatus: 'Fan Status', fanStatus: 'Fan Status',
cpuTemp: 'CPU Temp', cpuTemp: 'CPU Temp',
fanRpm: 'Fan RPM', fanRpm: 'Fan RPM',
wifiStatus: 'WiFi Status', wifiStatus: 'DHCP Status',
clients24: '2.4G Clients', clients24: '2.4G DHCP',
clients5: '5G Clients', clients5: '5G DHCP',
clientsLan: 'LAN Clients', clientsLan: 'LAN DHCP',
systemStatus: 'System Status', systemStatus: 'System Status',
loadAvg: 'Load Avg', loadAvg: 'Load Avg',
activeUsers: 'Active Users', activeUsers: 'Active Users',
@@ -152,7 +152,7 @@
cpuWatt: 'CPU Watt', cpuWatt: 'CPU Watt',
remaining: 'Remaining', remaining: 'Remaining',
batteryVoltage: 'Battery Voltage', batteryVoltage: 'Battery Voltage',
wifiClients: 'WiFi Clients', wifiClients: 'DHCP Clients',
deviceName: 'Device', deviceName: 'Device',
address: 'Address', address: 'Address',
paired: 'Paired', paired: 'Paired',
@@ -201,7 +201,7 @@
dialogInput: 'Input', dialogInput: 'Input',
dialogOk: 'OK', dialogOk: 'OK',
dialogCancel: 'Cancel', dialogCancel: 'Cancel',
noWifiClients: 'No connected WiFi clients', noWifiClients: 'No connected DHCP clients',
noProcessActivity: 'No process activity', noProcessActivity: 'No process activity',
noCause: 'No candidate', noCause: 'No candidate',
recordedReason: 'Recorded reason', recordedReason: 'Recorded reason',
@@ -256,10 +256,10 @@
fanStatus: '팬 상태', fanStatus: '팬 상태',
cpuTemp: 'CPU 온도', cpuTemp: 'CPU 온도',
fanRpm: '팬 RPM', fanRpm: '팬 RPM',
wifiStatus: 'WiFi 상태', wifiStatus: 'DHCP 상태',
clients24: '2.4G 접속', clients24: '2.4G DHCP',
clients5: '5G 접속', clients5: '5G DHCP',
clientsLan: 'LAN 접속', clientsLan: 'LAN DHCP',
systemStatus: '시스템 상태', systemStatus: '시스템 상태',
loadAvg: '부하 평균', loadAvg: '부하 평균',
activeUsers: '활성 사용자', activeUsers: '활성 사용자',
@@ -300,7 +300,7 @@
cpuWatt: 'CPU 전력', cpuWatt: 'CPU 전력',
remaining: '잔여 시간', remaining: '잔여 시간',
batteryVoltage: '배터리 전압', batteryVoltage: '배터리 전압',
wifiClients: 'WiFi 클라이언트', wifiClients: 'DHCP 클라이언트',
deviceName: '장치', deviceName: '장치',
address: '주소', address: '주소',
paired: '페어링', paired: '페어링',
@@ -349,7 +349,7 @@
dialogInput: '입력', dialogInput: '입력',
dialogOk: '확인', dialogOk: '확인',
dialogCancel: '취소', dialogCancel: '취소',
noWifiClients: '연결된 WiFi 클라이언트 없음', noWifiClients: '연결된 DHCP 클라이언트 없음',
noProcessActivity: '프로세스 활동 없음', noProcessActivity: '프로세스 활동 없음',
noCause: '원인 후보 없음', noCause: '원인 후보 없음',
recordedReason: '기록된 이유', recordedReason: '기록된 이유',
@@ -1224,6 +1224,14 @@
return `<td>${signalBadgeHtml(dbm, row[key] || '', `client:${row.mac || row.ip || row.hostname || ''}`)}</td>`; return `<td>${signalBadgeHtml(dbm, row[key] || '', `client:${row.mac || row.ip || row.hostname || ''}`)}</td>`;
} }
function wifiRateCells(row) {
if (row?.band === 'LAN') {
return `<td colspan="2">${escapeHtml(wifiCellValue(row, 'tx_bitrate'))}</td>`;
}
return `${wifiMetricCell(row, 'tx_bitrate')}${wifiMetricCell(row, 'rx_bitrate')}`;
}
function wifiSignalDbm(value) { function wifiSignalDbm(value) {
const match = String(value ?? '').match(/-?\d+(?:\.\d+)?/); const match = String(value ?? '').match(/-?\d+(?:\.\d+)?/);
return match ? Number(match[0]) : null; return match ? Number(match[0]) : null;
@@ -1430,8 +1438,7 @@
${td(row.ip)} ${td(row.ip)}
${td(row.mac)} ${td(row.mac)}
${wifiMetricCell(row, 'signal')} ${wifiMetricCell(row, 'signal')}
${wifiMetricCell(row, 'tx_bitrate')} ${wifiRateCells(row)}
${wifiMetricCell(row, 'rx_bitrate')}
${td(wifiConnectedCellValue(row))} ${td(wifiConnectedCellValue(row))}
${td(wifiCellValue(row, 'inactive_time'))} ${td(wifiCellValue(row, 'inactive_time'))}
</tr> </tr>
+5 -5
View File
@@ -199,11 +199,11 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da}
</div> </div>
<div class="card"> <div class="card">
<h2 data-i18n="wifiStatus">WiFi Status</h2> <h2 data-i18n="wifiStatus">DHCP Status</h2>
<div class="stat-grid"> <div class="stat-grid">
<div class="stat"><div class="stat-label" data-i18n="clients24">2.4G Clients</div><div class="stat-value" id="wifi24">-</div></div> <div class="stat"><div class="stat-label" data-i18n="clients24">2.4G DHCP</div><div class="stat-value" id="wifi24">-</div></div>
<div class="stat"><div class="stat-label" data-i18n="clients5">5G Clients</div><div class="stat-value" id="wifi5">-</div></div> <div class="stat"><div class="stat-label" data-i18n="clients5">5G DHCP</div><div class="stat-value" id="wifi5">-</div></div>
<div class="stat"><div class="stat-label" data-i18n="clientsLan">LAN Clients</div><div class="stat-value" id="wifiLan">-</div></div> <div class="stat"><div class="stat-label" data-i18n="clientsLan">LAN DHCP</div><div class="stat-value" id="wifiLan">-</div></div>
</div> </div>
</div> </div>
@@ -249,7 +249,7 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da}
</div> </div>
<div class="card"> <div class="card">
<h2 data-i18n="wifiClients">WiFi Clients</h2> <h2 data-i18n="wifiClients">DHCP Clients</h2>
<div class="table-wrap"> <div class="table-wrap">
<table class="wifi-table"> <table class="wifi-table">
<colgroup> <colgroup>