From ebef8796343f0249897908daa98a67b4c8210174 Mon Sep 17 00:00:00 2001 From: seo Date: Fri, 26 Jun 2026 04:42:43 +0900 Subject: [PATCH] =?UTF-8?q?Control=20=EC=84=A4=EC=A0=95=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EC=9E=85=EB=A0=A5=20=EB=B3=B4=EC=A1=B4=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +++ config/config.php | 6 +++++ public/api.php | 11 ++++---- public/assets/app.js | 61 +++++++++++++++++++++++++++++--------------- public/index.php | 4 +-- 5 files changed, 58 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index b5c0845..9bfcce0 100644 --- a/README.md +++ b/README.md @@ -113,10 +113,13 @@ WebSocket 장기 실행 프로세스에서는 1분 단위 단기 집계 결과 - 팬 자동 제어: 팬 시작 온도, 최대 온도, 즉시 최대 PWM 온도, 자동 상승/하강 PWM step - 보안 정책: 자동 로그인 유지 기간, remember 쿠키 Secure/SameSite, CSRF 길이, remember 토큰 길이, User-Agent/IP 접두사 검증, 로그인 실패 잠금, 재부팅 허용 여부, 재부팅 확인 문구, 명령 timeout, WiFi 조작 허용 여부 - 알림 정책: 배터리 긴급/위험/경고/복구 기준, 배터리 낮음/복구 알림 쿨다운, 시스템 유의사항 알림 쿨다운 +- 화면/진단 표시: 프로세스 후보 수, 사용자 서비스 로그 줄 수, 사용자 서비스 캐시 시간, 팬 이상 이력 수, HA 알림 이력 수 - 저전압/스로틀링: 최근 판정창, 복구 중 유지 시간, 반복 발생 기준 횟수 - 팬 이상감지: 기준 표본 수, 최신 표본 제외 수, RPM/온도 이상 감지 차이, RPM/온도 복구 차이, RPM 감시 시작 PWM, alert 유지 중 반복 기록, 반복 기록 간격, 하강 변화 원인 후보 표시 여부 - 배터리 예측: 단기/장기 학습 범위, 후보 필터 비율, 부하 보정 강도, 전압 하한, 용량 모델 가중치, 상태/잔여시간 차트 표본 수 +설정 모달이 열려 있는 동안 1초 상태 갱신은 폼 DOM을 다시 만들지 않습니다. 입력 중인 값은 저장, 기본값 초기화, 닫기 전까지 유지되며, 백그라운드 snapshot은 내부 설정 payload만 갱신합니다. 단순 boolean 설정은 `켜기`/`끄기` 세그먼트로 표시합니다. + ## 갱신 주기 - WebSocket 상태 갱신: 1초마다 diff --git a/config/config.php b/config/config.php index fb0d9c9..29e38da 100644 --- a/config/config.php +++ b/config/config.php @@ -179,6 +179,11 @@ function setting_definitions(): array 'notify.battery_low_cooldown_seconds' => ['group' => 'notify', 'label' => '배터리 낮음 알림 쿨다운', 'type' => 'number', 'default' => HA_NOTIFY_BATTERY_LOW_COOLDOWN, 'min' => 1, 'max' => 86400, 'step' => 1, 'unit' => '초'], 'notify.battery_clear_cooldown_seconds' => ['group' => 'notify', 'label' => '배터리 복구 알림 쿨다운', 'type' => 'number', 'default' => HA_NOTIFY_BATTERY_CLEAR_COOLDOWN, 'min' => 1, 'max' => 86400, 'step' => 1, 'unit' => '초'], 'notify.system_notice_cooldown_seconds' => ['group' => 'notify', 'label' => '시스템 유의사항 알림 쿨다운', 'type' => 'number', 'default' => HA_NOTIFY_SYSTEM_NOTICE_COOLDOWN, 'min' => 10, 'max' => 86400, 'step' => 10, 'unit' => '초'], + 'display.process_limit' => ['group' => 'display', 'label' => '프로세스 후보 표시 수', 'type' => 'number', 'default' => 6, 'min' => 1, 'max' => 30, 'step' => 1, 'unit' => '개'], + 'display.custom_service_log_lines' => ['group' => 'display', 'label' => '사용자 서비스 로그 줄 수', 'type' => 'number', 'default' => 12, 'min' => 1, 'max' => 50, 'step' => 1, 'unit' => '줄'], + 'display.custom_service_cache_seconds' => ['group' => 'display', 'label' => '사용자 서비스 캐시 시간', 'type' => 'number', 'default' => 5, 'min' => 1, 'max' => 60, 'step' => 1, 'unit' => '초'], + 'display.fan_notice_history_limit' => ['group' => 'display', 'label' => '팬 이상 이력 표시 수', 'type' => 'number', 'default' => 100, 'min' => 10, 'max' => 500, 'step' => 10, 'unit' => '개'], + 'display.ha_notify_history_limit' => ['group' => 'display', 'label' => 'HA 알림 이력 표시 수', 'type' => 'number', 'default' => 20, 'min' => 5, 'max' => 100, 'step' => 5, 'unit' => '개'], 'battery.trend_hours' => ['group' => 'battery', 'label' => '단기 배터리 학습 범위', 'type' => 'number', 'default' => 24, 'min' => 1, 'max' => 48, 'step' => 1, 'unit' => '시간'], 'battery.profile_days' => ['group' => 'battery', 'label' => '장기 배터리 학습 범위', 'type' => 'number', 'default' => 45, 'min' => 3, 'max' => 90, 'step' => 1, 'unit' => '일'], 'battery.load_adjust_recent' => ['group' => 'battery', 'label' => '최근 추세 부하 보정 강도', 'type' => 'number', 'default' => 0.45, 'min' => 0, 'max' => 1, 'step' => 0.01, 'unit' => 'x'], @@ -261,6 +266,7 @@ function settings_payload(): array $groups = [ 'security' => '보안 정책', 'notify' => '알림 정책', + 'display' => '화면/진단 표시', 'fan' => '팬 자동 제어', 'battery' => '배터리 예측', 'event' => '저전압/스로틀링', diff --git a/public/api.php b/public/api.php index 27e20e5..517f209 100644 --- a/public/api.php +++ b/public/api.php @@ -3058,7 +3058,8 @@ function systemd_service_logs(string $unit, int $limit = 12): array function custom_systemd_services(): array { $cachePath = '/tmp/control-custom-services.json'; - if (is_file($cachePath) && filemtime($cachePath) >= time() - 5) { + $cacheSeconds = (int)setting_value('display.custom_service_cache_seconds'); + if (is_file($cachePath) && filemtime($cachePath) >= time() - $cacheSeconds) { $cached = json_decode((string)@file_get_contents($cachePath), true); if (is_array($cached)) { return $cached; @@ -3104,7 +3105,7 @@ function custom_systemd_services(): array foreach ($names as $name) { $record = $records[$name] ?? []; - $logs = systemd_service_logs($name, 12); + $logs = systemd_service_logs($name, (int)setting_value('display.custom_service_log_lines')); $rows[] = [ 'name' => $name, @@ -3259,7 +3260,7 @@ function collect_snapshot(bool $applyFan = true): array ], $battery['remaining']['seconds'] ?? null); $history = array_slice($history, -(int)setting_value('battery.history_sync_limit')); - $processes = process_resource_data(6); + $processes = process_resource_data((int)setting_value('display.process_limit')); $fanSpike = fan_spike_analysis($history, $fan, $system, $processes); $shouldLogNotice = !empty($fanSpike['alert_started']) @@ -3297,8 +3298,8 @@ function collect_snapshot(bool $applyFan = true): array 'processes' => $processes, 'custom_services' => custom_systemd_services(), 'fan_spike' => $fanSpike, - 'fan_spike_history' => fan_spike_history(100), - 'ha_notify_history' => ha_notify_log_rows(20), + 'fan_spike_history' => fan_spike_history((int)setting_value('display.fan_notice_history_limit')), + 'ha_notify_history' => ha_notify_log_rows((int)setting_value('display.ha_notify_history_limit')), 'settings' => settings_payload(), ]; diff --git a/public/assets/app.js b/public/assets/app.js index 750757a..6b0e642 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -86,6 +86,7 @@ batteryTooltipText: '', settingsPayload: null, settingsOpen: false, + settingsDirty: false, }; const storageKeys = { @@ -521,9 +522,7 @@ cleanup(true); }; const onCancel = () => cleanup(mode === 'confirm' ? false : null); - const onLayerClick = event => { - if (event.target === layer) onCancel(); - }; + const onLayerClick = () => {}; const onKeydown = event => { if (event.key === 'Escape') { event.preventDefault(); @@ -606,8 +605,13 @@ return json.data; } - function renderSettings(payload = state.settingsPayload) { + function settingDomId(key) { + return `setting-${String(key).replace(/[^A-Za-z0-9_-]/g, '-')}`; + } + + function renderSettings(payload = state.settingsPayload, options = {}) { if (!els.settingsBody || !payload) return; + if (state.settingsOpen && state.settingsDirty && !options.force) return; state.settingsPayload = payload; const groups = payload.groups || {}; const byGroup = {}; @@ -618,21 +622,23 @@ }); const fieldHtml = item => { const key = escapeHtml(item.key); + const id = escapeHtml(settingDomId(item.key)); const value = item.value ?? item.default ?? ''; const type = item.type || 'number'; if (type === 'boolean') { const checked = value === true || value === 1 || value === '1' || value === 'true'; return ` - - ${checked ? '켜기' : '끄기'} + + 켜기 + 끄기 `; } if (type === 'select') { const options = item.options || {}; return ` - ${Object.entries(options).map(([optionValue, label]) => ` `).join('')} @@ -642,19 +648,25 @@ if (type === 'text') { return ` `; } return ` `; }; @@ -675,7 +687,7 @@

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

${items.map(item => ` -
`).join(''); + els.settingsBody.querySelectorAll('[data-setting-key]').forEach(input => { + input.addEventListener('input', () => { + state.settingsDirty = true; + }); + input.addEventListener('change', () => { + state.settingsDirty = true; + }); + }); els.settingsBody.querySelectorAll('.settings-toggle input').forEach(input => { input.addEventListener('change', () => { - const label = input.closest('.settings-toggle')?.querySelector('span'); - if (label) label.textContent = input.checked ? '켜기' : '끄기'; + const toggle = input.closest('.settings-toggle'); + if (toggle) toggle.dataset.checked = input.checked ? '1' : '0'; }); - input.closest('.settings-toggle')?.addEventListener('click', () => { + const toggle = input.closest('.settings-toggle'); + toggle?.setAttribute('data-checked', input.checked ? '1' : '0'); + toggle?.addEventListener('click', () => { input.checked = !input.checked; input.dispatchEvent(new Event('change')); }); @@ -716,6 +738,7 @@ return; } state.settingsOpen = true; + state.settingsDirty = false; els.settingsDialog.dataset.open = '1'; els.settingsDialog.setAttribute('aria-hidden', 'false'); } @@ -723,6 +746,7 @@ function closeSettings() { if (!els.settingsDialog) return; state.settingsOpen = false; + state.settingsDirty = false; delete els.settingsDialog.dataset.open; els.settingsDialog.setAttribute('aria-hidden', 'true'); } @@ -742,8 +766,9 @@ const data = await api('settings_save', { settings: JSON.stringify(currentSettingsFormValues()), }); + state.settingsDirty = false; render(data); - renderSettings(data.settings); + renderSettings(data.settings, { force: true }); notice(t('settingsSaved'), 'success'); } catch (e) { console.error(e); @@ -757,8 +782,9 @@ } try { const data = await api('settings_reset', {}); + state.settingsDirty = false; render(data); - renderSettings(data.settings); + renderSettings(data.settings, { force: true }); notice(t('settingsResetDone'), 'success'); } catch (e) { console.error(e); @@ -1809,7 +1835,7 @@ function render(data) { if (data.settings) { state.settingsPayload = data.settings; - if (state.settingsOpen) { + if (!state.settingsOpen) { renderSettings(data.settings); } } @@ -2062,11 +2088,6 @@ els.settingsClose?.addEventListener('click', closeSettings); els.settingsSave?.addEventListener('click', saveSettings); els.settingsReset?.addEventListener('click', resetSettings); - els.settingsDialog?.addEventListener('click', event => { - if (event.target === els.settingsDialog) { - closeSettings(); - } - }); els.translateBtn?.addEventListener('click', () => { applyLang(state.lang === 'ko' ? 'en' : 'ko'); refreshStatus(); diff --git a/public/index.php b/public/index.php index a90e9c9..d9f07a7 100644 --- a/public/index.php +++ b/public/index.php @@ -135,7 +135,7 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da} .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} .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:flex;align-items:center;width:100%;height:42px;border:1px solid var(--line);border-radius:12px;background:var(--input);padding:4px;cursor:pointer}.settings-toggle input{position:absolute;opacity:0;pointer-events:none}.settings-toggle span{display:grid;place-items:center;width:72px;height:32px;border-radius:10px;background:#334155;color:#cbd5e1;font-weight:700;font-size:12px}.settings-toggle input:checked+span{background:var(--blue);color:#fff}.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)} +.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: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)}} @@ -364,7 +364,7 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da}