스캔 캐시를 백그라운드로 갱신

This commit is contained in:
seo
2026-06-27 03:08:28 +09:00
parent 9936ae6985
commit 3be4d8838f
4 changed files with 167 additions and 51 deletions
+6 -3
View File
@@ -59,6 +59,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
- `config/config.php`: DB, 인증, CSRF, HA 알림, shell 실행 공통 함수
- `apply_policy.php`: CLI/cron 팬 정책 적용
- `bin/control_ws.php`: WebSocket 서버. 핵심 PHP 파일 변경 감지 시 종료되고 systemd가 재시작해 새 코드를 로드
- `bin/control_scan_refresh.php`: 주변 WiFi/Bluetooth 스캔 캐시를 백그라운드에서 갱신하는 CLI
- `bin/ha_notify_channel.php`: Android 알림 채널 제거 명령 전송용 운영 CLI
- `bin/wifi_observe.php`: 5G WiFi 연결 시간 보정을 위한 시스템단 관측 CLI
- `systemd/control-wifi-observe.*`: WiFi 관측 CLI를 주기적으로 실행하는 systemd unit 예시
@@ -137,7 +138,7 @@ WebSocket 장기 실행 프로세스에서는 1분 단위 단기 집계 결과
- 탭 복귀: 즉시 1회 상태 갱신
- 잔여 시간 커스텀 팝오버: 상태 갱신과 동일하며 hover/focus 중에도 내용과 위치를 계속 갱신
- dmesg: 진단 패널에서 열었을 때만 1초마다 갱신, 닫으면 중지
- 주변 WiFi/Bluetooth 스캔: 상태 snapshot에 포함되지만 파일 캐시를 사용하며 기본 60초마다 실제 스캔 명령을 다시 시도, 실패 시 DB의 마지막 성공 관측 목록 표시. 화면의 마지막 관측 경과 문구는 1초 상태 렌더마다 현재 시각 기준으로 다시 계산
- 주변 WiFi/Bluetooth 스캔: 상태 snapshot에 포함되지만 파일 캐시를 사용합니다. 캐시가 없거나 만료되어도 첫 응답은 실제 스캔을 기다리지 않고 DB의 마지막 성공 관측 목록을 즉시 반환하며, `bin/control_scan_refresh.php`를 백그라운드로 실행해 캐시를 갱신합니다. 화면의 마지막 관측 경과 문구는 1초 상태 렌더마다 현재 시각 기준으로 다시 계산합니다.
- WiFi 관측 보정 timer: 10초마다 `bin/wifi_observe.php` 실행
- WebSocket 소스 변경 감지: 15초마다 확인 후 재시작
- 팬 슬라이더 자동 적용 debounce: 약 450ms
@@ -197,13 +198,13 @@ control-wifi-observe.service
신호/송신 속도/수신 속도는 실제 `iw station dump` 값이 있으면 그대로 사용합니다. 값이 비어 있으면 2.4G는 일반 802.11n 계열 링크 속도와 dBm 범위를 기준으로, 5G는 Realtek RTL8822BU/802.11ac 계열 동글의 최대 1300Mbps 특성을 참고해 dBm과 링크 속도 사이를 보정합니다. 화면의 신호 칸은 숫자 대신 `최상`, `매우 좋음`, `좋음`, `양호`, `보통`, `약함`, `매우 약함` 7단계 등급으로 표시하고, 마우스 hover/키보드 focus/모바일 touch 시 커스텀 툴팁으로 실제 dBm을 표시합니다. 툴팁 내용과 위치는 1초 상태 렌더마다 다시 갱신됩니다.
주변 WiFi 스캔은 대시보드 snapshot에 포함되지만 기본 60초 파일 캐시를 사용합니다. `iw dev wlan0 scan`, `iw dev wlan1 scan`을 시도하고 SSID, BSSID, 주파수, 채널, 신호, 보안, 마지막 관측, WiFi 세대, 채널 폭, 지원 속도, capability를 표시합니다. 화면은 WiFi client 섹션 아래 별도 카드로 분리하며, 2.4G와 5G를 나란히 두지 않고 각각 독립 표로 세로 배치합니다.
주변 WiFi 스캔은 대시보드 snapshot에 포함되지만 기본 60초 파일 캐시를 사용합니다. `iw dev wlan0 scan`, `iw dev wlan1 scan`을 시도하고 SSID, BSSID, 주파수, 채널, 신호, 보안, 마지막 관측, WiFi 세대, 채널 폭, 지원 속도, capability를 표시합니다. 캐시가 없거나 만료된 첫 요청에서는 실제 스캔을 기다리지 않고 DB의 마지막 성공 관측값을 즉시 반환한 뒤, 백그라운드 CLI가 캐시를 갱신합니다. 화면은 WiFi client 섹션 아래 별도 카드로 분리하며, 2.4G와 5G를 나란히 두지 않고 각각 독립 표로 세로 배치합니다.
스캔에 성공한 AP는 `wifi_scan_observations`에 BSSID 기준으로 저장합니다. SSID, BSSID, 신호, 보안, 채널 폭 같은 화면 표시값뿐 아니라 `iw` 원문과 파싱 결과 JSON도 함께 저장해 이후 분석 근거를 남깁니다. 다음 스캔이 실패하거나 특정 대역이 AP 모드라 스캔할 수 없어도 목록을 비우지 않고, DB에 누적된 BSSID별 마지막 성공 관측값을 계속 표시합니다. 화면의 마지막 관측 경과는 캐시된 API 값에 고정하지 않고 1초 상태 렌더마다 현재 시각 기준으로 다시 계산합니다. live 스캔 결과와 DB 관측값 모두 마지막 관측 시각과 경과 시간을 함께 표시합니다.
스캔 실패 사유는 API payload에는 남지만 대시보드에는 노란 경고 박스로 표시하지 않습니다. 스캔 표의 보안, 속도, capability 같은 긴 문자열은 말줄임으로 자르지 않고 표 전체를 가로 스크롤해 확인합니다. 대역 제목 옆 숫자는 해당 대역에 표시 중인 AP 개수입니다. 스캔 표에서 가로 스크롤을 드래그하거나 스크롤 중일 때는 1초 상태 렌더가 표 컨테이너를 교체하지 않도록 짧게 보류해 스크롤바 클릭/드래그가 끊기지 않게 합니다.
주변 Bluetooth 스캔은 WiFi 스캔 카드 아래 별도 카드로 표시합니다. `bluetoothctl scan on`, `bluetoothctl devices`, `bluetoothctl info <address>`를 조합해 주소, 이름, alias, RSSI, TxPower, 연결/페어링/신뢰/차단 상태, class, icon, UUID, 제조사 데이터, 서비스 데이터, modalias, appearance를 가능한 범위에서 수집합니다. 성공한 장치는 `bluetooth_scan_observations`에 주소 기준으로 저장하며 원문과 파싱 결과 JSON도 함께 남깁니다. 스캔이 실패하거나 주변 장치가 일시적으로 사라져도 마지막 성공 관측값을 계속 표시하고, 마지막 관측 경과는 1초 상태 렌더마다 다시 계산합니다.
주변 Bluetooth 스캔은 WiFi 스캔 카드 아래 별도 카드로 표시합니다. `bluetoothctl scan on`, `bluetoothctl devices`, `bluetoothctl info <address>`를 조합해 주소, 이름, alias, RSSI, TxPower, 연결/페어링/신뢰/차단 상태, class, icon, UUID, 제조사 데이터, 서비스 데이터, modalias, appearance를 가능한 범위에서 수집합니다. 성공한 장치는 `bluetooth_scan_observations`에 주소 기준으로 저장하며 원문과 파싱 결과 JSON도 함께 남깁니다. Bluetooth 실시간 스캔은 10초 이상 걸릴 수 있으므로 화면 첫 응답에서는 기다리지 않고 마지막 관측값을 먼저 표시하며, 백그라운드 CLI가 새 캐시를 채웁니다. 스캔이 실패하거나 주변 장치가 일시적으로 사라져도 마지막 성공 관측값을 계속 표시하고, 마지막 관측 경과는 1초 상태 렌더마다 다시 계산합니다.
## 처리 흐름
@@ -216,6 +217,7 @@ control-wifi-observe.service
7. WiFi 신호/송신 속도/수신 속도 값이 비어 있으면 실제로 존재하는 다른 무선 지표와 대역별 기준으로 값을 채웁니다.
8. 주변 WiFi 스캔은 캐시 주기를 확인한 뒤 `iw dev <iface> scan`을 시도하고, 성공 결과를 DB에 저장한 뒤 live 값과 마지막 관측값을 주변 WiFi 스캔 전용 카드에 표시합니다.
9. 주변 Bluetooth 스캔은 캐시 주기를 확인한 뒤 `bluetoothctl`로 scan/devices/info를 시도하고, 성공 결과를 DB에 저장한 뒤 live 값과 마지막 관측값을 주변 Bluetooth 스캔 전용 카드에 표시합니다.
10. 스캔 캐시가 만료된 경우 status snapshot은 스캔 완료를 기다리지 않고 `refresh_pending=true`와 마지막 관측값을 반환하며, 백그라운드 refresh가 다음 렌더에서 사용할 캐시를 생성합니다.
9. `control-wifi-observe.timer`는 사용자 접속과 무관하게 10초마다 5G 관측 세션을 갱신합니다.
10. WakeLock 버튼은 활성 상태를 초록색 버튼으로 표시합니다.
11. Reboot 버튼은 설정된 확인 문구와 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다.
@@ -244,6 +246,7 @@ control-wifi-observe.service
- `save_wifi_scan_observations()`, `wifi_scan_observed_networks()`, `merge_wifi_scan_networks()`: 주변 WiFi 마지막 성공 관측값 저장, 조회, live/DB 결과 병합
- `bluetooth_scan_data()`, `parse_bluetooth_info()`: 주변 Bluetooth 장치 스캔, 세부 정보 수집, 파일 캐시 구성
- `save_bluetooth_scan_observations()`, `bluetooth_scan_observed_devices()`, `merge_bluetooth_scan_devices()`: 주변 Bluetooth 마지막 성공 관측값 저장, 조회, live/DB 결과 병합
- `wifi_scan_refresh_payload()`, `bluetooth_scan_refresh_payload()`, `scan_start_background_refresh()`: 느린 실제 스캔을 백그라운드 캐시 갱신으로 분리
- `send_ha_notify()`: HA webhook 알림 발송, 서울 우선/목포 fallback, 성공/실패 로그 저장
- `ha_notify_due()`: 동일 tag 기준 쿨다운 판정
- `clear_ha_notification()`: 같은 tag의 Android 알림 제거 명령 발송
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
define('CONTROL_API_LIBRARY', true);
require __DIR__ . '/../public/api.php';
$type = (string)($argv[1] ?? '');
if ($type === 'wifi') {
wifi_scan_refresh_payload();
} elseif ($type === 'bluetooth') {
bluetooth_scan_refresh_payload();
}
+146 -46
View File
@@ -2652,6 +2652,66 @@ function wifi_scan_cache_path(): string
return sys_get_temp_dir() . '/control_wifi_scan.json';
}
function scan_refresh_lock_path(string $type): string
{
return sys_get_temp_dir() . '/control_' . preg_replace('/[^a-z0-9_]/i', '', $type) . '_scan_refresh.lock';
}
function scan_start_background_refresh(string $type): void
{
$lockPath = scan_refresh_lock_path($type);
$script = dirname(__DIR__) . '/bin/control_scan_refresh.php';
if (!is_file($script)) {
return;
}
$cmd = '/usr/bin/flock -n '
. escapeshellarg($lockPath)
. ' /usr/bin/php '
. escapeshellarg($script)
. ' '
. escapeshellarg($type)
. ' >/dev/null 2>&1 &';
exec($cmd);
}
function scan_cached_payload(string $cachePath, int $cacheSeconds): ?array
{
if (!is_file($cachePath)) {
return null;
}
$cached = json_decode((string)@file_get_contents($cachePath), true);
if (!is_array($cached)) {
return null;
}
$cached['cached'] = true;
$cached['stale'] = (time() - filemtime($cachePath)) >= $cacheSeconds;
return $cached;
}
function scan_write_cache(string $cachePath, array $payload): void
{
$tmp = $cachePath . '.' . getmypid() . '.tmp';
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) {
return;
}
if (@file_put_contents($tmp, $json) === false) {
return;
}
@chmod($tmp, 0666);
if (!@rename($tmp, $cachePath)) {
@unlink($tmp);
return;
}
@chmod($cachePath, 0666);
}
function wifi_channel_from_freq(int $freq): ?int
{
if ($freq >= 2412 && $freq <= 2472) {
@@ -3031,31 +3091,9 @@ function merge_wifi_scan_networks(array $freshNetworks, array $observedNetworks)
return $rows;
}
function wifi_scan_data(): array
function wifi_scan_refresh_payload(): array
{
if (!(bool)setting_value('wifi.scan_enabled')) {
return [
'enabled' => false,
'cached' => false,
'generated_at' => null,
'interfaces' => [],
'networks' => wifi_scan_observed_networks((int)setting_value('wifi.scan_max_networks')),
'observed_hours' => null,
'observed_mode' => 'all',
'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 = [];
@@ -3098,11 +3136,54 @@ function wifi_scan_data(): array
'errors' => $errors,
];
@file_put_contents($cachePath, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
scan_write_cache($cachePath, $payload);
return $payload;
}
function wifi_scan_data(): array
{
$max = (int)setting_value('wifi.scan_max_networks');
if (!(bool)setting_value('wifi.scan_enabled')) {
return [
'enabled' => false,
'cached' => false,
'stale' => false,
'generated_at' => null,
'interfaces' => [],
'networks' => wifi_scan_observed_networks($max),
'observed_hours' => null,
'observed_mode' => 'all',
'errors' => [],
];
}
$cacheSeconds = (int)setting_value('wifi.scan_cache_seconds');
$cachePath = wifi_scan_cache_path();
$cached = scan_cached_payload($cachePath, $cacheSeconds);
if ($cached !== null && empty($cached['stale'])) {
return $cached;
}
scan_start_background_refresh('wifi');
$observedNetworks = wifi_scan_observed_networks($max);
return [
'enabled' => true,
'cached' => $cached !== null,
'stale' => true,
'refresh_pending' => true,
'generated_at' => is_array($cached) ? (string)($cached['generated_at'] ?? '') : date('Y-m-d H:i:s'),
'interfaces' => is_array($cached) ? (array)($cached['interfaces'] ?? []) : [],
'networks' => $observedNetworks,
'live_found' => is_array($cached) ? (int)($cached['live_found'] ?? 0) : 0,
'total_found' => count($observedNetworks),
'observed_hours' => null,
'observed_mode' => 'all',
'errors' => is_array($cached) ? (array)($cached['errors'] ?? []) : [],
];
}
function bluetooth_scan_cache_path(): string
{
return sys_get_temp_dir() . '/control_bluetooth_scan.json';
@@ -3444,30 +3525,10 @@ function merge_bluetooth_scan_devices(array $freshDevices, array $observedDevice
return $rows;
}
function bluetooth_scan_data(): array
function bluetooth_scan_refresh_payload(): array
{
$max = (int)setting_value('bluetooth.scan_max_devices');
if (!(bool)setting_value('bluetooth.scan_enabled')) {
return [
'enabled' => false,
'cached' => false,
'generated_at' => null,
'devices' => bluetooth_scan_observed_devices($max),
'observed_mode' => 'all',
'errors' => [],
];
}
$cacheSeconds = (int)setting_value('bluetooth.scan_cache_seconds');
$cachePath = bluetooth_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;
}
}
$timeout = (int)setting_value('bluetooth.scan_timeout_seconds');
$includeRaw = (bool)setting_value('bluetooth.scan_include_raw');
$ctl = bluetoothctl_path();
@@ -3512,11 +3573,50 @@ function bluetooth_scan_data(): array
'errors' => $errors,
];
@file_put_contents($cachePath, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
scan_write_cache($cachePath, $payload);
return $payload;
}
function bluetooth_scan_data(): array
{
$max = (int)setting_value('bluetooth.scan_max_devices');
if (!(bool)setting_value('bluetooth.scan_enabled')) {
return [
'enabled' => false,
'cached' => false,
'stale' => false,
'generated_at' => null,
'devices' => bluetooth_scan_observed_devices($max),
'observed_mode' => 'all',
'errors' => [],
];
}
$cacheSeconds = (int)setting_value('bluetooth.scan_cache_seconds');
$cachePath = bluetooth_scan_cache_path();
$cached = scan_cached_payload($cachePath, $cacheSeconds);
if ($cached !== null && empty($cached['stale'])) {
return $cached;
}
scan_start_background_refresh('bluetooth');
$observedDevices = bluetooth_scan_observed_devices($max);
return [
'enabled' => true,
'cached' => $cached !== null,
'stale' => true,
'refresh_pending' => true,
'generated_at' => is_array($cached) ? (string)($cached['generated_at'] ?? '') : date('Y-m-d H:i:s'),
'devices' => $observedDevices,
'live_found' => is_array($cached) ? (int)($cached['live_found'] ?? 0) : 0,
'total_found' => count($observedDevices),
'observed_mode' => 'all',
'errors' => is_array($cached) ? (array)($cached['errors'] ?? []) : [],
];
}
function parse_live_wifi_rows(string $iface, string $band, string $text, array $leases, array $aliases): array
{
$rows = [];
+2 -2
View File
@@ -134,7 +134,7 @@ html[data-theme="light"] .btn.secondary{background:#d9e2ef;color:#172033}html[da
html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da}html[data-theme="light"] .service-item{background:#e8f0f8;border-color:#b7c8dd}html[data-theme="light"] .service-name{color:#0f1f35}html[data-theme="light"] .service-desc,html[data-theme="light"] .service-meta{color:#42526a}html[data-theme="light"] .service-state{background:#f8fbff;border-color:#aebed2;color:#304057}html[data-theme="light"] .service-state.active{background:#d8f3e3;border-color:#7fc99c;color:#146c39}html[data-theme="light"] .service-state.failed{background:#ffe2e2;border-color:#e38b8b;color:#9f1d1d}html[data-theme="light"] .service-log,html[data-theme="light"] .dmesg-log{background:#f7fafc;border-color:#b9c8da;color:#0f2742}
.resource-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.resource-box{min-width:0}.resource-box h3{margin:0 0 9px;color:var(--sub);font-size:14px;font-weight:500}.resource-table{width:100%;min-width:620px;table-layout:fixed;border-collapse:collapse;border:1px solid var(--line);border-radius:14px;overflow:hidden}.resource-table th,.resource-table td{padding:9px 10px;border-bottom:1px solid rgba(128,145,170,.18);background:var(--input);text-align:left;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.resource-table th{background:var(--table-head);color:var(--sub);font-weight:500}.resource-table .pid{width:72px}.resource-table .metric{width:82px}.resource-table .service{width:150px}.resource-table .cmd{width:auto}
.notice{position:fixed;right:20px;bottom:20px;min-width:220px;max-width:420px;padding:14px 16px;border-radius:14px;font-weight:500;background:var(--table-head);color:var(--text);border:1px solid var(--line);box-shadow:var(--shadow);z-index:99}.notice:empty{display:none}.notice[data-type=success]{border-color:rgba(53,196,107,.5)}.notice[data-type=error]{border-color:rgba(255,95,87,.5)}
.status-value.interactive{cursor:help;text-decoration:underline;text-decoration-style:dotted;text-decoration-thickness:1px;text-underline-offset:4px}.runtime-tooltip{position:fixed;left:0;top:0;z-index:110;display:none;width:min(520px,calc(100vw - 28px));max-height:calc(100vh - 28px);overflow:hidden;padding:14px 16px;border:1px solid rgba(84,101,128,.78);border-radius:16px;background:var(--card);color:var(--text);box-shadow:0 18px 48px rgba(0,0,0,.38);font-size:13px;line-height:1.55;white-space:pre-line;word-break:keep-all;overflow-wrap:anywhere;pointer-events:none}.runtime-tooltip[data-open="1"]{display:block}.runtime-tooltip::before{content:"";position:absolute;left:18px;top:-7px;width:12px;height:12px;border-left:1px solid rgba(84,101,128,.78);border-top:1px solid rgba(84,101,128,.78);background:var(--card);transform:rotate(45deg)}html[data-theme="light"] .runtime-tooltip{background:#f8fbff;border-color:#aebed2;color:#172033;box-shadow:0 18px 44px rgba(28,43,64,.18)}html[data-theme="light"] .runtime-tooltip::before{background:#f8fbff;border-color:#aebed2}
.status-value.interactive{cursor:help;text-decoration:underline;text-decoration-style:dotted;text-decoration-thickness:1px;text-underline-offset:4px}.runtime-tooltip{position:fixed;left:0;top:0;z-index:110;display:none;width:min(520px,calc(100vw - 28px));max-height:calc(100vh - 28px);overflow:hidden;padding:14px 16px;border:1px solid rgba(84,101,128,.78);border-radius:16px;background:var(--card);color:var(--text);box-shadow:0 18px 48px rgba(0,0,0,.38);font-size:13px;line-height:1.55;white-space:pre-line;word-break:keep-all;overflow-wrap:anywhere;pointer-events:none}.runtime-tooltip.signal-tooltip{width:auto;min-width:118px;max-width:220px;padding:9px 12px;border-radius:12px;font-size:12px;line-height:1.45;box-shadow:0 12px 30px rgba(0,0,0,.28)}.runtime-tooltip[data-open="1"]{display:block}.runtime-tooltip::before{content:"";position:absolute;left:18px;top:-7px;width:12px;height:12px;border-left:1px solid rgba(84,101,128,.78);border-top:1px solid rgba(84,101,128,.78);background:var(--card);transform:rotate(45deg)}html[data-theme="light"] .runtime-tooltip{background:#f8fbff;border-color:#aebed2;color:#172033;box-shadow:0 18px 44px rgba(28,43,64,.18)}html[data-theme="light"] .runtime-tooltip.signal-tooltip{box-shadow:0 12px 28px rgba(28,43,64,.16)}html[data-theme="light"] .runtime-tooltip::before{background:#f8fbff;border-color:#aebed2}
.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))}}
@@ -383,7 +383,7 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da}
</div>
</div>
</div>
<script src="/assets/app.js?v=20260627_scan_scroll_signal1"></script>
<script src="/assets/app.js?v=20260627_async_scan_tooltip1"></script>
<script src="/assets/wakelock.js?v=20260607_prefs1"></script>
<?php endif; ?>
</body>