diff --git a/README.md b/README.md index 1783022..f8e051a 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - 팬 모드 `auto`, `manual`, `off` 제어 - PWM slider 기반 수동 팬 제어 - 오버레이 설정 모달에서 보안 정책, 팬 자동곡선, 이벤트 판정, 팬 이상감지 파라미터 조정 +- 설정 모달에서 라즈베리파이 정품 7인치 DSI 터치 디스플레이 상태 확인, 콘솔 회전, 터치 좌표 보정, LCD/터치 비활성화 부팅 설정 조정 - CPU 온도, 팬 RPM 핵심 차트 - RP1 온도, 팬 효율, CPU 전력 상세 차트는 접힘 영역에서 확인 - UPS/배터리 잔여 시간 기능은 장치 제거 후 비활성 상태로 두고, 배터리 센서 조회와 장기 학습 계산은 돌리지 않음 @@ -46,6 +47,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - `public/api.php?action=settings`: 설정 가능한 Control 동작값 조회 - `public/api.php?action=settings_save`: 설정 모달 값 저장 - `public/api.php?action=settings_reset`: 설정 모달 값을 기본값으로 초기화 +- `public/api.php?action=touch_display_save`: `/boot/firmware/config.txt`, `/boot/firmware/cmdline.txt`에 DSI 터치 디스플레이 부팅 설정 저장 - `public/api.php?action=dmesg`: dmesg 로그 조회 - `public/api.php?action=reboot`: 확인 단어와 관리자 암호 재검증 후 sudo reboot 실행 @@ -96,10 +98,13 @@ API 응답의 `battery.enabled`는 false, `battery.removed`는 true로 내려가 - 팬 자동 제어: 팬 시작 온도, 최대 온도, 즉시 최대 PWM 온도, RP1 온도 반영 비율, 팬 판단 온도 여유, 자동 상승/하강 PWM step - 보안 정책: 자동 로그인 유지 기간, remember 쿠키 Secure/SameSite, CSRF 길이, remember 토큰 길이, User-Agent/IP 접두사 검증, 로그인 실패 잠금, 재부팅 허용 여부, 재부팅 확인 문구, 명령 timeout, WiFi 조작 허용 여부 - 화면/진단 표시: 프로세스 후보 수, 사용자 서비스 로그 줄 수, 사용자 서비스 캐시 시간, 팬 이상 이력 수 +- 터치 디스플레이: 현재 DSI 연결 상태, `800x480` 모드, framebuffer, 자동감지/수동 overlay 상태 표시와 콘솔 회전, LCD 출력, 터치 입력, `invx`/`invy`/`swapxy`, 터치 좌표 크기 저장 - 저전압/스로틀링: 최근 판정창, 복구 중 유지 시간, 반복 발생 기준 횟수 - 팬 이상감지: 기준 표본 수, 최신 표본 제외 수, RPM/온도 이상 감지 차이, RPM/온도 복구 차이, RPM 감시 시작 PWM, alert 유지 중 반복 기록, 반복 기록 간격, 하강 변화 원인 후보 표시 여부 - UPS/배터리 설정: 현재 UPS 제거 상태에서는 설정 모달에서 숨겨지고, `battery.enabled=true`일 때만 배터리 예측 항목을 노출합니다. +디스플레이 설정은 DB가 아니라 라즈베리파이 부팅 파일에 저장합니다. 저장 전 기존 파일을 `.control-YYYYMMDD-HHMMSS.bak` 형식으로 백업하고, 변경 사항은 재부팅 후 적용됩니다. 구형 정품 7인치 Touch Display의 콘솔 회전은 `cmdline.txt`의 `video=DSI-1:800x480@60,rotate=...`를 사용하며, 터치 좌표 보정이 필요하면 `config.txt`의 `vc4-kms-dsi-7inch` overlay로 전환합니다. + 설정 모달이 열려 있는 동안 1초 상태 갱신은 폼 DOM을 다시 만들지 않습니다. 입력 중인 값은 저장, 기본값 초기화, 닫기 전까지 유지되며, 백그라운드 snapshot은 내부 설정 payload만 갱신합니다. 단순 boolean 설정은 `켜기`/`끄기` 세그먼트로 표시합니다. ## 갱신 주기 @@ -208,6 +213,7 @@ control-wifi-observe.service - `sync_current_battery_remaining_history()`: 잔여 시간 차트 마지막 지점을 현재 snapshot 잔여 시간과 동기화 - `batteryRemainingTitle()`, `showBatteryTooltip()`, `updateBatteryTooltip()`: 잔여 시간 계산 근거 커스텀 팝오버 표시와 실시간 갱신 - `setting_definitions()`, `settings_payload()`, `save_settings()`, `reset_settings()`: 설정 모달 항목 정의, 조회, 저장, 초기화 +- `touch_display_status()`, `touch_display_set_config()`: 정품 7인치 DSI 터치 디스플레이 인식 상태와 부팅 설정 조회/저장 - `bin/wifi_observe.php`: 화면 접속 없이 `wifi_data()`를 호출해 5G/LAN 관측 세션을 선제 갱신 - `assets/wakelock.js`: WakeLock 버튼 상태와 Screen Wake Lock API 제어 - `customAlert()`, `customConfirm()`, `customPrompt()`: 대시보드 공통 확인/입력 dialog diff --git a/public/api.php b/public/api.php index 5f6c12c..0f54288 100644 --- a/public/api.php +++ b/public/api.php @@ -3478,6 +3478,308 @@ function custom_systemd_services(): array return $rows; } +function touch_display_bool(mixed $value): bool +{ + return in_array((string)$value, ['1', 'true', 'yes', 'on'], true); +} + +function touch_display_file(string $kind): string +{ + return $kind === 'cmdline' ? '/boot/firmware/cmdline.txt' : '/boot/firmware/config.txt'; +} + +function touch_display_read_file(string $kind): string +{ + $path = touch_display_file($kind); + $content = @file_get_contents($path); + return is_string($content) ? $content : ''; +} + +function touch_display_backup_file(string $path): void +{ + if (!is_file($path)) { + return; + } + + $backup = $path . '.control-' . date('Ymd-His') . '.bak'; + if (function_exists('posix_geteuid') && posix_geteuid() === 0) { + @copy($path, $backup); + return; + } + + sh(['/bin/cp', '-a', $path, $backup], true, 5); +} + +function touch_display_write_file(string $path, string $content): void +{ + if (function_exists('posix_geteuid') && posix_geteuid() === 0) { + if (@file_put_contents($path, $content) === false) { + throw new RuntimeException(basename($path) . ' 저장에 실패했습니다.'); + } + return; + } + + $tmp = sys_get_temp_dir() . '/control-touch-display-' . bin2hex(random_bytes(6)); + if (@file_put_contents($tmp, $content) === false) { + throw new RuntimeException('임시 설정 파일 생성에 실패했습니다.'); + } + @chmod($tmp, 0644); + + $result = sh(['/bin/cp', '-f', $tmp, $path], true, 5); + @unlink($tmp); + if ($result['code'] !== 0) { + throw new RuntimeException(trim($result['out']) !== '' ? trim($result['out']) : basename($path) . ' 저장에 실패했습니다.'); + } +} + +function touch_display_active_config_lines(string $config): array +{ + return array_values(array_filter( + preg_split('/\R/', $config) ?: [], + static function (string $line): bool { + $trimmed = trim($line); + return $trimmed !== '' && !str_starts_with($trimmed, '#'); + } + )); +} + +function touch_display_config_flag(array $lines, string $key): bool +{ + foreach ($lines as $line) { + if (preg_match('/^' . preg_quote($key, '/') . '\s*=\s*1\s*$/', $line)) { + return true; + } + } + + return false; +} + +function touch_display_overlay(array $lines): array +{ + $overlay = [ + 'enabled' => false, + 'params' => [], + ]; + + foreach ($lines as $line) { + if (!preg_match('/^dtoverlay\s*=\s*vc4-kms-dsi-7inch(?(?:,.*)?)\s*$/', $line, $m)) { + continue; + } + + $overlay['enabled'] = true; + $params = trim((string)($m['params'] ?? ''), ','); + foreach (array_filter(array_map('trim', explode(',', $params))) as $param) { + if (str_contains($param, '=')) { + [$key, $value] = array_map('trim', explode('=', $param, 2)); + $overlay['params'][$key] = $value; + } else { + $overlay['params'][$param] = true; + } + } + } + + return $overlay; +} + +function touch_display_rotation(string $cmdline): int +{ + if (preg_match('/(?:^|\s)video=DSI-1:\d+x\d+@\d+(?:\.\d+)?,rotate=(0|90|180|270)(?:\s|$)/', $cmdline, $m)) { + return (int)$m[1]; + } + + return 0; +} + +function touch_display_status(): array +{ + $connector = '/sys/class/drm/card0-DSI-1'; + $config = touch_display_read_file('config'); + $cmdline = touch_display_read_file('cmdline'); + $lines = touch_display_active_config_lines($config); + $overlay = touch_display_overlay($lines); + $params = $overlay['params']; + + $statusPath = $connector . '/status'; + $modesPath = $connector . '/modes'; + $enabledPath = $connector . '/enabled'; + $dpmsPath = $connector . '/dpms'; + $modes = is_readable($modesPath) + ? array_values(array_filter(array_map('trim', file($modesPath, FILE_IGNORE_NEW_LINES) ?: []))) + : []; + + return [ + 'connector' => 'DSI-1', + 'detected' => is_dir($connector), + 'connected' => trim((string)@file_get_contents($statusPath)) === 'connected', + 'status' => is_readable($statusPath) ? trim((string)@file_get_contents($statusPath)) : 'unknown', + 'enabled_state' => is_readable($enabledPath) ? trim((string)@file_get_contents($enabledPath)) : 'unknown', + 'dpms' => is_readable($dpmsPath) ? trim((string)@file_get_contents($dpmsPath)) : 'unknown', + 'modes' => $modes, + 'current_mode' => $modes[0] ?? '800x480', + 'framebuffer' => is_dir('/sys/class/graphics/fb0') ? 'fb0' : '-', + 'touch_input' => trim((string)shell_exec("for e in /sys/class/input/event*/device/name; do cat \"\$e\" 2>/dev/null; done | grep -i 'ft5x06' | head -n 1")) ?: '-', + 'config' => [ + 'display_auto_detect' => touch_display_config_flag($lines, 'display_auto_detect'), + 'manual_overlay' => (bool)$overlay['enabled'], + 'display_enabled' => !touch_display_config_flag($lines, 'ignore_lcd'), + 'touch_enabled' => !touch_display_config_flag($lines, 'disable_touchscreen') && empty($params['disable_touch']), + 'rotation' => touch_display_rotation($cmdline), + 'touch_invx' => !empty($params['invx']) && (string)$params['invx'] !== '0', + 'touch_invy' => !empty($params['invy']) && (string)$params['invy'] !== '0', + 'touch_swapxy' => !empty($params['swapxy']) && (string)$params['swapxy'] !== '0', + 'touch_sizex' => is_numeric($params['sizex'] ?? null) ? (int)$params['sizex'] : 800, + 'touch_sizey' => is_numeric($params['sizey'] ?? null) ? (int)$params['sizey'] : 480, + ], + 'paths' => [ + 'config' => touch_display_file('config'), + 'cmdline' => touch_display_file('cmdline'), + ], + ]; +} + +function touch_display_comment_directives(string $config, array $patterns): string +{ + $lines = preg_split('/\R/', $config) ?: []; + $cleaned = []; + $skipControlBlock = false; + foreach ($lines as $line) { + $trimmed = trim($line); + if ($trimmed === '# Control touch display settings') { + $skipControlBlock = true; + continue; + } + if ($skipControlBlock) { + if ( + $trimmed === '' + || preg_match('/^(display_auto_detect|ignore_lcd|disable_touchscreen)\s*=/', $trimmed) + || preg_match('/^dtoverlay\s*=\s*vc4-kms-dsi-7inch(?:,.*)?$/', $trimmed) + ) { + continue; + } + $skipControlBlock = false; + } + $cleaned[] = $line; + } + $lines = $cleaned; + + foreach ($lines as &$line) { + $trimmed = trim($line); + if ($trimmed === '' || str_starts_with($trimmed, '#')) { + continue; + } + foreach ($patterns as $pattern) { + if (preg_match($pattern, $trimmed)) { + $line = '# control disabled: ' . $line; + break; + } + } + } + unset($line); + + return implode("\n", $lines); +} + +function touch_display_set_config(array $input): array +{ + $rotation = (int)($input['rotation'] ?? 0); + if (!in_array($rotation, [0, 90, 180, 270], true)) { + throw new RuntimeException('지원하지 않는 회전 값입니다.'); + } + + $displayEnabled = touch_display_bool($input['display_enabled'] ?? '1'); + $touchEnabled = touch_display_bool($input['touch_enabled'] ?? '1'); + $invx = touch_display_bool($input['touch_invx'] ?? '0'); + $invy = touch_display_bool($input['touch_invy'] ?? '0'); + $swapxy = touch_display_bool($input['touch_swapxy'] ?? '0'); + $sizeX = max(100, min(4096, (int)($input['touch_sizex'] ?? 800))); + $sizeY = max(100, min(4096, (int)($input['touch_sizey'] ?? 480))); + $manualOverlay = touch_display_bool($input['manual_overlay'] ?? '0') || $invx || $invy || $swapxy || !$touchEnabled || $sizeX !== 800 || $sizeY !== 480; + + $configPath = touch_display_file('config'); + $cmdlinePath = touch_display_file('cmdline'); + $config = touch_display_read_file('config'); + $cmdline = trim(touch_display_read_file('cmdline')); + + if ($config === '' || $cmdline === '') { + throw new RuntimeException('부팅 설정 파일을 읽을 수 없습니다.'); + } + + $newConfig = touch_display_comment_directives($config, [ + '/^display_auto_detect\s*=/', + '/^ignore_lcd\s*=/', + '/^disable_touchscreen\s*=/', + '/^dtoverlay\s*=\s*vc4-kms-dsi-7inch(?:,.*)?$/', + ]); + + $append = []; + $append[] = ''; + $append[] = '# Control touch display settings'; + if ($manualOverlay) { + $params = ['vc4-kms-dsi-7inch']; + if ($sizeX !== 800) { + $params[] = 'sizex=' . $sizeX; + } + if ($sizeY !== 480) { + $params[] = 'sizey=' . $sizeY; + } + if ($invx) { + $params[] = 'invx'; + } + if ($invy) { + $params[] = 'invy'; + } + if ($swapxy) { + $params[] = 'swapxy'; + } + if (!$touchEnabled) { + $params[] = 'disable_touch'; + } + $append[] = 'dtoverlay=' . implode(',', $params); + } else { + $append[] = 'display_auto_detect=1'; + } + if (!$displayEnabled) { + $append[] = 'ignore_lcd=1'; + } + if (!$touchEnabled) { + $append[] = 'disable_touchscreen=1'; + } + $newConfig = rtrim($newConfig) . "\n" . implode("\n", $append) . "\n"; + + $newCmdline = preg_replace('/(?:^|\s)video=DSI-1:\d+x\d+@\d+(?:\.\d+)?,rotate=(?:0|90|180|270)(?=\s|$)/', '', $cmdline) ?? $cmdline; + $newCmdline = trim(preg_replace('/\s+/', ' ', $newCmdline) ?? $newCmdline); + if ($rotation !== 0) { + $newCmdline .= ' video=DSI-1:800x480@60,rotate=' . $rotation; + } + $newCmdline .= "\n"; + + $changed = []; + if ($newConfig !== $config) { + touch_display_backup_file($configPath); + touch_display_write_file($configPath, $newConfig); + $changed[] = $configPath; + } + if ($newCmdline !== touch_display_read_file('cmdline')) { + touch_display_backup_file($cmdlinePath); + touch_display_write_file($cmdlinePath, $newCmdline); + $changed[] = $cmdlinePath; + } + + add_fan_action( + 'touch_display_config', + null, + null, + 'rotation=' . $rotation . ', manual_overlay=' . ($manualOverlay ? '1' : '0') . ', display_enabled=' . ($displayEnabled ? '1' : '0') . ', touch_enabled=' . ($touchEnabled ? '1' : '0'), + true + ); + + return [ + 'changed' => $changed, + 'reboot_required' => $changed !== [], + 'display' => touch_display_status(), + ]; +} + function collect_snapshot(bool $applyFan = true): array { if ($applyFan) { @@ -3653,9 +3955,12 @@ function collect_snapshot(bool $applyFan = true): array 'history' => $history, 'processes' => $processes, 'custom_services' => custom_systemd_services(), + 'touch_display' => touch_display_status(), 'fan_spike' => $fanSpike, 'fan_spike_history' => fan_spike_history((int)setting_value('display.fan_notice_history_limit')), - 'settings' => settings_payload(), + 'settings' => array_merge(settings_payload(), [ + 'touch_display' => touch_display_status(), + ]), ]; return $snapshot; @@ -3689,7 +3994,9 @@ function control_api_dispatch(): void if ($action === 'settings') { json_out([ 'ok' => true, - 'data' => settings_payload(), + 'data' => array_merge(settings_payload(), [ + 'touch_display' => touch_display_status(), + ]), ]); } @@ -3717,6 +4024,25 @@ function control_api_dispatch(): void ]); } + if ($action === 'touch_display_save') { + $raw = (string)($_POST['display'] ?? '{}'); + $display = json_decode($raw, true); + if (!is_array($display)) { + json_out([ + 'ok' => false, + 'error' => 'bad_display_settings', + ], 400); + } + + $result = touch_display_set_config($display); + $data = collect_snapshot(false); + $data['touch_display_save'] = $result; + json_out([ + 'ok' => true, + 'data' => $data, + ]); + } + if ($action === 'fan') { $mode = (string)($_POST['mode'] ?? 'auto'); $pwm = max(0, min(255, (int)($_POST['pwm'] ?? 120))); diff --git a/public/assets/app.js b/public/assets/app.js index 9815811..a8a4021 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -199,6 +199,25 @@ settingsSaveFailed: 'settings save failed', settingsResetConfirm: 'Reset all Control settings to defaults?', settingsDefault: 'default', + touchDisplaySettings: 'Touch Display', + touchDisplayStatus: 'Status', + touchDisplayDetected: 'Detected', + touchDisplayConnected: 'Connected', + touchDisplayMode: 'Mode', + touchDisplayFramebuffer: 'Framebuffer', + touchDisplayOverlay: 'Manual overlay', + touchDisplayAutoDetect: 'Auto detect', + touchDisplayEnabled: 'LCD output', + touchInputEnabled: 'Touch input', + touchRotation: 'Console rotation', + touchInvx: 'Invert X', + touchInvy: 'Invert Y', + touchSwapxy: 'Swap X/Y', + touchSizeX: 'Touch X size', + touchSizeY: 'Touch Y size', + touchDisplaySave: 'Save display boot settings', + touchDisplaySaved: 'Display settings saved. Reboot is required.', + touchDisplayNoChange: 'Display settings already match.', rebootButton: 'Reboot', dialogAlert: 'Alert', dialogConfirm: 'Confirm', @@ -351,6 +370,25 @@ settingsSaveFailed: '설정 저장 실패', settingsResetConfirm: 'Control 설정을 모두 기본값으로 되돌릴까요?', settingsDefault: '기본값', + touchDisplaySettings: '터치 디스플레이', + touchDisplayStatus: '상태', + touchDisplayDetected: '인식', + touchDisplayConnected: '연결', + touchDisplayMode: '모드', + touchDisplayFramebuffer: '프레임버퍼', + touchDisplayOverlay: '수동 overlay', + touchDisplayAutoDetect: '자동 감지', + touchDisplayEnabled: 'LCD 출력', + touchInputEnabled: '터치 입력', + touchRotation: '콘솔 회전', + touchInvx: '터치 X 반전', + touchInvy: '터치 Y 반전', + touchSwapxy: '터치 X/Y 교환', + touchSizeX: '터치 X 크기', + touchSizeY: '터치 Y 크기', + touchDisplaySave: '디스플레이 부팅 설정 저장', + touchDisplaySaved: '디스플레이 설정을 저장했습니다. 재부팅이 필요합니다.', + touchDisplayNoChange: '디스플레이 설정이 이미 동일합니다.', rebootButton: '재부팅', dialogAlert: '알림', dialogConfirm: '확인', @@ -645,6 +683,85 @@ return `setting-${String(key).replace(/[^A-Za-z0-9_-]/g, '-')}`; } + function displayDomId(key) { + return `touch-display-${String(key).replace(/[^A-Za-z0-9_-]/g, '-')}`; + } + + function boolLabel(value) { + return value ? 'ON' : 'OFF'; + } + + function renderTouchDisplaySettings(display) { + if (!display || !display.config) return ''; + const config = display.config || {}; + const statusRows = [ + [t('touchDisplayDetected'), boolLabel(!!display.detected)], + [t('touchDisplayConnected'), `${display.status || 'unknown'} / ${display.enabled_state || 'unknown'} / ${display.dpms || 'unknown'}`], + [t('touchDisplayMode'), display.current_mode || '-'], + [t('touchDisplayFramebuffer'), display.framebuffer || '-'], + [t('touchDisplayAutoDetect'), boolLabel(!!config.display_auto_detect)], + [t('touchDisplayOverlay'), boolLabel(!!config.manual_overlay)], + ]; + const checkboxField = (key, label, checked) => { + const id = escapeHtml(displayDomId(key)); + return ` +
+ ${escapeHtml(label)} + + + + 켜기 + 끄기 + + +
+ `; + }; + const numberField = (key, label, value, min, max, step = 1) => ` +
+ ${escapeHtml(label)} + + + +
+ `; + + return ` +
+

${escapeHtml(t('touchDisplaySettings'))}

+
+ ${statusRows.map(([key, value]) => ` +
${escapeHtml(key)}${escapeHtml(value)}
+ `).join('')} +
+
+
+ ${escapeHtml(t('touchRotation'))}deg + + + +
+ ${checkboxField('manual_overlay', t('touchDisplayOverlay'), !!config.manual_overlay)} + ${checkboxField('display_enabled', t('touchDisplayEnabled'), !!config.display_enabled)} + ${checkboxField('touch_enabled', t('touchInputEnabled'), !!config.touch_enabled)} + ${checkboxField('touch_invx', t('touchInvx'), !!config.touch_invx)} + ${checkboxField('touch_invy', t('touchInvy'), !!config.touch_invy)} + ${checkboxField('touch_swapxy', t('touchSwapxy'), !!config.touch_swapxy)} + ${numberField('touch_sizex', t('touchSizeX'), config.touch_sizex || 800, 100, 4096)} + ${numberField('touch_sizey', t('touchSizeY'), config.touch_sizey || 480, 100, 4096)} +
+
+ + /boot/firmware/config.txt, /boot/firmware/cmdline.txt +
+
+ `; + } + function renderSettings(payload = state.settingsPayload, options = {}) { if (!els.settingsBody || !payload) return; if (state.settingsOpen && state.settingsDirty && !options.force) return; @@ -718,7 +835,7 @@ return item.default ?? ''; }; - els.settingsBody.innerHTML = Object.entries(byGroup).map(([group, items]) => ` + els.settingsBody.innerHTML = renderTouchDisplaySettings(payload.touch_display) + Object.entries(byGroup).map(([group, items]) => `

${escapeHtml(groups[group] || group)}

@@ -737,7 +854,7 @@
`).join(''); - els.settingsBody.querySelectorAll('[data-setting-key]').forEach(input => { + els.settingsBody.querySelectorAll('[data-setting-key],[data-display-key]').forEach(input => { input.addEventListener('input', () => { state.settingsDirty = true; }); @@ -757,6 +874,7 @@ input.dispatchEvent(new Event('change')); }); }); + $('#touchDisplaySave', els.settingsBody)?.addEventListener('click', saveTouchDisplaySettings); } function settingByKey(key, fallback = null) { @@ -797,6 +915,35 @@ return values; } + function currentTouchDisplayFormValues() { + const values = {}; + els.settingsBody?.querySelectorAll('[data-display-key]').forEach(input => { + values[input.dataset.displayKey] = input.type === 'checkbox' + ? (input.checked ? '1' : '0') + : input.value; + }); + return values; + } + + async function saveTouchDisplaySettings() { + if (!await window.customConfirm('디스플레이 부팅 설정을 저장할까요? 저장 후 재부팅해야 적용됩니다.', { title: t('touchDisplaySettings') })) { + return; + } + try { + const data = await api('touch_display_save', { + display: JSON.stringify(currentTouchDisplayFormValues()), + }); + state.settingsDirty = false; + render(data); + renderSettings(data.settings, { force: true }); + const changed = data.touch_display_save?.changed || []; + notice(changed.length ? t('touchDisplaySaved') : t('touchDisplayNoChange'), 'success'); + } catch (e) { + console.error(e); + notice(e.message || t('settingsSaveFailed'), 'error'); + } + } + async function saveSettings() { try { const data = await api('settings_save', { diff --git a/public/index.php b/public/index.php index 36dad59..91fc027 100644 --- a/public/index.php +++ b/public/index.php @@ -145,9 +145,10 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da} .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)} +.touch-display-status{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-bottom:14px}.touch-display-status div{display:grid;gap:4px;border:1px solid rgba(128,145,170,.28);border-radius:12px;background:var(--input);padding:10px 12px;min-width:0}.touch-display-status span{color:var(--sub);font-size:12px}.touch-display-status strong{font-size:13px;font-weight:500;word-break:break-word}.settings-inline-actions{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-top:14px}.settings-note{color:var(--sub);font-size:12px;word-break:break-all} @media(max-width:900px){.chart-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} @media(max-width:1100px){.layout{grid-template-columns:1fr}.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){.chart-grid{grid-template-columns:1fr}.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)}} +@media(max-width:720px){.chart-grid{grid-template-columns:1fr}.settings-grid,.touch-display-status{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)}} @@ -363,7 +364,7 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da} - +