(() => { 'use strict'; const $ = (s, p = document) => p.querySelector(s); const els = { updated: $('#updatedAt'), notice: $('#notice'), temp: $('#tempValue'), fanRpm: $('#fanRpm'), fanPercent: $('#fanPercent'), fanSlider: $('#fanSlider'), fanSliderWrap: $('#fanSliderWrap'), fanSliderValue: $('#fanSliderValue'), fanModeOptions: $('#fanModeOptions'), wifiTotal: $('#wifiTotal'), wifi24: $('#wifi24'), wifi5: $('#wifi5'), wifiLan: $('#wifiLan'), wifiTable: $('#wifiTable'), signalTooltip: $('#signalTooltip'), statusHost: $('#statusHost'), statusLoad: $('#statusLoad'), statusUsers: $('#statusUsers'), statusDisk: $('#statusDisk'), statusMemory: $('#statusMemory'), statusUptime: $('#statusUptime'), statusLowVoltage: $('#statusLowVoltage'), statusLowVoltageLast: $('#statusLowVoltageLast'), statusLowVoltageDuration: $('#statusLowVoltageDuration'), statusLowVoltageRecent: $('#statusLowVoltageRecent'), statusThrottling: $('#statusThrottling'), statusThrottlingLast: $('#statusThrottlingLast'), statusThrottlingDuration: $('#statusThrottlingDuration'), statusThrottlingRecent: $('#statusThrottlingRecent'), statusBatteryVoltage: $('#statusBatteryVoltage'), statusBatterySoc: $('#statusBatterySoc'), statusBatteryRemaining: $('#statusBatteryRemaining'), batteryRuntimeTooltip: $('#batteryRuntimeTooltip'), spikeLogList: $('#spikeLogList'), customServiceList: $('#customServiceList'), noticeBaseline: $('#noticeBaseline'), rebootBtn: $('#rebootBtn'), settingsBtn: $('#settingsBtn'), settingsDialog: $('#settingsDialog'), settingsBody: $('#settingsBody'), settingsClose: $('#settingsClose'), settingsSave: $('#settingsSave'), settingsReset: $('#settingsReset'), translateBtn: $('#translateBtn'), themeBtn: $('#themeBtn'), customDialog: $('#customDialog'), customDialogTitle: $('#customDialogTitle'), customDialogMessage: $('#customDialogMessage'), customDialogInput: $('#customDialogInput'), customDialogCancel: $('#customDialogCancel'), customDialogOk: $('#customDialogOk'), processCpuTable: $('#processCpuTable'), processMemoryTable: $('#processMemoryTable'), dmesgToggle: $('#dmesgToggle'), dmesgOutput: $('#dmesgOutput'), dmesgMeta: $('#dmesgMeta'), processDetails: $('#processDetails'), diagnosticDetails: $('#diagnosticDetails'), }; const state = { loading: false, charts: {}, fanDirty: false, fanApplying: false, fanApplyTimer: null, fanApplyPending: null, latestFanPwm: 0, fanCauseTick: 0, dmesgOpen: false, dmesgTimer: null, dmesgLatestKey: null, ws: null, wsConnected: false, wsFallbackTimer: null, wsReconnectTimer: null, wsReconnectDelay: 1000, rebootRequesting: false, lang: 'en', theme: 'dark', batteryTooltipOpen: false, batteryTooltipText: '', signalTooltipOpen: false, signalTooltipTarget: null, signalTooltipKey: '', signalTooltipText: '', scanScrollHoldUntil: 0, settingsPayload: null, settingsOpen: false, settingsDirty: false, customServiceScrollHoldUntil: 0, }; const storageKeys = { lang: 'controlLang', theme: 'controlTheme', }; const messages = { en: { updated: 'Updated', logout: 'Logout', fanStatus: 'Fan Status', cpuTemp: 'CPU Temp', fanRpm: 'Fan RPM', wifiStatus: 'DHCP Status', clientsTotal: 'All Devices', clients24: '2.4G', clients5: '5G', clientsLan: 'LAN', systemStatus: 'System Status', loadAvg: 'Load Avg', activeUsers: 'Active Users', diskRoot: 'Disk', memory: 'Memory', uptime: 'Uptime', lowVoltage: 'Low Voltage', lowVoltageLast: 'Last Low Voltage', lowVoltageDuration: 'LV Duration', lowVoltageRecent: 'LV 10m', lowVoltageActive: 'Active', lowVoltageNormal: 'Normal', lowVoltageRecovering: 'Recovering', lowVoltageRepeated: 'Repeated', lowVoltageSeen: 'seen since boot', lowVoltageNotSeen: 'not seen since boot', throttling: 'Throttling', throttlingLast: 'Last Throttling', throttlingDuration: 'Throttle Duration', throttlingRecent: 'Throttle 10m', throttlingActive: 'Throttling', throttlingNormal: 'Normal', throttlingRecovering: 'Recovering', throttlingRepeated: 'Repeated', throttlingSeen: 'seen since boot', throttlingNotSeen: 'not seen since boot', currentEpisode: 'current', lastEpisode: 'last', recentCount: 'events', recentActive: 'active', batteryV: 'Battery V', batterySoc: 'Battery SOC', sensorHistory: 'Sensor History', temperature: 'CPU Temperature', rp1Temp: 'RP1 Temp', cpuWatt: 'CPU Watt', remaining: 'Remaining', batteryVoltage: 'Battery Voltage', wifiClients: 'DHCP Clients', deviceName: 'Device', address: 'Address', paired: 'Paired', trusted: 'Trusted', blocked: 'Blocked', observed: 'Observed', ssid: 'SSID', channel: 'Channel', security: 'Security', lastSeen: 'Last Seen', band: 'Band', hostname: 'Hostname', signal: 'Signal', txRate: 'TX Rate', rxRate: 'RX Rate', connected: 'Connected', inactive: 'Inactive', systemNotice: 'System Notice', processDetails: 'Process Details', diagnostics: 'Diagnostics', noticeHistory: 'Notice History', noNoticeHistory: 'No system notice history.', customServices: 'Custom Services', noCustomServices: 'No custom services.', serviceLogs: 'Logs', serviceEnabled: 'enabled', servicePid: 'pid', serviceRestarts: 'restarts', wifiAliasTitle: 'WiFi Hostname', wifiAliasPrompt: 'Enter a name to keep for this MAC address.', wifiAliasSaved: 'WiFi hostname saved.', wolConfirmTitle: 'Wake LAN Device', wolConfirm: ({ hostname, mac }) => `Send Wake-on-LAN request to ${hostname || mac}?`, wolSent: 'Wake-on-LAN request sent.', wolFailed: 'Wake-on-LAN request failed', wolAlreadyOnline: 'LAN device is already online.', show: 'Show', hide: 'Hide', translateButton: 'Translate', themeButton: 'Theme', settingsButton: 'Settings', settingsTitle: 'Settings', settingsSaved: 'Settings saved', settingsResetDone: 'Settings reset', 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', dialogInput: 'Input', dialogOk: 'OK', dialogCancel: 'Cancel', noWifiClients: 'No connected DHCP clients', noProcessActivity: 'No process activity', noCause: 'No candidate', recordedReason: 'Recorded reason', instantChange: 'instant change', tempHigher: 'temperature above average', tempLower: 'temperature below average', rpmHigher: 'fan RPM above average', rpmLower: 'fan RPM below average', average: 'Average', current: 'Current', causeCandidate: 'Candidate', hostLabel: 'Host', ipLabel: 'IP', created: 'Created', encoding: 'Encoding', endpoint: 'Endpoint', userAgent: 'UA', hash: 'Hash', na: 'N/A', max: 'MAX', avg: 'AVG', min: 'MIN', minuteAgo: ({ minutes }) => `${minutes}m ago`, unnamed: 'Unnamed', ago: 'ago', refreshFailed: 'refresh failed', applyingFan: 'Applying fan policy...', fanUpdated: 'Fan updated', fanUpdateFailed: 'fan update failed', dmesgUnavailable: 'dmesg log is not available yet.', dmesgRefreshFailed: 'dmesg refresh failed', rebootPrompt: 'Type reboot below to continue.', rebootPhrase: 'reboot', rebootTitle: 'System Reboot', next: 'Next', rebootCanceled: 'Reboot canceled', rebootPhraseMismatch: 'Confirmation word does not match.', adminConfirm: 'Admin Check', passwordPrompt: 'Enter the administrator password again.', rebootSending: 'Sending reboot command...', rebootStarted: 'Reboot Started', rebootStartedBody: 'Reboot command sent. The connection may drop shortly.', rebootFailed: 'Reboot Failed', delete: 'Delete', wakeLockTitle: 'Prevent screen sleep', wakeLockUnsupported: 'WakeLock is not supported by this browser.', wakeLockErrorTitle: 'WakeLock Error', }, ko: { updated: '갱신', logout: '로그아웃', fanStatus: '팬 상태', cpuTemp: 'CPU 온도', fanRpm: '팬 RPM', wifiStatus: 'DHCP 상태', clientsTotal: '전체 기기', clients24: '2.4G', clients5: '5G', clientsLan: 'LAN', systemStatus: '시스템 상태', loadAvg: '부하 평균', activeUsers: '활성 사용자', diskRoot: '디스크', memory: '메모리', uptime: '가동 시간', lowVoltage: '저전압', lowVoltageLast: '최근 저전압', lowVoltageDuration: '저전압 지속', lowVoltageRecent: '저전압 10분', lowVoltageActive: '감지 중', lowVoltageNormal: '정상', lowVoltageRecovering: '복구 중', lowVoltageRepeated: '반복 발생', lowVoltageSeen: '부팅 후 이력 있음', lowVoltageNotSeen: '부팅 후 이력 없음', throttling: '스로틀링', throttlingLast: '최근 스로틀링', throttlingDuration: '스로틀링 지속', throttlingRecent: '스로틀링 10분', throttlingActive: '감지 중', throttlingNormal: '정상', throttlingRecovering: '복구 중', throttlingRepeated: '반복 발생', throttlingSeen: '부팅 후 이력 있음', throttlingNotSeen: '부팅 후 이력 없음', currentEpisode: '현재', lastEpisode: '최근', recentCount: '발생', recentActive: '감지', batteryV: '배터리 전압', batterySoc: '배터리 SOC', sensorHistory: '센서 이력', temperature: 'CPU 온도', rp1Temp: 'RP1 온도', cpuWatt: 'CPU 전력', remaining: '잔여 시간', batteryVoltage: '배터리 전압', wifiClients: 'DHCP 클라이언트', deviceName: '장치', address: '주소', paired: '페어링', trusted: '신뢰', blocked: '차단', observed: '관측', ssid: 'SSID', channel: '채널', security: '보안', lastSeen: '마지막 감지', band: '대역', hostname: '호스트명', signal: '신호', txRate: '송신 속도', rxRate: '수신 속도', connected: '연결 시간', inactive: '비활성', systemNotice: '시스템 알림', processDetails: '프로세스 상세', diagnostics: '진단', noticeHistory: '알림 이력', noNoticeHistory: '시스템 알림 이력이 없습니다.', customServices: '사용자 서비스', noCustomServices: '사용자 생성 서비스가 없습니다.', serviceLogs: '로그', serviceEnabled: '활성화', servicePid: 'PID', serviceRestarts: '재시작', wifiAliasTitle: 'WiFi 호스트명', wifiAliasPrompt: '이 MAC 주소에 계속 표시할 이름을 입력하세요.', wifiAliasSaved: 'WiFi 호스트명을 저장했습니다.', wolConfirmTitle: 'LAN 기기 켜기', wolConfirm: ({ hostname, mac }) => `${hostname || mac} 기기에 WOL 요청을 보낼까요?`, wolSent: 'WOL 요청을 보냈습니다.', wolFailed: 'WOL 요청 실패', wolAlreadyOnline: 'LAN 기기가 이미 켜져 있습니다.', show: '보기', hide: '숨기기', translateButton: '번역', themeButton: '테마', settingsButton: '설정', settingsTitle: '설정', settingsSaved: '설정을 저장했습니다.', settingsResetDone: '설정을 기본값으로 되돌렸습니다.', 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: '확인', dialogInput: '입력', dialogOk: '확인', dialogCancel: '취소', noWifiClients: '연결된 DHCP 클라이언트 없음', noProcessActivity: '프로세스 활동 없음', noCause: '원인 후보 없음', recordedReason: '기록된 이유', instantChange: '순간 변화', tempHigher: '온도 평균보다 상승', tempLower: '온도 평균보다 하강', rpmHigher: '팬RPM 평균보다 상승', rpmLower: '팬RPM 평균보다 하강', average: '평균', current: '현재', causeCandidate: '원인 후보', hostLabel: '호스트', ipLabel: 'IP', created: '생성', encoding: '인코딩', endpoint: 'Endpoint', userAgent: 'UA', hash: 'Hash', na: 'N/A', max: '최대', avg: '평균', min: '최소', minuteAgo: ({ minutes }) => `${minutes}분 전`, unnamed: '이름 없음', ago: '전', refreshFailed: '갱신 실패', applyingFan: '팬 정책 적용 중...', fanUpdated: '팬 갱신 완료', fanUpdateFailed: '팬 갱신 실패', dmesgUnavailable: 'dmesg 로그를 아직 사용할 수 없습니다.', dmesgRefreshFailed: 'dmesg 갱신 실패', rebootPrompt: '재부팅을 계속하려면 아래에 재부팅을 입력하세요.', rebootPhrase: '재부팅', rebootTitle: '시스템 재부팅', next: '다음', rebootCanceled: '재부팅 취소', rebootPhraseMismatch: '확인 단어가 일치하지 않습니다.', adminConfirm: '관리자 확인', passwordPrompt: '관리자 암호를 다시 입력하세요.', rebootSending: '재부팅 명령 전송 중...', rebootStarted: '재부팅 시작', rebootStartedBody: '재부팅 명령을 전송했습니다. 잠시 후 연결이 끊어질 수 있습니다.', rebootFailed: '재부팅 실패', delete: '삭제', wakeLockTitle: '화면 꺼짐 방지', wakeLockUnsupported: '현재 브라우저에서 WakeLock을 지원하지 않습니다.', wakeLockErrorTitle: 'WakeLock 오류', }, }; function defaultLang() { return String(navigator.language || '').toLowerCase().startsWith('ko') ? 'ko' : 'en'; } function t(key, params = null) { const value = messages[state.lang]?.[key] ?? messages.en[key] ?? key; return typeof value === 'function' ? value(params || {}) : value; } window.controlT = t; function applyTheme(theme) { state.theme = theme === 'light' ? 'light' : 'dark'; localStorage.setItem(storageKeys.theme, state.theme); document.documentElement.dataset.theme = state.theme; const themeMeta = document.querySelector('meta[name="theme-color"]'); if (themeMeta) { themeMeta.content = state.theme === 'light' ? '#f4f7fb' : '#0f1115'; } els.themeBtn && (els.themeBtn.textContent = t('themeButton')); } function applyLang(lang) { state.lang = lang === 'ko' ? 'ko' : 'en'; localStorage.setItem(storageKeys.lang, state.lang); document.documentElement.lang = state.lang; document.querySelectorAll('[data-i18n]').forEach(node => { node.textContent = t(node.dataset.i18n); }); els.translateBtn && (els.translateBtn.textContent = t('translateButton')); els.themeBtn && (els.themeBtn.textContent = t('themeButton')); els.settingsBtn && (els.settingsBtn.textContent = t('settingsButton')); els.rebootBtn && (els.rebootBtn.textContent = t('rebootButton')); if (els.dmesgToggle) { els.dmesgToggle.textContent = state.dmesgOpen ? t('hide') : t('show'); } } function initPreferences() { state.lang = localStorage.getItem(storageKeys.lang) || defaultLang(); state.theme = localStorage.getItem(storageKeys.theme) || 'dark'; applyTheme(state.theme); applyLang(state.lang); } function markFanDirty() { state.fanDirty = true; } function isFanEditing() { return state.fanApplying || state.fanDirty || document.activeElement === els.fanSlider; } function fanModeInputs() { return Array.from(document.querySelectorAll('input[name="fanModeOption"]')); } function selectedFanMode() { return document.querySelector('input[name="fanModeOption"]:checked')?.value || 'auto'; } function setSelectedFanMode(mode) { const normalized = ['auto', 'manual', 'off'].includes(mode) ? mode : 'auto'; fanModeInputs().forEach(input => { input.checked = input.value === normalized; }); updateFanModeUi(normalized); } function updateFanModeUi(mode = selectedFanMode()) { if (els.fanSliderWrap) { els.fanSliderWrap.hidden = mode !== 'manual'; } } function csrf() { return document.querySelector('meta[name="csrf-token"]')?.content || ''; } function escapeHtml(v) { return String(v ?? 'N/A') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"'); } function notice(text, type = 'info') { if (!els.notice) return; els.notice.textContent = text; els.notice.dataset.type = type; clearTimeout(els.notice._timer); els.notice._timer = setTimeout(() => { els.notice.textContent = ''; els.notice.dataset.type = ''; }, 2600); } function customDialog(options = {}) { const layer = els.customDialog; const input = els.customDialogInput; const ok = els.customDialogOk; const cancel = els.customDialogCancel; if (!layer || !input || !ok || !cancel) { return Promise.resolve(null); } return new Promise(resolve => { const mode = options.mode || 'alert'; const needsInput = mode === 'prompt'; const previousFocus = document.activeElement; let settled = false; const cleanup = value => { if (settled) return; settled = true; layer.dataset.open = '0'; layer.setAttribute('aria-hidden', 'true'); ok.classList.remove('red'); ok.textContent = '확인'; cancel.textContent = '취소'; input.value = ''; input.hidden = true; input.type = 'text'; ok.removeEventListener('click', onOk); cancel.removeEventListener('click', onCancel); layer.removeEventListener('click', onLayerClick); document.removeEventListener('keydown', onKeydown); if (previousFocus && typeof previousFocus.focus === 'function') { previousFocus.focus(); } resolve(value); }; const onOk = () => { if (needsInput) { cleanup(input.value); return; } cleanup(true); }; const onCancel = () => cleanup(mode === 'confirm' ? false : null); const onLayerClick = () => {}; const onKeydown = event => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); onCancel(); return; } if (event.key === 'Enter' && (document.activeElement === input || !needsInput)) { event.preventDefault(); event.stopPropagation(); onOk(); } }; els.customDialogTitle.textContent = options.title || t('dialogConfirm'); els.customDialogMessage.textContent = options.message || ''; ok.textContent = options.okText || t('dialogOk'); cancel.textContent = options.cancelText || t('dialogCancel'); ok.classList.toggle('red', options.danger === true); cancel.hidden = mode === 'alert'; input.hidden = !needsInput; input.type = options.inputType || 'text'; input.placeholder = options.placeholder || ''; input.autocomplete = options.autocomplete || 'off'; input.value = options.defaultValue || ''; ok.addEventListener('click', onOk); cancel.addEventListener('click', onCancel); layer.addEventListener('click', onLayerClick); document.addEventListener('keydown', onKeydown); layer.dataset.open = '1'; layer.setAttribute('aria-hidden', 'false'); setTimeout(() => (needsInput ? input : ok).focus(), 20); }); } window.customAlert = (message, options = {}) => customDialog(Object.assign({ mode: 'alert', title: t('dialogAlert'), message, }, options)); window.customConfirm = (message, options = {}) => customDialog(Object.assign({ mode: 'confirm', title: t('dialogConfirm'), message, }, options)); window.customPrompt = (message, options = {}) => customDialog(Object.assign({ mode: 'prompt', title: t('dialogInput'), message, }, options)); function apiErrorMessage(message) { const map = { device_already_online: t('wolAlreadyOnline'), }; return map[message] || message; } async function api(action, body = null) { const opts = { method: body ? 'POST' : 'GET', credentials: 'same-origin', headers: {}, cache: 'no-store', }; let url = '/api.php?action=' + encodeURIComponent(action) + '&_=' + Date.now(); if (body) { const fd = new URLSearchParams(); Object.entries(body).forEach(([k, v]) => fd.append(k, String(v))); fd.append('action', action); fd.append('csrf', csrf()); opts.method = 'POST'; opts.headers['Content-Type'] = 'application/x-www-form-urlencoded'; opts.headers['X-CSRF-Token'] = csrf(); opts.body = fd.toString(); url = '/api.php'; } const res = await fetch(url, opts); const json = await res.json(); if (!res.ok || !json.ok) { throw new Error(apiErrorMessage(json?.message || json?.error || 'API error')); } return json.data; } function settingDomId(key) { 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; state.settingsPayload = payload; const groups = payload.groups || {}; const byGroup = {}; (payload.items || []).forEach(item => { const group = item.group || 'etc'; if (!byGroup[group]) byGroup[group] = []; byGroup[group].push(item); }); 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 ` 켜기 끄기 `; } if (type === 'select') { const options = item.options || {}; return ` `; } if (type === 'text') { return ` `; } return ` `; }; const defaultValueText = item => { if (item.type === 'select' && item.options && item.options[item.default]) { return item.options[item.default]; } if (item.type === 'boolean') { return item.default === true || item.default === 1 || item.default === '1' || item.default === 'true' ? '켜기' : '끄기'; } return item.default ?? ''; }; els.settingsBody.innerHTML = renderTouchDisplaySettings(payload.touch_display) + Object.entries(byGroup).map(([group, items]) => `

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

${items.map(item => `
${escapeHtml(item.label || item.key)} ${escapeHtml(item.unit || '')} ${fieldHtml(item)} ${t('settingsDefault')} ${escapeHtml(defaultValueText(item))}
`).join('')}
`).join(''); els.settingsBody.querySelectorAll('[data-setting-key],[data-display-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 toggle = input.closest('.settings-toggle'); if (toggle) toggle.dataset.checked = input.checked ? '1' : '0'; }); 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')); }); }); $('#touchDisplaySave', els.settingsBody)?.addEventListener('click', saveTouchDisplaySettings); } function settingByKey(key, fallback = null) { const item = (state.settingsPayload?.items || []).find(row => row.key === key); return item ? item.value : fallback; } async function openSettings() { if (!els.settingsDialog) return; try { renderSettings(await api('settings')); } catch (e) { console.error(e); notice(e.message || t('refreshFailed'), 'error'); return; } state.settingsOpen = true; state.settingsDirty = false; els.settingsDialog.dataset.open = '1'; els.settingsDialog.setAttribute('aria-hidden', 'false'); } function closeSettings() { if (!els.settingsDialog) return; state.settingsOpen = false; state.settingsDirty = false; delete els.settingsDialog.dataset.open; els.settingsDialog.setAttribute('aria-hidden', 'true'); } function currentSettingsFormValues() { const values = {}; els.settingsBody?.querySelectorAll('[data-setting-key]').forEach(input => { values[input.dataset.settingKey] = input.type === 'checkbox' ? (input.checked ? '1' : '0') : input.value; }); 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', { settings: JSON.stringify(currentSettingsFormValues()), }); state.settingsDirty = false; render(data); renderSettings(data.settings, { force: true }); notice(t('settingsSaved'), 'success'); } catch (e) { console.error(e); notice(e.message || t('settingsSaveFailed'), 'error'); } } async function resetSettings() { if (!await window.customConfirm(t('settingsResetConfirm'), { title: t('settingsTitle') })) { return; } try { const data = await api('settings_reset', {}); state.settingsDirty = false; render(data); renderSettings(data.settings, { force: true }); notice(t('settingsResetDone'), 'success'); } catch (e) { console.error(e); notice(e.message || t('settingsSaveFailed'), 'error'); } } function websocketUrl() { const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:'; return `${scheme}//${location.host}/ws`; } function sendWs(payload) { if (!state.wsConnected || !state.ws || state.ws.readyState !== WebSocket.OPEN) { return false; } state.ws.send(JSON.stringify(payload)); return true; } function stopStatusFallback() { clearInterval(state.wsFallbackTimer); state.wsFallbackTimer = null; } function startStatusFallback() { if (state.wsFallbackTimer) return; refreshStatus(); state.wsFallbackTimer = setInterval(() => { if (!state.wsConnected) { refreshStatus(); } }, 2000); } function connectControlSocket() { if (!('WebSocket' in window)) { startStatusFallback(); return; } clearTimeout(state.wsReconnectTimer); try { state.ws = new WebSocket(websocketUrl()); } catch (e) { console.error(e); startStatusFallback(); return; } state.ws.addEventListener('open', () => { state.wsConnected = true; state.wsReconnectDelay = 1000; stopStatusFallback(); if (state.dmesgOpen) { stopDmesgFallback(); sendWs({ type: 'dmesg', open: true }); } }); state.ws.addEventListener('message', event => { let message = null; try { message = JSON.parse(event.data); } catch (e) { console.error(e); return; } if (message?.type === 'status') { render(message.data || {}); return; } if (message?.type === 'dmesg') { renderDmesg(message.data || {}); return; } if (message?.type === 'error') { console.error(message.message || 'websocket error'); } }); state.ws.addEventListener('close', () => { state.wsConnected = false; startStatusFallback(); if (state.dmesgOpen) { startDmesgFallback(); } const delay = state.wsReconnectDelay; state.wsReconnectDelay = Math.min(15000, state.wsReconnectDelay * 1.7); state.wsReconnectTimer = setTimeout(connectControlSocket, delay); }); state.ws.addEventListener('error', event => { console.error(event); }); } function setText(node, value) { if (node) node.textContent = value ?? 'N/A'; } function batteryRemainingTitle(remaining) { if (!remaining || typeof remaining !== 'object') { return ''; } const sourceMap = { soc_multi_window: 'SOC 다중 시간창 추세', capacity_power_fallback: '배터리 용량과 최근 전력 평균', battery_soc_missing: 'SOC 없음', avg_watts_missing: '전력 평균 없음', battery_capacity_missing: '배터리 용량 설정 없음', }; const lines = [sourceMap[remaining.source] || remaining.source || '-']; 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)}%`); } if (remaining.confidence !== undefined && remaining.confidence !== null) { lines.push(`추세 신뢰도: ${(Number(remaining.confidence) * 100).toFixed(0)}%`); } if (remaining.candidate_count !== undefined && remaining.candidate_count !== null) { const spread = remaining.rate_spread === undefined || remaining.rate_spread === null ? '' : ` / 후보 분산 ${(Number(remaining.rate_spread) * 100).toFixed(1)}%`; lines.push(`후보 수: ${remaining.candidate_count}${spread}`); } if (remaining.recent_watts !== undefined && remaining.recent_watts !== null) { lines.push(`최근 CPU 전력: ${Number(remaining.recent_watts).toFixed(3)}W`); } else if (remaining.avg_watts !== undefined && remaining.avg_watts !== null) { lines.push(`평균 CPU 전력: ${Number(remaining.avg_watts).toFixed(3)}W`); } if (Array.isArray(remaining.sources) && remaining.sources.length > 0) { const labels = { recent_window: '최근 추세', robust_regression: '강건 회귀', voltage_slope: '전압 기울기', energy_profile: '에너지 학습', capacity_power_model: '용량 전력 모델', learned_profile: '장기 학습', }; lines.push(`계산 출처: ${remaining.sources.map(source => labels[source] || source).join(', ')}`); } if (Array.isArray(remaining.windows) && remaining.windows.length > 0) { lines.push(`반영 구간: ${remaining.windows.map(row => { if (row.source === 'learned_profile') { return `장기 ${row.intervals || 0}구간`; } if (row.source === 'robust_regression') { return `회귀 ${row.minutes}분`; } if (row.source === 'voltage_slope') { return `전압 ${row.minutes}분`; } if (row.source === 'energy_profile') { return `에너지 ${row.intervals || 0}구간`; } if (row.source === 'capacity_power_model') { return '용량 모델'; } return `${row.minutes}분`; }).join(', ')}`); lines.push(''); lines.push('세부 후보'); remaining.windows.forEach(row => { if (row.source === 'learned_profile') { 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)}%`; 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)}%`; 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`; 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)}%`; 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}`); } }); } return lines.join('\n'); } function positionBatteryTooltip() { if (!state.batteryTooltipOpen || !els.statusBatteryRemaining || !els.batteryRuntimeTooltip) { return; } const targetRect = els.statusBatteryRemaining.getBoundingClientRect(); const tip = els.batteryRuntimeTooltip; const margin = 14; tip.style.left = '0px'; tip.style.top = '0px'; const tipRect = tip.getBoundingClientRect(); let left = targetRect.left; let top = targetRect.bottom + 10; if (left + tipRect.width > window.innerWidth - margin) { left = window.innerWidth - tipRect.width - margin; } if (left < margin) { left = margin; } if (top + tipRect.height > window.innerHeight - margin) { top = targetRect.top - tipRect.height - 10; } if (top < margin) { top = margin; } tip.style.left = `${Math.round(left)}px`; tip.style.top = `${Math.round(top)}px`; } function updateBatteryTooltip(text = state.batteryTooltipText) { state.batteryTooltipText = text || ''; if (!els.batteryRuntimeTooltip) return; els.batteryRuntimeTooltip.textContent = state.batteryTooltipText || '-'; if (state.batteryTooltipOpen) { requestAnimationFrame(positionBatteryTooltip); } } function showBatteryTooltip() { if (!els.batteryRuntimeTooltip) return; state.batteryTooltipOpen = true; els.batteryRuntimeTooltip.dataset.open = '1'; els.batteryRuntimeTooltip.setAttribute('aria-hidden', 'false'); updateBatteryTooltip(); } function hideBatteryTooltip() { if (!els.batteryRuntimeTooltip) return; state.batteryTooltipOpen = false; delete els.batteryRuntimeTooltip.dataset.open; els.batteryRuntimeTooltip.setAttribute('aria-hidden', 'true'); } function signalTooltipTarget() { if (state.signalTooltipKey && typeof document.querySelector === 'function') { const selector = `[data-signal-key="${cssEscape(state.signalTooltipKey)}"]`; const fresh = document.querySelector(selector); if (fresh) { state.signalTooltipTarget = fresh; return fresh; } } return state.signalTooltipTarget?.isConnected ? state.signalTooltipTarget : null; } function signalTooltipText(target = signalTooltipTarget()) { if (!target) return '-'; const label = target.dataset.signalLabel || target.textContent || '-'; const dbm = target.dataset.signalDbm || '-'; return `${label}\n${dbm}`; } function positionSignalTooltip() { if (!state.signalTooltipOpen || !els.signalTooltip) { return; } const target = signalTooltipTarget(); if (!target) { hideSignalTooltip(); return; } const targetRect = target.getBoundingClientRect(); const tip = els.signalTooltip; const margin = 14; tip.style.left = '0px'; tip.style.top = '0px'; const tipRect = tip.getBoundingClientRect(); let left = targetRect.left + (targetRect.width / 2) - (tipRect.width / 2); let top = targetRect.bottom + 10; if (left + tipRect.width > window.innerWidth - margin) { left = window.innerWidth - tipRect.width - margin; } if (left < margin) { left = margin; } if (top + tipRect.height > window.innerHeight - margin) { top = targetRect.top - tipRect.height - 10; } if (top < margin) { top = margin; } tip.style.left = `${Math.round(left)}px`; tip.style.top = `${Math.round(top)}px`; } function updateSignalTooltip() { if (!els.signalTooltip) return; state.signalTooltipText = signalTooltipText(); els.signalTooltip.textContent = state.signalTooltipText || '-'; if (state.signalTooltipOpen) { requestAnimationFrame(positionSignalTooltip); } } function showSignalTooltip(target) { if (!els.signalTooltip || !target || target.classList.contains('unknown')) return; state.signalTooltipOpen = true; state.signalTooltipTarget = target; state.signalTooltipKey = target.dataset.signalKey || ''; els.signalTooltip.dataset.open = '1'; els.signalTooltip.setAttribute('aria-hidden', 'false'); updateSignalTooltip(); } function hideSignalTooltip() { if (!els.signalTooltip) return; state.signalTooltipOpen = false; state.signalTooltipTarget = null; state.signalTooltipKey = ''; delete els.signalTooltip.dataset.open; els.signalTooltip.setAttribute('aria-hidden', 'true'); } function cssEscape(value) { if (window.CSS?.escape) { return window.CSS.escape(value); } return String(value).replace(/["\\]/g, '\\$&'); } function renderTop(data) { setText(els.updated, data.generated_at || '-'); setText(els.temp, Number(data.system?.temp_c || 0).toFixed(1) + '°C'); setText(els.fanRpm, Number(data.fan?.rpm || 0).toLocaleString() + ' RPM'); setText(els.fanPercent, Number(data.fan?.percent || 0).toFixed(1) + '%'); const count24 = Number(data.wifi?.count24 ?? 0); const count5 = Number(data.wifi?.count5 ?? 0); const countLan = Number(data.wifi?.countLan ?? 0); setText(els.wifiTotal, count24 + count5 + countLan); setText(els.wifiLan, countLan); setText(els.wifi24, count24); setText(els.wifi5, count5); state.latestFanPwm = Math.max(0, Math.min(255, Number(data.fan?.pwm || 0))); if (!isFanEditing()) { const serverPwm = data.fan?.target_pwm ?? data.fan?.pwm ?? 120; if (els.fanSlider) { els.fanSlider.value = serverPwm; } if (els.fanSliderValue) { els.fanSliderValue.textContent = String(serverPwm); } } if (!state.fanApplying) setSelectedFanMode(data.fan?.mode || 'auto'); } function td(v) { return `${escapeHtml(v)}`; } function attr(v) { return escapeHtml(v).replaceAll("'", '''); } function wifiValueTitle(row, key) { const source = row?.[`${key}_source`]; if (!source || source === 'iw' || source === 'lease') return ''; if (source.startsWith('estimated_')) return ''; const labels = { alias: 'DB 저장 이름', generated: 'MAC 기반 임시 이름', }; return labels[source] || source; } function wifiHostCell(row) { const title = wifiValueTitle(row, 'hostname') || t('wifiAliasPrompt'); return ``; } function wifiBandCell(row) { if (row?.band !== 'LAN' || !row?.wol_available) { return td(row?.band || '-'); } return ``; } function wifiMetricCell(row, key) { if (key !== 'signal') { return `${escapeHtml(row[key] || '-')}`; } const dbm = wifiSignalDbm(row[key]); return `${signalBadgeHtml(dbm, row[key] || '', `client:${row.mac || row.ip || row.hostname || ''}`)}`; } function wifiRateCells(row) { if (row?.band === 'LAN') { return `${escapeHtml(wifiCellValue(row, 'tx_bitrate'))}`; } return `${wifiMetricCell(row, 'tx_bitrate')}${wifiMetricCell(row, 'rx_bitrate')}`; } function wifiSignalDbm(value) { const match = String(value ?? '').match(/-?\d+(?:\.\d+)?/); return match ? Number(match[0]) : null; } function signalQuality(dbm) { if (dbm === null || !Number.isFinite(dbm)) return 'unknown'; if (dbm >= -45) return { className: 'best', label: '최상' }; if (dbm >= -55) return { className: 'excellent', label: '매우 좋음' }; if (dbm >= -65) return { className: 'good', label: '좋음' }; if (dbm >= -72) return { className: 'fine', label: '양호' }; if (dbm >= -80) return { className: 'normal', label: '보통' }; if (dbm >= -88) return { className: 'weak', label: '약함' }; return { className: 'bad', label: '매우 약함' }; } function signalDbmText(dbm, raw = '') { if (dbm !== null && Number.isFinite(dbm)) { return `${Number(dbm).toFixed(Number.isInteger(dbm) ? 0 : 1)} dBm`; } return String(raw || '').trim() || '-'; } function signalBadgeHtml(dbm, raw = '', key = '') { const quality = signalQuality(dbm); if (typeof quality === 'string') { return `-`; } const keyAttr = key ? ` data-signal-key="${attr(key)}"` : ''; return `${escapeHtml(quality.label)}`; } async function editWifiHostname(mac, currentName) { if (!mac) return; const value = await window.customPrompt(`${t('wifiAliasPrompt')}\n${mac}`, { title: t('wifiAliasTitle'), defaultValue: currentName || '', okText: t('dialogOk'), }); if (value === null) return; const hostname = String(value || '').trim(); if (!hostname) return; try { const data = await api('wifi_alias', { mac, hostname }); notice(t('wifiAliasSaved'), 'success'); render(data.status || await api('status')); } catch (e) { console.error(e); notice(e.message || 'WiFi alias failed', 'error'); } } async function wakeLanDevice(mac, hostname = '') { if (!mac) return; const confirmed = await window.customConfirm(t('wolConfirm', { hostname, mac }), { title: t('wolConfirmTitle'), okText: t('dialogOk'), cancelText: t('dialogCancel'), }); if (!confirmed) return; try { const data = await api('wake_lan', { mac }); notice(t('wolSent'), 'success'); render(data.status || await api('status')); } catch (e) { console.error(e); notice(e.message || t('wolFailed'), 'error'); } } function parseWifiDurationSeconds(value) { const text = String(value ?? '').trim(); if (text === '' || text.toUpperCase() === 'N/A') { return null; } if (/^\d+(?:\.\d+)?$/.test(text)) { return Number(text); } let total = 0; let matched = false; const pattern = /(\d+(?:\.\d+)?)\s*(days?|d|hours?|hrs?|h|minutes?|mins?|min|m|seconds?|secs?|sec|s|milliseconds?|msecs?|msec|ms)\b/gi; let match; while ((match = pattern.exec(text)) !== null) { const amount = Number(match[1]); const unit = match[2].toLowerCase(); matched = true; if (unit === 'd' || unit.startsWith('day')) { total += amount * 86400; } else if (unit === 'h' || unit.startsWith('hour') || unit.startsWith('hr')) { total += amount * 3600; } else if (unit === 'm' || unit.startsWith('min')) { total += amount * 60; } else if (unit === 'ms' || unit.startsWith('msec') || unit.startsWith('millisecond')) { total += amount / 1000; } else { total += amount; } } return matched ? total : null; } function formatDhms(seconds) { if (seconds === null || seconds === undefined || !Number.isFinite(Number(seconds))) { return null; } const totalSeconds = Math.max(0, Math.round(Number(seconds))); const days = Math.floor(totalSeconds / 86400); const hours = Math.floor((totalSeconds % 86400) / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const secs = totalSeconds % 60; const parts = []; if (state.lang === 'ko') { if (days > 0) parts.push(`${days}일`); if (days > 0 || hours > 0) parts.push(`${hours}시`); if (days > 0 || hours > 0 || minutes > 0) parts.push(`${minutes}분`); parts.push(`${secs}초`); } else { if (days > 0) parts.push(`${days}d`); if (days > 0 || hours > 0) parts.push(`${hours}h`); if (days > 0 || hours > 0 || minutes > 0) parts.push(`${minutes}m`); parts.push(`${secs}s`); } return parts.join(' '); } function wifiConnectedTime(value) { return formatDhms(parseWifiDurationSeconds(value)) || value; } function wifiInactiveTime(value) { const text = String(value ?? '').trim(); if (text === '' || text.toUpperCase() === 'N/A' || text === '-') { return '-'; } const msMatch = text.match(/^(\d+(?:\.\d+)?)\s*ms$/i); if (msMatch) { const ms = Number(msMatch[1]); if (Number.isFinite(ms) && ms <= 1000) { return `${Math.round(ms)} ms`; } return formatDhms(ms / 1000) || text; } return formatDhms(parseWifiDurationSeconds(text)) || text; } function wifiCellValue(row, key) { const value = row?.[key]; const text = String(value ?? '').trim(); if (row?.band === 'LAN' && (text === '' || text.toUpperCase() === 'N/A')) { return '-'; } return text || '-'; } function wifiConnectedCellValue(row) { if (row?.band === 'LAN' && wifiCellValue(row, 'connected_time') === '-') { return '-'; } return wifiConnectedTime(row?.connected_time); } function eventStateText(eventState, activeKey, normalKey, recoveringKey, repeatedKey, seenKey, notSeenKey) { if (!eventState || eventState.available === false) { return t('na'); } let stateText = t(normalKey); if (eventState.state_label === 'repeated') { stateText = t(repeatedKey); } else if (eventState.state_label === 'recovering') { stateText = t(recoveringKey); } else if (eventState.active) { stateText = t(activeKey); } const seenText = eventState.seen_since_boot ? t(seenKey) : t(notSeenKey); return `${stateText} (${seenText})`; } function eventRecentText(eventState) { if (!eventState || eventState.available === false) { return t('na'); } const count = Number(eventState.recent_event_count ?? 0); const seconds = Number(eventState.recent_active_seconds ?? 0); const percent = Number(eventState.recent_active_percent ?? 0); const activeText = formatDhms(seconds) || '0s'; return `${count}${state.lang === 'ko' ? '회' : 'x'} / ${t('recentActive')} ${activeText} (${percent.toFixed(1)}%)`; } function eventDurationText(eventState) { if (!eventState || eventState.available === false) { return t('na'); } const seconds = Number(eventState.duration_seconds ?? 0); const label = eventState.active ? t('currentEpisode') : t('lastEpisode'); const formatted = formatDhms(seconds) || '-'; return `${label}: ${formatted}`; } function lowVoltageStateText(lowVoltage) { return eventStateText(lowVoltage, 'lowVoltageActive', 'lowVoltageNormal', 'lowVoltageRecovering', 'lowVoltageRepeated', 'lowVoltageSeen', 'lowVoltageNotSeen'); } function throttlingStateText(throttling) { return eventStateText(throttling, 'throttlingActive', 'throttlingNormal', 'throttlingRecovering', 'throttlingRepeated', 'throttlingSeen', 'throttlingNotSeen'); } function timeAgo(seconds) { const formatted = formatDhms(seconds); return formatted ? `${formatted} ${t('ago')}` : '-'; } function renderWifi(data) { const rows = data.wifi?.clients || []; if (!els.wifiTable) return; const tableWrap = els.wifiTable.closest('.table-wrap'); const scrollLeft = tableWrap ? tableWrap.scrollLeft : 0; els.wifiTable.innerHTML = rows.length ? rows.map(row => ` ${wifiBandCell(row)} ${wifiHostCell(row)} ${td(row.ip)} ${td(row.mac)} ${wifiMetricCell(row, 'signal')} ${wifiRateCells(row)} ${td(wifiConnectedCellValue(row))} ${td(wifiInactiveTime(row.inactive_time))} `).join('') : `${escapeHtml(t('noWifiClients'))}`; if (tableWrap) { tableWrap.scrollLeft = scrollLeft; } } function renderSystemStatus(data) { const system = data.system || {}; const disk = system.disk || {}; const memory = system.memory || {}; const battery = data.battery || {}; const lowVoltage = system.low_voltage || {}; const throttling = system.throttling || {}; const load = Array.isArray(system.load) ? system.load : []; const activeUsers = system.active_users || {}; setText(els.statusHost, system.hostname || '-'); setText(els.statusLoad, load.length ? load.map(v => Number(v || 0).toFixed(2)).join(' / ') : '-'); setText(els.statusUsers, activeUsers.display || `0 users / 0 sessions`); setText(els.statusDisk, `${Number(disk.used_kb || 0).toLocaleString()} / ${Number(disk.total_kb || 0).toLocaleString()} KB (${disk.percent ?? '-'}%)`); setText(els.statusMemory, `${memory.used_mb ?? '-'} / ${memory.total_mb ?? '-'} MB (${memory.percent ?? '-'}%)`); setText(els.statusUptime, system.uptime || '-'); setText(els.statusLowVoltage, lowVoltageStateText(lowVoltage)); setText(els.statusLowVoltageLast, lowVoltage.last_detected_at || '-'); setText(els.statusLowVoltageDuration, eventDurationText(lowVoltage)); setText(els.statusLowVoltageRecent, eventRecentText(lowVoltage)); setText(els.statusThrottling, throttlingStateText(throttling)); setText(els.statusThrottlingLast, throttling.last_detected_at || '-'); setText(els.statusThrottlingDuration, eventDurationText(throttling)); setText(els.statusThrottlingRecent, eventRecentText(throttling)); if (els.statusBatteryVoltage) { setText(els.statusBatteryVoltage, battery.voltage === null || battery.voltage === undefined ? '-' : `${Number(battery.voltage).toFixed(3)} V`); } if (els.statusBatterySoc) { setText(els.statusBatterySoc, battery.percent === null || battery.percent === undefined ? '-' : `${Number(battery.percent).toFixed(2)}%`); } if (els.statusBatteryRemaining) { setText(els.statusBatteryRemaining, battery.remaining?.display || '-'); els.statusBatteryRemaining.removeAttribute('title'); updateBatteryTooltip(batteryRemainingTitle(battery.remaining)); } if (els.statusUsers) { const names = String(activeUsers.names || '').trim(); els.statusUsers.innerHTML = names ? `${escapeHtml(activeUsers.display || '0 users / 0 sessions')}
${escapeHtml(names)}` : escapeHtml(activeUsers.display || '0 users / 0 sessions'); } } function renderProcessRows(node, rows, metric) { if (!node) return; node.innerHTML = rows.length ? rows.map(row => { const value = metric === 'cpu' ? `${Number(row.cpu_percent || 0).toFixed(1)}%` : `${Number(row.mem_percent || 0).toFixed(1)}%`; return ` ${td(row.pid)} ${td(value)} ${td(row.service || 'N/A')} ${td(row.command || row.name || 'N/A')} `; }).join('') : `${escapeHtml(t('noProcessActivity'))}`; } function shortProcessName(p) { if (p?.service && p.service !== 'N/A') { return p.service; } const cmd = String(p?.command || p?.name || ''); if (cmd.includes('/codex ') || cmd.includes('/codex') || cmd.includes('codex app-server')) { return 'codex'; } if (cmd.includes('.vscode-server')) { return 'vscode-server'; } if (cmd.includes('python3 -m homeassistant')) { return 'homeassistant'; } if (cmd.includes('firefox')) { return 'firefox'; } return cmd || 'N/A'; } function processIdentity(p) { return [ p?.pid || '', p?.service || '', p?.command || p?.name || '', ].join('|'); } function spikeProcessText(row) { const cpuRows = Array.isArray(row.cpu_process) ? row.cpu_process : []; const memRows = Array.isArray(row.memory_process) ? row.memory_process : []; const cpu = cpuRows.find(p => { const cmd = String(p.command || p.name || ''); const service = String(p.service || ''); return !cmd.includes('/bin/ps') && !cmd.includes('api.php') && !cmd.includes('php-fpm') && service !== 'fanpanel-apply.service'; }); const mem = memRows[0] || null; if (cpu && mem && processIdentity(mem) === processIdentity(cpu)) { return shortProcessName(cpu); } const parts = []; if (cpu) { parts.push(`CPU ${Number(cpu.cpu_percent || 0).toFixed(1)}% ${shortProcessName(cpu)}`); } if (mem) { parts.push(`RAM ${Number(mem.mem_percent || 0).toFixed(1)}% ${shortProcessName(mem)}`); } return parts.length ? parts.join(' / ') : t('noCause'); } function signedCompact(value, digits = 0, suffix = '') { const n = Number(value || 0); const sign = n > 0 ? '+' : ''; return `${sign}${n.toLocaleString(undefined, { maximumFractionDigits: digits, minimumFractionDigits: digits, })}${suffix}`; } function noticeMetrics(row) { const rpmDelta = Number(row.rpm_delta || 0); const pwmDelta = Number(row.pwm_delta || 0); const tempDelta = Number(row.temp_delta || 0); const avgTemp = Number(row.current_temp || 0) - tempDelta; const avgRpm = Number(row.current_rpm || 0) - rpmDelta; const avgPwm = Number(row.current_pwm || 0) - pwmDelta; return { rpmDelta, tempDelta, avgTemp, avgRpm, avgPwm, }; } function noticeChangeText(row) { const m = noticeMetrics(row); const reasons = []; const tempThreshold = Number(settingByKey('notice.temp_delta_threshold', 3)); const rpmThreshold = Number(settingByKey('notice.rpm_delta_threshold', 1000)); if (Math.abs(m.tempDelta) >= tempThreshold) reasons.push(t(m.tempDelta >= 0 ? 'tempHigher' : 'tempLower')); if (Math.abs(m.rpmDelta) >= rpmThreshold) reasons.push(t(m.rpmDelta >= 0 ? 'rpmHigher' : 'rpmLower')); return `${t('recordedReason')}: ${reasons.length ? reasons.join(', ') : t('instantChange')}`; } function noticePreviousText(row) { const m = noticeMetrics(row); return `${t('average')}: ${m.avgTemp.toFixed(1)}°C / ${Math.round(m.avgRpm).toLocaleString()} RPM / PWM ${Math.round(m.avgPwm)}`; } function noticeCurrentText(row) { return `${t('current')}: ${Number(row.current_temp || 0).toFixed(1)}°C / ${Number(row.current_rpm || 0).toLocaleString()} RPM / PWM ${Number(row.current_pwm || 0).toFixed(0)}`; } function renderSpikeHistory(rows = []) { if (!els.spikeLogList) return; const visibleRows = rows.slice(0, 3); els.spikeLogList.innerHTML = visibleRows.length ? visibleRows.map((row, index) => { const processText = spikeProcessText(row); return `
${escapeHtml(row.created_at || row.time || '-')} ${escapeHtml(noticeChangeText(row))} ${escapeHtml(noticePreviousText(row))} ${escapeHtml(noticeCurrentText(row))} ${processText === t('noCause') ? '' : `${escapeHtml(t('causeCandidate'))}: ${escapeHtml(processText)}`}
`; }).join('') : `
${escapeHtml(t('noNoticeHistory'))}
`; } function renderCustomServices(rows = []) { if (!els.customServiceList) return; if (Date.now() < state.customServiceScrollHoldUntil) return; const scrollTop = els.customServiceList.scrollTop; els.customServiceList.innerHTML = rows.length ? rows.map(row => { const active = String(row.active || 'unknown'); const sub = String(row.sub || '').trim(); const stateClass = active === 'active' ? 'active' : (active === 'failed' ? 'failed' : ''); const stateLabel = sub ? `${active} / ${sub}` : active; const meta = [ row.enabled ? `${t('serviceEnabled')}: ${row.enabled}` : '', Number(row.pid || 0) > 0 ? `${t('servicePid')}: ${Number(row.pid).toLocaleString()}` : '', Number(row.restarts || 0) > 0 ? `${t('serviceRestarts')}: ${Number(row.restarts).toLocaleString()}` : '', row.active_enter ? row.active_enter : '', ].filter(Boolean).join(' · '); const logLines = Array.isArray(row.logs) && row.logs.length ? row.logs.join('\n') : (row.logs_ok === false ? row.logs_error : `${t('serviceLogs')}: -`); return `
${escapeHtml(row.name || '-')}
${escapeHtml(row.description || row.fragment || '-')}
${escapeHtml(stateLabel)}
${escapeHtml(meta || row.fragment || '-')}
${escapeHtml(logLines)}
`; }).join('') : `
${escapeHtml(t('noCustomServices'))}
`; els.customServiceList.querySelectorAll('.service-log').forEach(log => { log.scrollTop = 0; }); els.customServiceList.scrollTop = scrollTop; } function renderFanCause(data) { const processes = data.processes || {}; const baseline = data.fan_spike || {}; const baselineTemp = Number(baseline.temp_avg || 0); const baselineRpm = Number(baseline.rpm_avg || 0); const stateText = baseline.notice_state === 'alert' ? 'ALERT' : 'NORMAL'; const baselineText = baselineTemp > 0 || baselineRpm > 0 ? `${baselineTemp.toFixed(1)}°C / ${Math.round(baselineRpm).toLocaleString()} RPM · ${stateText}` : stateText; renderSpikeHistory(data.fan_spike_history || []); renderCustomServices(data.custom_services || []); setText(els.noticeBaseline, baselineText); renderProcessRows(els.processCpuTable, processes.cpu || [], 'cpu'); renderProcessRows(els.processMemoryTable, processes.memory || [], 'memory'); } function rollingStats(values, windowSize = 30) { const avg = []; const min = []; const max = []; const samples = []; values.forEach(value => { if (Number.isFinite(value)) { samples.push(value); } if (samples.length > windowSize) { samples.shift(); } if (!samples.length) { avg.push(null); min.push(null); max.push(null); return; } avg.push(samples.reduce((sum, v) => sum + v, 0) / samples.length); min.push(Math.min(...samples)); max.push(Math.max(...samples)); }); return { avg, min, max }; } function emaValues(values, alpha = 0.18) { let previous = null; return values.map(value => { if (!Number.isFinite(value)) { return previous; } previous = previous === null ? value : (value * alpha) + (previous * (1 - alpha)); return previous; }); } function formatDurationSeconds(value) { if (value === null || value === undefined || !Number.isFinite(Number(value))) { return '-'; } const totalSeconds = Math.max(0, Math.round(Number(value))); const days = Math.floor(totalSeconds / 86400); const hours = Math.floor((totalSeconds % 86400) / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const parts = []; if (state.lang === 'ko') { if (days > 0) { parts.push(`${days}일`); } if (days > 0 || hours > 0) { parts.push(`${hours}시`); } parts.push(`${minutes}분`); } else { if (days > 0) { parts.push(`${days}d`); } if (days > 0 || hours > 0) { parts.push(`${hours}h`); } parts.push(`${minutes}m`); } return parts.join(' '); } function chart(canvasId, label, rows, key, color, suffix = '', scaleOptions = {}, options = {}) { const canvas = $('#' + canvasId); if (!canvas || typeof Chart === 'undefined') return; const labels = rows.map(row => String(row.time || '').slice(11, 19)); const timestamps = rows.map(row => { const time = String(row.time || '').replace(' ', 'T'); const parsed = Date.parse(time); return Number.isFinite(parsed) ? parsed : null; }); const latestTimestamp = [...timestamps].reverse().find(value => value !== null) ?? null; const tickAgeSeconds = index => { if (latestTimestamp === null || timestamps[index] === null) return null; return Math.max(0, Math.round((latestTimestamp - timestamps[index]) / 1000)); }; const xMinuteTicks = [60, 120, 180]; const xTickMarkers = new Map(); xMinuteTicks.forEach(targetAge => { let bestIndex = -1; let bestDistance = Number.POSITIVE_INFINITY; timestamps.forEach((_timestamp, index) => { const age = tickAgeSeconds(index); if (age === null) return; const distance = Math.abs(age - targetAge); if (distance < bestDistance) { bestDistance = distance; bestIndex = index; } }); if (bestIndex >= 0 && bestDistance <= 15) { xTickMarkers.set(bestIndex, t('minuteAgo', { minutes: targetAge / 60 })); } }); const isXGridTick = index => { return xTickMarkers.has(index); }; const xTickLabel = index => { return xTickMarkers.get(index) || ''; }; const xTickCallback = (_value, index) => isXGridTick(index) ? xTickLabel(index) : ''; const xGridColor = context => { const index = Number(context.index ?? context.tick?.value ?? -1); return isXGridTick(index) ? 'rgba(203,213,225,.18)' : 'rgba(203,213,225,0)'; }; const rawValues = rows.map(row => { const value = row[key]; return value === null || value === undefined || value === '' ? null : Number(value); }); const values = typeof options.transform === 'function' ? options.transform(rawValues) : rawValues; const stats = rollingStats(values, options.window || 30); const datasets = [{ label: `${label} AVG`, data: stats.avg, minData: stats.min, maxData: stats.max, borderColor: color, backgroundColor: 'transparent', borderWidth: 2.4, pointRadius: 0, tension: 0.34, fill: false, }]; if (options.showRaw) { datasets.push({ label, data: values, borderColor: color + '66', backgroundColor: 'transparent', borderWidth: 1, pointRadius: 0, tension: 0.28, fill: false, }); } if (!state.charts[canvasId]) { state.charts[canvasId] = new Chart(canvas, { type: 'line', data: { labels, datasets, }, options: { responsive: true, maintainAspectRatio: false, animation: false, interaction: { intersect: false, mode: 'index', }, plugins: { legend: { display: false }, tooltip: { displayColors: false, padding: 8, callbacks: { beforeBody: items => { const row = items.find(item => item.dataset.label.endsWith('AVG')); if (!row) return []; const idx = row.dataIndex; const avgDataset = row.chart.data.datasets.find(ds => ds.label.endsWith('AVG')); const avg = avgDataset?.data?.[idx]; const min = avgDataset?.minData?.[idx]; const max = avgDataset?.maxData?.[idx]; const format = typeof options.tooltipFormat === 'function' ? options.tooltipFormat : value => value === null || value === undefined || !Number.isFinite(Number(value)) ? '-' : `${Number(value).toLocaleString(undefined, { maximumFractionDigits: 2 })}${suffix}`; return [ `${t('max')}: ${format(max)}`, `${t('avg')}: ${format(avg)}`, `${t('min')}: ${format(min)}`, ]; }, label: () => null, labelColor: () => ({ borderColor: 'transparent', backgroundColor: 'transparent', }), }, filter: item => item.dataset.label.endsWith('AVG'), }, }, scales: { x: { ticks: { color: '#64748b', maxRotation: 0, autoSkip: false, callback: xTickCallback, }, grid: { color: xGridColor, drawTicks: false, }, border: { display: false }, }, y: { min: 0, ...scaleOptions, ticks: { color: '#8d98aa', maxTicksLimit: 4 }, grid: { color: 'rgba(255,255,255,.045)', drawTicks: false }, border: { display: false }, }, }, }, }); return; } const c = state.charts[canvasId]; c.data.labels = labels; c.data.datasets = datasets; c.options.scales.x.ticks.callback = xTickCallback; c.options.scales.x.grid.color = xGridColor; c.options.scales.y.min = scaleOptions.min ?? 0; c.options.scales.y.max = scaleOptions.max; c.update('none'); } function dynamicScaleForValues(values, options = {}) { const floor = Number(options.floor ?? 0); const ceiling = Number.isFinite(Number(options.ceiling)) ? Number(options.ceiling) : null; const finiteValues = values.filter(value => Number.isFinite(value)); if (!finiteValues.length) { return ceiling === null ? { min: floor } : { min: floor, max: ceiling }; } const minValue = Math.min(...finiteValues); const maxValue = Math.max(...finiteValues); const center = (minValue + maxValue) / 2; const minSpan = Number(options.minSpan ?? 10); const padding = Number(options.padding ?? Math.max(1, minSpan * 0.2)); const span = Math.max(minSpan, maxValue - minValue + padding); const min = Math.max(floor, Math.floor(center - span / 2)); const naturalMax = Math.ceil(center + span / 2); const max = ceiling === null ? naturalMax : Math.min(ceiling, naturalMax); return { min, max: Math.max(max, min + 1), }; } function chartValues(rows, key, transform = null) { const rawValues = rows.map(row => { const value = row[key]; return value === null || value === undefined || value === '' ? null : Number(value); }); return typeof transform === 'function' ? transform(rawValues) : rawValues; } function dynamicScale(rows, key, options = {}, transform = null) { return dynamicScaleForValues(chartValues(rows, key, transform), options); } function renderCharts(data) { const rows = (data.history || []).slice(-180); const cpuWattSmoothing = values => emaValues(values, 0.18); chart('tempChart', 'CPUTEMP', rows, 'temp_c', '#ef4444', '°C', dynamicScale(rows, 'temp_c', { minSpan: 10 })); chart('rp1TempChart', 'RP1TEMP', rows, 'rp1_temp_c', '#f97316', '°C', dynamicScale(rows, 'rp1_temp_c', { minSpan: 10 })); chart('cpuWattChart', 'CPUW', rows, 'cpu_watts', '#a855f7', 'W', dynamicScale(rows, 'cpu_watts', { minSpan: 1, padding: 0.4 }, cpuWattSmoothing), { transform: cpuWattSmoothing }); chart('fanRpmChart', 'RPM', rows, 'fan_rpm', '#3b82f6', ' RPM', dynamicScale(rows, 'fan_rpm', { minSpan: 1000, padding: 400 })); if (document.getElementById('batterySocChart')) { chart('batterySocChart', 'BATTERYSOC', rows, 'battery_percent', '#f59e0b', '%', dynamicScale(rows, 'battery_percent', { minSpan: 10, ceiling: 110 })); } if (document.getElementById('remainingChart')) { chart('remainingChart', 'REMAINING', rows, 'battery_remaining_seconds', '#06b6d4', 's', dynamicScale(rows, 'battery_remaining_seconds', { minSpan: 1800, padding: 600 }), { tooltipFormat: formatDurationSeconds }); } if (document.getElementById('batteryVoltageChart')) { chart('batteryVoltageChart', 'BATTERYV', rows, 'battery_voltage', '#14b8a6', 'V', dynamicScale(rows, 'battery_voltage', { minSpan: 0.2, padding: 0.06 })); } } function resizeChartsSoon() { window.setTimeout(() => { Object.values(state.charts).forEach(chartInstance => { if (chartInstance && typeof chartInstance.resize === 'function') { chartInstance.resize(); chartInstance.update('none'); } }); }, 80); } function render(data) { if (data.settings) { state.settingsPayload = data.settings; if (!state.settingsOpen) { renderSettings(data.settings); } } renderTop(data); renderSystemStatus(data); renderWifi(data); renderCharts(data); updateSignalTooltip(); state.fanCauseTick = (state.fanCauseTick + 1) % 2; if (state.fanCauseTick === 0) { renderFanCause(data); } } function scrollDmesgToTop() { if (!els.dmesgOutput) return; if (els.dmesgOutput.hidden) return; els.dmesgOutput.scrollTop = 0; } function renderDmesg(data) { if (!els.dmesgOutput || !els.dmesgMeta) return; if (!data?.available) { els.dmesgOutput.textContent = data?.message || t('dmesgUnavailable'); els.dmesgMeta.textContent = '/tmp/dmesg.log unavailable'; return; } const lines = data.lines || []; const latestKey = `${data.line_count || 0}\n${lines[0] || ''}`; const hasNewLine = state.dmesgLatestKey !== null && latestKey !== state.dmesgLatestKey; els.dmesgOutput.textContent = lines.join('\n'); els.dmesgMeta.textContent = `${data.path || '/tmp/dmesg.log'} · ${data.line_count || 0} lines · updated ${data.updated_at || '-'}`; state.dmesgLatestKey = latestKey; if (hasNewLine) { requestAnimationFrame(scrollDmesgToTop); } } async function refreshDmesg() { if (!state.dmesgOpen) return; try { renderDmesg(await api('dmesg')); } catch (e) { console.error(e); if (els.dmesgOutput) { els.dmesgOutput.textContent = e.message || t('dmesgRefreshFailed'); } } } function stopDmesgFallback() { clearInterval(state.dmesgTimer); state.dmesgTimer = null; } function startDmesgFallback() { if (state.dmesgTimer) return; refreshDmesg(); state.dmesgTimer = setInterval(refreshDmesg, 1000); } function setDmesgOpen(open) { state.dmesgOpen = open; if (els.dmesgOutput) { els.dmesgOutput.hidden = !open; } if (els.dmesgToggle) { els.dmesgToggle.textContent = open ? t('hide') : t('show'); } stopDmesgFallback(); if (open) { if (!sendWs({ type: 'dmesg', open: true })) { startDmesgFallback(); } } else { sendWs({ type: 'dmesg', open: false }); } } async function refreshStatus() { if (state.loading) return; state.loading = true; try { render(await api('status')); } catch (e) { console.error(e); notice(e.message || t('refreshFailed'), 'error'); } finally { state.loading = false; } } function selectedFanPwm() { const mode = selectedFanMode(); return mode === 'off' ? 0 : Number(els.fanSlider?.value || 120); } function syncManualPwmFromCurrent() { const pwm = Math.max(0, Math.min(255, Math.round(state.latestFanPwm))); if (els.fanSlider) { els.fanSlider.value = pwm; } if (els.fanSliderValue) { els.fanSliderValue.textContent = String(pwm); } return pwm; } function scheduleFanApply(mode = selectedFanMode(), pwm = selectedFanPwm()) { clearTimeout(state.fanApplyTimer); state.fanApplyTimer = setTimeout(() => { fanApply(mode, pwm, true); }, 150); } async function fanApply(mode = selectedFanMode(), pwm = selectedFanPwm(), quiet = false) { if (state.fanApplying) { state.fanApplyPending = { mode, pwm, quiet }; return; } try { state.fanApplying = true; if (!quiet) notice(t('applyingFan'), 'info'); const data = await api('fan', { mode, pwm, }); state.fanDirty = false; state.fanApplying = false; render(data); if (!quiet) notice(t('fanUpdated'), 'success'); } catch (e) { console.error(e); notice(e.message || t('fanUpdateFailed'), 'error'); } finally { state.fanApplying = false; const pending = state.fanApplyPending; state.fanApplyPending = null; if (pending) fanApply(pending.mode, pending.pwm, pending.quiet); } } async function requestReboot() { if (state.rebootRequesting) return; state.rebootRequesting = true; try { const phrase = await window.customPrompt(t('rebootPrompt'), { title: t('rebootTitle'), okText: t('next'), danger: true, placeholder: String(settingByKey('security.reboot_phrase', t('rebootPhrase'))), autocomplete: 'off', }); if (phrase === null) return; const expectedPhrase = String(settingByKey('security.reboot_phrase', t('rebootPhrase'))); if (String(phrase).trim() !== expectedPhrase) { await window.customAlert(t('rebootPhraseMismatch'), { title: t('rebootCanceled'), danger: true, }); return; } const password = await window.customPrompt(t('passwordPrompt'), { title: t('adminConfirm'), okText: t('rebootButton'), danger: true, inputType: 'password', autocomplete: 'current-password', }); if (password === null) return; els.rebootBtn.disabled = true; notice(t('rebootSending'), 'info'); await api('reboot', { phrase: expectedPhrase, password, }); await window.customAlert(t('rebootStartedBody'), { title: t('rebootStarted'), okText: t('dialogOk'), }); } catch (e) { console.error(e); els.rebootBtn.disabled = false; await window.customAlert(e.message || 'reboot failed', { title: t('rebootFailed'), danger: true, }); } finally { state.rebootRequesting = false; } } if (els.fanSlider && els.fanSliderValue) { els.fanSlider.addEventListener('input', () => { markFanDirty(); els.fanSliderValue.textContent = els.fanSlider.value; scheduleFanApply(); }); els.fanSlider.addEventListener('focus', markFanDirty); els.fanSlider.addEventListener('pointerdown', markFanDirty); els.fanSlider.addEventListener('touchstart', markFanDirty); } fanModeInputs().forEach(input => { input.addEventListener('change', () => { if (!input.checked) return; updateFanModeUi(input.value); const pwm = input.value === 'manual' ? syncManualPwmFromCurrent() : (input.value === 'off' ? 0 : Number(els.fanSlider?.value || 120)); fanApply(input.value, pwm, true); }); }); els.dmesgToggle?.addEventListener('click', () => { setDmesgOpen(!state.dmesgOpen); }); const holdCustomServiceScroll = () => { state.customServiceScrollHoldUntil = Date.now() + 4000; }; ['scroll', 'wheel', 'touchstart', 'touchmove', 'pointerdown'].forEach(eventName => { els.customServiceList?.addEventListener(eventName, holdCustomServiceScroll, { passive: true }); }); els.wifiTable?.addEventListener('click', event => { const hostButton = event.target?.closest?.('.wifi-host-edit'); if (hostButton) { editWifiHostname(hostButton.dataset.mac || '', hostButton.dataset.hostname || ''); return; } const lanButton = event.target?.closest?.('.lan-wake-btn'); if (lanButton) { wakeLanDevice(lanButton.dataset.mac || '', lanButton.dataset.hostname || ''); } }); els.statusBatteryRemaining?.addEventListener('mouseenter', showBatteryTooltip); els.statusBatteryRemaining?.addEventListener('mouseleave', hideBatteryTooltip); els.statusBatteryRemaining?.addEventListener('focus', showBatteryTooltip); els.statusBatteryRemaining?.addEventListener('blur', hideBatteryTooltip); window.addEventListener('resize', positionBatteryTooltip); window.addEventListener('scroll', positionBatteryTooltip, true); document.addEventListener('mouseover', event => { const target = event.target?.closest?.('.wifi-signal[data-signal-dbm]'); if (target) showSignalTooltip(target); }); document.addEventListener('mouseout', event => { const target = event.target?.closest?.('.wifi-signal[data-signal-dbm]'); if (!target) return; const related = event.relatedTarget; if (related && target.contains(related)) return; hideSignalTooltip(); }); document.addEventListener('focusin', event => { const target = event.target?.closest?.('.wifi-signal[data-signal-dbm]'); if (target) showSignalTooltip(target); }); document.addEventListener('focusout', event => { const target = event.target?.closest?.('.wifi-signal[data-signal-dbm]'); if (target) hideSignalTooltip(); }); document.addEventListener('pointerdown', event => { const target = event.target?.closest?.('.wifi-signal[data-signal-dbm]'); if (target) { showSignalTooltip(target); return; } hideSignalTooltip(); }); window.addEventListener('resize', positionSignalTooltip); window.addEventListener('scroll', positionSignalTooltip, true); [els.processDetails, els.diagnosticDetails].forEach(node => { node?.addEventListener('toggle', resizeChartsSoon); }); els.rebootBtn?.addEventListener('click', () => { requestReboot(); }); els.settingsBtn?.addEventListener('click', openSettings); els.settingsClose?.addEventListener('click', closeSettings); els.settingsSave?.addEventListener('click', saveSettings); els.settingsReset?.addEventListener('click', resetSettings); els.translateBtn?.addEventListener('click', () => { applyLang(state.lang === 'ko' ? 'en' : 'ko'); refreshStatus(); }); els.themeBtn?.addEventListener('click', () => { applyTheme(state.theme === 'dark' ? 'light' : 'dark'); refreshStatus(); }); document.addEventListener('visibilitychange', () => { if (!document.hidden) { if (!sendWs({ type: 'status_refresh' })) { refreshStatus(); } if (state.dmesgOpen && !sendWs({ type: 'dmesg', open: true })) { refreshDmesg(); } } }); document.addEventListener('keydown', event => { if (event.key === 'Escape' && state.settingsOpen) { closeSettings(); } }); initPreferences(); connectControlSocket(); startStatusFallback(); })();