Control WiFi 진단과 배터리 예측 보강

This commit is contained in:
seo
2026-06-26 18:09:29 +09:00
parent ebef879634
commit 6665b37975
5 changed files with 557 additions and 26 deletions
+374 -5
View File
@@ -1133,6 +1133,10 @@ function battery_voltage_floor(float $currentVoltage): float
function battery_voltage_trend_candidate(array $trendRows, float $currentPercent, ?float $currentVoltage, int $windowSeconds, ?float $recentWatts): ?array
{
if (!(bool)setting_value('battery.voltage_model_enabled')) {
return null;
}
if ($currentVoltage === null || $currentVoltage <= 0 || count($trendRows) < 10) {
return null;
}
@@ -1358,6 +1362,7 @@ function battery_capacity_power_candidate(float $currentPercent, ?float $recentW
return null;
}
$recentWatts = battery_effective_system_watts($recentWatts);
$remainingWh = BATTERY_CAPACITY_WH * ($currentPercent / 100);
$seconds = (int)round(($remainingWh / $recentWatts) * 3600);
if ($seconds <= 0) {
@@ -1382,10 +1387,15 @@ function battery_capacity_power_candidate(float $currentPercent, ?float $recentW
function battery_energy_profile_candidate(array $profileRows, float $currentPercent, ?float $recentWatts): ?array
{
if (!(bool)setting_value('battery.energy_model_enabled')) {
return null;
}
if ($recentWatts === null || $recentWatts <= 0 || count($profileRows) < 24) {
return null;
}
$recentWatts = battery_effective_system_watts($recentWatts);
$now = time();
$weightedWhPerPercent = 0.0;
$weightSum = 0.0;
@@ -1469,6 +1479,74 @@ function battery_energy_profile_candidate(array $profileRows, float $currentPerc
];
}
function battery_effective_system_watts(?float $watts): ?float
{
if ($watts === null || $watts <= 0) {
return null;
}
return max($watts, (float)setting_value('battery.minimum_system_watts'));
}
function battery_runtime_cap_seconds(float $percent): int
{
$fullCapSeconds = max(0.0, (float)setting_value('battery.runtime_cap_hours')) * 3600;
if ($fullCapSeconds <= 0) {
return 0;
}
return (int)round($fullCapSeconds * clamp_float($percent, 0.0, 100.0) / 100);
}
function battery_cap_candidate(array $candidate, float $currentPercent): array
{
$capSeconds = battery_runtime_cap_seconds($currentPercent);
$seconds = (int)($candidate['seconds'] ?? 0);
if ($capSeconds <= 0 || $seconds <= $capSeconds) {
$candidate['runtime_cap_seconds'] = $capSeconds;
$candidate['runtime_capped'] = false;
return $candidate;
}
$candidate['original_seconds'] = $seconds;
$candidate['seconds'] = $capSeconds;
$candidate['rate_per_second'] = $capSeconds > 0 ? $currentPercent / $capSeconds : (float)($candidate['rate_per_second'] ?? 0);
$candidate['confidence'] = round((float)($candidate['confidence'] ?? 0) * (float)setting_value('battery.long_candidate_penalty'), 4);
$candidate['weight'] = max(0.02, (float)($candidate['weight'] ?? 0.03) * (float)setting_value('battery.long_candidate_penalty'));
$candidate['runtime_cap_seconds'] = $capSeconds;
$candidate['runtime_capped'] = true;
return $candidate;
}
function battery_cap_candidates(array $candidates, float $currentPercent): array
{
return array_map(
static fn(array $candidate): array => battery_cap_candidate($candidate, $currentPercent),
$candidates
);
}
function battery_cap_result(array $result, float $percent): array
{
$capSeconds = battery_runtime_cap_seconds($percent);
$seconds = $result['seconds'] ?? null;
$result['runtime_cap_seconds'] = $capSeconds;
$result['runtime_capped'] = false;
if ($capSeconds > 0 && is_numeric($seconds) && (int)$seconds > $capSeconds) {
$result['original_seconds'] = (int)$seconds;
$result['seconds'] = $capSeconds;
$result['display'] = human_remaining_seconds($capSeconds);
$result['runtime_capped'] = true;
if (isset($result['confidence']) && is_numeric($result['confidence'])) {
$result['confidence'] = round((float)$result['confidence'] * (float)setting_value('battery.long_candidate_penalty'), 3);
}
}
return $result;
}
function refine_battery_candidates(array $candidates): array
{
if (count($candidates) < 3) {
@@ -1517,18 +1595,18 @@ function battery_power_fallback_estimate(float $percent, array $history): array
}
}
$avgWatts = numeric_trimmed_average($wattValues, 0.12);
$avgWatts = battery_effective_system_watts(numeric_trimmed_average($wattValues, 0.12));
if (BATTERY_CAPACITY_WH > 0 && $avgWatts !== null && $avgWatts > 0) {
$remainingWh = BATTERY_CAPACITY_WH * ($percent / 100);
$seconds = (int)round(($remainingWh / $avgWatts) * 3600);
return [
return battery_cap_result([
'display' => human_remaining_seconds($seconds),
'seconds' => $seconds,
'source' => 'capacity_power_fallback',
'avg_watts' => round($avgWatts, 3),
'capacity_wh' => BATTERY_CAPACITY_WH,
];
], $percent);
}
return [
@@ -1590,6 +1668,7 @@ function battery_remaining_estimate(array $battery, array $history, array $trend
if ($capacityPower !== null) {
$candidates[] = $capacityPower;
}
$candidates = battery_cap_candidates($candidates, $percent);
$candidates = refine_battery_candidates($candidates);
if ($candidates !== []) {
@@ -1614,7 +1693,7 @@ function battery_remaining_estimate(array $battery, array $history, array $trend
$rateSpread = (numeric_mad($rates) ?? 0.0) / $medianRate;
}
return [
return battery_cap_result([
'display' => human_remaining_seconds($seconds),
'seconds' => $seconds,
'source' => 'soc_multi_window',
@@ -1640,9 +1719,11 @@ function battery_remaining_estimate(array $battery, array $history, array $trend
'volts_per_hour' => $candidate['volts_per_hour'] ?? null,
'wh_per_percent' => $candidate['wh_per_percent'] ?? null,
'capacity_wh' => $candidate['capacity_wh'] ?? null,
'runtime_capped' => $candidate['runtime_capped'] ?? false,
'original_seconds' => $candidate['original_seconds'] ?? null,
];
}, $candidates),
];
], $percent);
}
}
@@ -2566,6 +2647,293 @@ function iw_station_dump(string $iface): string
return sh(['/usr/sbin/iw', 'dev', $iface, 'station', 'dump'], true, 4)['out'];
}
function wifi_scan_cache_path(): string
{
return sys_get_temp_dir() . '/control_wifi_scan.json';
}
function wifi_channel_from_freq(int $freq): ?int
{
if ($freq >= 2412 && $freq <= 2472) {
return (int)(($freq - 2407) / 5);
}
if ($freq === 2484) {
return 14;
}
if ($freq >= 5000 && $freq <= 5900) {
return (int)(($freq - 5000) / 5);
}
if ($freq >= 5925 && $freq <= 7125) {
return (int)(($freq - 5950) / 5);
}
return null;
}
function wifi_band_from_freq(int $freq): string
{
if ($freq >= 2400 && $freq < 2500) {
return '2.4G';
}
if ($freq >= 4900 && $freq < 5925) {
return '5G';
}
if ($freq >= 5925 && $freq <= 7125) {
return '6G';
}
return '기타';
}
function wifi_interfaces_from_iw(): array
{
$out = sh(['/usr/sbin/iw', 'dev'], true, 4)['out'];
$interfaces = [];
$current = null;
foreach (explode("\n", $out) as $line) {
$trimmed = trim($line);
if (preg_match('/^Interface\s+(.+)$/', $trimmed, $m)) {
if ($current !== null && !empty($current['iface'])) {
$interfaces[$current['iface']] = $current;
}
$current = [
'iface' => trim($m[1]),
'type' => '',
'channel' => null,
'freq' => null,
'band' => '',
];
continue;
}
if ($current === null) {
continue;
}
if (preg_match('/^type\s+(.+)$/', $trimmed, $m)) {
$current['type'] = trim($m[1]);
} elseif (preg_match('/^channel\s+(\d+)\s+\((\d+)\s+MHz\)/', $trimmed, $m)) {
$current['channel'] = (int)$m[1];
$current['freq'] = (int)$m[2];
$current['band'] = wifi_band_from_freq((int)$m[2]);
}
}
if ($current !== null && !empty($current['iface'])) {
$interfaces[$current['iface']] = $current;
}
foreach (['wlan0' => '2.4G', 'wlan1' => '5G'] as $iface => $band) {
if (!isset($interfaces[$iface])) {
$interfaces[$iface] = [
'iface' => $iface,
'type' => 'unknown',
'channel' => null,
'freq' => null,
'band' => $band,
];
} elseif ($interfaces[$iface]['band'] === '') {
$interfaces[$iface]['band'] = $band;
}
}
return $interfaces;
}
function parse_wifi_scan_networks(string $iface, string $text, bool $includeRaw): array
{
$networks = [];
$current = null;
$raw = [];
$push = static function () use (&$networks, &$current, &$raw, $iface, $includeRaw): void {
if ($current === null) {
return;
}
$freq = (int)($current['freq'] ?? 0);
$current['iface'] = $iface;
$current['band'] = $freq > 0 ? wifi_band_from_freq($freq) : '기타';
$current['channel'] = $freq > 0 ? wifi_channel_from_freq($freq) : null;
$current['security'] = trim(implode(' / ', array_values(array_unique($current['security'] ?? [])))) ?: '개방 또는 미확인';
if ($includeRaw) {
$current['raw'] = implode("\n", array_slice($raw, 0, 45));
}
unset($current['security_flags']);
$networks[] = $current;
};
foreach (explode("\n", $text) as $line) {
$trimmed = trim($line);
if (preg_match('/^BSS\s+([0-9a-f:]{17})/i', $trimmed, $m)) {
$push();
$raw = [$trimmed];
$current = [
'bssid' => strtolower($m[1]),
'ssid' => '숨김 SSID',
'freq' => null,
'signal_dbm' => null,
'signal' => 'N/A',
'last_seen_ms' => null,
'security' => [],
'capability' => '',
'rates' => '',
'ht' => false,
'vht' => false,
'he' => false,
'channel_width' => '',
];
continue;
}
if ($current === null) {
continue;
}
$raw[] = $trimmed;
if (preg_match('/^SSID:\s*(.*)$/', $trimmed, $m)) {
$ssid = trim($m[1]);
$current['ssid'] = $ssid === '' ? '숨김 SSID' : $ssid;
} elseif (preg_match('/^freq:\s*(\d+)/', $trimmed, $m)) {
$current['freq'] = (int)$m[1];
} elseif (preg_match('/^signal:\s*(-?\d+(?:\.\d+)?)\s*dBm/i', $trimmed, $m)) {
$dbm = (float)$m[1];
$current['signal_dbm'] = $dbm;
$current['signal'] = number_format($dbm, 1) . ' dBm';
} elseif (preg_match('/^last seen:\s*(\d+)\s*ms/i', $trimmed, $m)) {
$current['last_seen_ms'] = (int)$m[1];
} elseif (preg_match('/^capability:\s*(.+)$/i', $trimmed, $m)) {
$current['capability'] = trim($m[1]);
if (stripos($m[1], 'Privacy') !== false) {
$current['security'][] = '암호화';
}
} elseif (preg_match('/^RSN:/i', $trimmed)) {
$current['security'][] = 'WPA2/WPA3';
} elseif (preg_match('/^WPA:/i', $trimmed)) {
$current['security'][] = 'WPA';
} elseif (preg_match('/^\*\s+Authentication suites:\s*(.+)$/i', $trimmed, $m)) {
$current['security'][] = trim($m[1]);
} elseif (preg_match('/^\*\s+Pairwise ciphers:\s*(.+)$/i', $trimmed, $m)) {
$current['security'][] = trim($m[1]);
} elseif (preg_match('/^Supported rates:\s*(.+)$/i', $trimmed, $m)) {
$current['rates'] = trim($m[1]);
} elseif (preg_match('/^HT capabilities:/i', $trimmed)) {
$current['ht'] = true;
} elseif (preg_match('/^VHT capabilities:/i', $trimmed)) {
$current['vht'] = true;
} elseif (preg_match('/^HE capabilities:/i', $trimmed)) {
$current['he'] = true;
} elseif (preg_match('/^\*\s+channel width:\s*(.+)$/i', $trimmed, $m)) {
$current['channel_width'] = trim($m[1]);
}
}
$push();
usort($networks, static function (array $a, array $b): int {
return (float)($b['signal_dbm'] ?? -999) <=> (float)($a['signal_dbm'] ?? -999);
});
return $networks;
}
function wifi_scan_failure_reason(int $code, string $out): string
{
$lower = strtolower($out);
if ($code === 124) {
return '스캔 명령이 timeout 되었습니다. AP 모드 중 오프채널 스캔이 오래 걸리거나 드라이버가 응답하지 않는 상태일 수 있습니다.';
}
if (str_contains($lower, 'device or resource busy') || str_contains($lower, 'resource busy')) {
return '인터페이스가 AP로 동작 중이라 같은 무선 칩에서 주변 스캔을 동시에 수행하지 못했습니다.';
}
if (str_contains($lower, 'operation not supported') || str_contains($lower, 'not supported')) {
return '무선 드라이버가 현재 모드에서 주변 스캔을 지원하지 않습니다.';
}
if (str_contains($lower, 'network is down')) {
return '인터페이스가 내려가 있어 스캔할 수 없습니다.';
}
if ($out !== '') {
return trim($out);
}
return '스캔 결과가 비어 있습니다. AP 모드, 드라이버 제한, 권한 문제 중 하나일 수 있습니다.';
}
function wifi_scan_data(): array
{
if (!(bool)setting_value('wifi.scan_enabled')) {
return [
'enabled' => false,
'cached' => false,
'generated_at' => null,
'interfaces' => [],
'networks' => [],
'errors' => [],
];
}
$cacheSeconds = (int)setting_value('wifi.scan_cache_seconds');
$cachePath = wifi_scan_cache_path();
if (is_file($cachePath) && (time() - filemtime($cachePath)) < $cacheSeconds) {
$cached = json_decode((string)@file_get_contents($cachePath), true);
if (is_array($cached)) {
$cached['cached'] = true;
return $cached;
}
}
$interfaces = wifi_interfaces_from_iw();
$networks = [];
$errors = [];
$timeout = (int)setting_value('wifi.scan_timeout_seconds');
$includeRaw = (bool)setting_value('wifi.scan_include_raw');
foreach (['wlan0', 'wlan1'] as $iface) {
$meta = $interfaces[$iface] ?? ['iface' => $iface, 'type' => 'unknown', 'band' => $iface === 'wlan0' ? '2.4G' : '5G'];
$result = sh(['/usr/sbin/iw', 'dev', $iface, 'scan'], true, $timeout);
if ((int)$result['code'] !== 0) {
$errors[] = [
'iface' => $iface,
'band' => $meta['band'] ?? '',
'type' => $meta['type'] ?? '',
'code' => (int)$result['code'],
'reason' => wifi_scan_failure_reason((int)$result['code'], (string)$result['out']),
];
continue;
}
foreach (parse_wifi_scan_networks($iface, (string)$result['out'], $includeRaw) as $network) {
$networks[] = $network;
}
}
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');
$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),
'errors' => $errors,
];
@file_put_contents($cachePath, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return $payload;
}
function parse_live_wifi_rows(string $iface, string $band, string $text, array $leases, array $aliases): array
{
$rows = [];
@@ -2951,6 +3319,7 @@ function wifi_data(): array
'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),
'scan' => wifi_scan_data(),
];
}
+132 -6
View File
@@ -15,6 +15,7 @@
wifi24: $('#wifi24'),
wifi5: $('#wifi5'),
wifiTable: $('#wifiTable'),
wifiScan: $('#wifiScan'),
statusHost: $('#statusHost'),
statusLoad: $('#statusLoad'),
statusUsers: $('#statusUsers'),
@@ -145,6 +146,15 @@
remaining: 'Remaining',
batteryVoltage: 'Battery Voltage',
wifiClients: 'WiFi Clients',
wifiScan: 'Nearby WiFi Scan',
wifiScanDisabled: 'Nearby WiFi scan is disabled.',
wifiScanEmpty: 'No nearby WiFi scan results.',
wifiScanCached: 'cached',
wifiScanFresh: 'fresh',
ssid: 'SSID',
channel: 'Channel',
security: 'Security',
lastSeen: 'Last Seen',
band: 'Band',
hostname: 'Hostname',
signal: 'Signal',
@@ -286,6 +296,15 @@
remaining: '잔여 시간',
batteryVoltage: '배터리 전압',
wifiClients: 'WiFi 클라이언트',
wifiScan: '주변 WiFi 스캔',
wifiScanDisabled: '주변 WiFi 스캔이 꺼져 있습니다.',
wifiScanEmpty: '주변 WiFi 스캔 결과가 없습니다.',
wifiScanCached: '캐시',
wifiScanFresh: '실시간',
ssid: 'SSID',
channel: '채널',
security: '보안',
lastSeen: '마지막 감지',
band: '대역',
hostname: '호스트명',
signal: '신호',
@@ -909,6 +928,14 @@
if (remaining.seconds !== undefined && remaining.seconds !== null) {
lines.push(`계산 잔여: ${formatDurationSeconds(remaining.seconds)}`);
}
if (remaining.runtime_cap_seconds !== undefined && remaining.runtime_cap_seconds !== null) {
const capText = formatDurationSeconds(remaining.runtime_cap_seconds);
const cappedText = remaining.runtime_capped ? ' / 현실 상한 적용' : '';
lines.push(`현실 상한: ${capText}${cappedText}`);
}
if (remaining.original_seconds !== undefined && remaining.original_seconds !== null) {
lines.push(`상한 전 계산값: ${formatDurationSeconds(remaining.original_seconds)}`);
}
if (remaining.drop_per_hour !== undefined && remaining.drop_per_hour !== null) {
lines.push(`시간당 SOC 감소: ${Number(remaining.drop_per_hour).toFixed(3)}%`);
}
@@ -960,28 +987,33 @@
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)}%`);
const cap = row.runtime_capped ? ` / 상한 전 ${formatDurationSeconds(row.original_seconds)}` : '';
lines.push(`- 장기 학습: ${row.intervals || 0}구간 / SOC대 ${row.soc_buckets || 0}개 / 누적감소 ${Number(row.drop_percent || 0).toFixed(3)}% / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}%${cap}`);
} else if (row.source === 'energy_profile') {
const wh = row.wh_per_percent === null || row.wh_per_percent === undefined ? '-' : `${Number(row.wh_per_percent).toFixed(4)}Wh/%`;
const stability = row.stability === null || row.stability === undefined ? '' : ` / 안정도 ${(Number(row.stability) * 100).toFixed(0)}%`;
lines.push(`- 에너지 학습: ${row.intervals || 0}구간 / SOC대 ${row.soc_buckets || 0}개 / ${wh} / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}%${stability}`);
const cap = row.runtime_capped ? ` / 상한 전 ${formatDurationSeconds(row.original_seconds)}` : '';
lines.push(`- 에너지 학습: ${row.intervals || 0}구간 / SOC대 ${row.soc_buckets || 0}개 / ${wh} / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}%${stability}${cap}`);
} else if (row.source === 'capacity_power_model') {
const capacity = row.capacity_wh === null || row.capacity_wh === undefined ? '-' : `${Number(row.capacity_wh).toFixed(2)}Wh`;
const stability = row.stability === null || row.stability === undefined ? '' : ` / 안정도 ${(Number(row.stability) * 100).toFixed(0)}%`;
lines.push(`- 용량 전력 모델: ${capacity} / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}%${stability}`);
const cap = row.runtime_capped ? ` / 상한 전 ${formatDurationSeconds(row.original_seconds)}` : '';
lines.push(`- 용량 전력 모델: ${capacity} / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}%${stability}${cap}`);
} else if (row.source === 'voltage_slope') {
const load = row.load_factor === null || row.load_factor === undefined ? '-' : `${Number(row.load_factor).toFixed(3)}x`;
const kept = row.kept_ratio === null || row.kept_ratio === undefined ? '' : ` / 유효점 ${(Number(row.kept_ratio) * 100).toFixed(0)}%`;
const stability = row.stability === null || row.stability === undefined ? '' : ` / 안정도 ${(Number(row.stability) * 100).toFixed(0)}%`;
const floor = row.voltage_floor === null || row.voltage_floor === undefined ? '' : ` / 하한 ${Number(row.voltage_floor).toFixed(3)}V`;
const volts = row.volts_per_hour === null || row.volts_per_hour === undefined ? '' : ` / ${Number(row.volts_per_hour).toFixed(4)}V/h`;
lines.push(`- 전압 ${row.minutes}분: 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}% / 부하보정 ${load}${kept}${stability}${floor}${volts}`);
const cap = row.runtime_capped ? ` / 상한 전 ${formatDurationSeconds(row.original_seconds)}` : '';
lines.push(`- 전압 ${row.minutes}분: 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}% / 부하보정 ${load}${kept}${stability}${floor}${volts}${cap}`);
} else {
const load = row.load_factor === null || row.load_factor === undefined ? '-' : `${Number(row.load_factor).toFixed(3)}x`;
const prefix = row.source === 'robust_regression' ? '강건 회귀' : '최근 추세';
const kept = row.kept_ratio === null || row.kept_ratio === undefined ? '' : ` / 유효점 ${(Number(row.kept_ratio) * 100).toFixed(0)}%`;
const stability = row.stability === null || row.stability === undefined ? '' : ` / 안정도 ${(Number(row.stability) * 100).toFixed(0)}%`;
lines.push(`- ${prefix} ${row.minutes}분: 감소 ${Number(row.drop_percent || 0).toFixed(3)}% / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}% / 부하보정 ${load}${kept}${stability}`);
const cap = row.runtime_capped ? ` / 상한 전 ${formatDurationSeconds(row.original_seconds)}` : '';
lines.push(`- ${prefix} ${row.minutes}분: 감소 ${Number(row.drop_percent || 0).toFixed(3)}% / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}% / 부하보정 ${load}${kept}${stability}${cap}`);
}
});
}
@@ -1091,7 +1123,36 @@
}
function wifiMetricCell(row, key) {
return `<td>${escapeHtml(row[key] || '-')}</td>`;
if (key !== 'signal') {
return `<td>${escapeHtml(row[key] || '-')}</td>`;
}
const dbm = wifiSignalDbm(row[key]);
const cls = wifiSignalClass(dbm);
const title = dbm === null ? '' : `title="${attr(signalQualityText(dbm))}"`;
return `<td><span class="wifi-signal ${cls}" ${title}>${escapeHtml(row[key] || '-')}</span></td>`;
}
function wifiSignalDbm(value) {
const match = String(value ?? '').match(/-?\d+(?:\.\d+)?/);
return match ? Number(match[0]) : null;
}
function wifiSignalClass(dbm) {
if (dbm === null || !Number.isFinite(dbm)) return 'unknown';
if (dbm >= -55) return 'excellent';
if (dbm >= -67) return 'good';
if (dbm >= -76) return 'fair';
if (dbm >= -84) return 'weak';
return 'bad';
}
function signalQualityText(dbm) {
if (dbm >= -55) return '매우 강함';
if (dbm >= -67) return '강함';
if (dbm >= -76) return '보통';
if (dbm >= -84) return '약함';
return '매우 약함';
}
async function editWifiHostname(mac, currentName) {
@@ -1255,6 +1316,71 @@
</tr>
`).join('')
: `<tr><td colspan="9">${escapeHtml(t('noWifiClients'))}</td></tr>`;
renderWifiScan(data.wifi?.scan || null);
}
function renderWifiScan(scan) {
if (!els.wifiScan) return;
if (!scan || scan.enabled === false) {
els.wifiScan.innerHTML = `<div class="wifi-scan-empty">${escapeHtml(t('wifiScanDisabled'))}</div>`;
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 groups = ['2.4G', '5G'].map(band => {
const bandRows = networks.filter(row => row.band === band);
return `
<section class="wifi-scan-band">
<h3>${escapeHtml(band)} <span>${bandRows.length}${scan.total_found ? ` / ${scan.total_found}` : ''}</span></h3>
<div class="table-wrap compact">
<table class="wifi-scan-table">
<thead><tr><th>${escapeHtml(t('ssid'))}</th><th>BSSID</th><th>${escapeHtml(t('channel'))}</th><th>${escapeHtml(t('signal'))}</th><th>${escapeHtml(t('security'))}</th><th>${escapeHtml(t('lastSeen'))}</th><th>WiFi</th><th>폭</th><th>속도</th><th>기능</th></tr></thead>
<tbody>
${bandRows.length ? bandRows.map(row => {
const dbm = wifiSignalDbm(row.signal);
return `<tr>
<td>${escapeHtml(row.ssid || '-')}</td>
<td>${escapeHtml(row.bssid || '-')}</td>
<td>${escapeHtml(row.channel ?? '-')} ${row.freq ? `(${escapeHtml(row.freq)}MHz)` : ''}</td>
<td><span class="wifi-signal ${wifiSignalClass(dbm)}">${escapeHtml(row.signal || '-')}</span></td>
<td>${escapeHtml(row.security || '-')}</td>
<td>${row.last_seen_ms === null || row.last_seen_ms === undefined ? '-' : `${escapeHtml(row.last_seen_ms)}ms`}</td>
<td>${escapeHtml(wifiStandardText(row))}</td>
<td>${escapeHtml(row.channel_width || '-')}</td>
<td>${escapeHtml(row.rates || '-')}</td>
<td>${escapeHtml(row.capability || '-')}</td>
</tr>`;
}).join('') : `<tr><td colspan="10">${escapeHtml(t('wifiScanEmpty'))}</td></tr>`}
</tbody>
</table>
</div>
</section>
`;
}).join('');
const errorHtml = errors.length
? `<div class="wifi-scan-errors">${errors.map(row => `<div><strong>${escapeHtml(row.band || row.iface || '-')}</strong> ${escapeHtml(row.reason || '-')}</div>`).join('')}</div>`
: '';
els.wifiScan.innerHTML = `
<div class="wifi-scan-head">
<h3>${escapeHtml(t('wifiScan'))}</h3>
<span>${status}</span>
</div>
${errorHtml}
<div class="wifi-scan-grid">${groups}</div>
`;
}
function wifiStandardText(row) {
const parts = [];
if (row?.he) parts.push('WiFi 6');
if (row?.vht) parts.push('WiFi 5');
if (row?.ht) parts.push('WiFi 4');
return parts.join(' / ') || '-';
}
function renderSystemStatus(data) {
+4 -2
View File
@@ -125,8 +125,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)}.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}
.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}.wifi-signal.excellent{color:#22c55e}.wifi-signal.good{color:#38bdf8}.wifi-signal.fair{color:#f59e0b}.wifi-signal.weak{color:#fb7185}.wifi-signal.bad{color:#ef4444}.wifi-signal.unknown{color:var(--sub)}.wifi-scan{margin-top:16px;display:grid;gap:12px}.wifi-scan-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px}.wifi-scan-head h3{margin:0;color:var(--text);font-size:15px;font-weight:600}.wifi-scan-head span{color:var(--sub);font-size:12px;font-variant-numeric:tabular-nums}.wifi-scan-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.wifi-scan-band{min-width:0;display:grid;gap:8px}.wifi-scan-band h3{margin:0;color:var(--sub);font-size:13px;font-weight:600}.wifi-scan-band h3 span{font-weight:500;color:var(--sub)}.wifi-scan-table{width:100%;min-width:1180px;table-layout:fixed;border-collapse:collapse}.wifi-scan-table th,.wifi-scan-table td{padding:9px 10px;border-bottom:1px solid rgba(128,145,170,.16);font-size:12px;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.wifi-scan-table th{background:var(--table-head);color:var(--sub);font-weight:500}.wifi-scan-table th:nth-child(1),.wifi-scan-table td:nth-child(1){width:190px}.wifi-scan-table th:nth-child(2),.wifi-scan-table td:nth-child(2){width:145px}.wifi-scan-table th:nth-child(3),.wifi-scan-table td:nth-child(3){width:112px}.wifi-scan-table th:nth-child(4),.wifi-scan-table td:nth-child(4){width:92px}.wifi-scan-table th:nth-child(5),.wifi-scan-table td:nth-child(5){width:210px}.wifi-scan-table th:nth-child(6),.wifi-scan-table td:nth-child(6){width:90px}.wifi-scan-table th:nth-child(7),.wifi-scan-table td:nth-child(7){width:105px}.wifi-scan-table th:nth-child(8),.wifi-scan-table td:nth-child(8){width:90px}.wifi-scan-table th:nth-child(9),.wifi-scan-table td:nth-child(9){width:150px}.wifi-scan-errors,.wifi-scan-empty{border:1px solid rgba(245,158,11,.35);background:rgba(245,158,11,.1);color:var(--text);border-radius:12px;padding:10px 12px;font-size:13px;line-height:1.5}.wifi-scan-errors{display:grid;gap:5px}.wifi-scan-errors strong{color:#fbbf24;margin-right:5px}
.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}
@@ -137,7 +138,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)}}
</style>
</head>
@@ -269,6 +270,7 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da}
<tbody id="wifiTable"></tbody>
</table>
</div>
<div id="wifiScan" class="wifi-scan"></div>
</div>
</section>
</main>