(() => { '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'), wifi24: $('#wifi24'), wifi5: $('#wifi5'), wifiTable: $('#wifiTable'), 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'), spikeLogList: $('#spikeLogList'), haNotifyList: $('#haNotifyList'), customServiceList: $('#customServiceList'), noticeBaseline: $('#noticeBaseline'), rebootBtn: $('#rebootBtn'), 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'), secondaryChartDetails: $('#secondaryChartDetails'), 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', }; const storageKeys = { lang: 'controlLang', theme: 'controlTheme', }; const messages = { en: { updated: 'Updated', logout: 'Logout', fanStatus: 'Fan Status', cpuTemp: 'CPU Temp', fanRpm: 'Fan RPM', wifiStatus: 'WiFi Status', clients24: '2.4G Clients', clients5: '5G Clients', 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', detailCharts: 'Detail Charts', temperature: 'Temperature', rp1Temp: 'RP1 Temp', fanEfficiency: 'Fan Efficiency', cpuWatt: 'CPU Watt', remaining: 'Remaining', batteryVoltage: 'Battery Voltage', wifiClients: 'WiFi Clients', 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.', haNotifyHistory: 'HA Notify History', noHaNotifyHistory: 'No HA notify history.', customServices: 'Custom Services', noCustomServices: 'No custom services.', serviceLogs: 'Logs', serviceEnabled: 'enabled', servicePid: 'pid', serviceRestarts: 'restarts', notifySuccess: 'success', notifyFailed: 'failed', show: 'Show', hide: 'Hide', translateButton: 'Translate', themeButton: 'Theme', rebootButton: 'Reboot', dialogAlert: 'Alert', dialogConfirm: 'Confirm', dialogInput: 'Input', dialogOk: 'OK', dialogCancel: 'Cancel', noWifiClients: 'No connected WiFi 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: 'WiFi 상태', clients24: '2.4G 접속', clients5: '5G 접속', 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: '센서 이력', detailCharts: '상세 차트', temperature: '온도', rp1Temp: 'RP1 온도', fanEfficiency: '팬 효율', cpuWatt: 'CPU 전력', remaining: '잔여 시간', batteryVoltage: '배터리 전압', wifiClients: 'WiFi 클라이언트', band: '대역', hostname: '호스트명', signal: '신호', txRate: '송신 속도', rxRate: '수신 속도', connected: '연결 시간', inactive: '비활성', systemNotice: '시스템 알림', processDetails: '프로세스 상세', diagnostics: '진단', noticeHistory: '알림 이력', noNoticeHistory: '시스템 알림 이력이 없습니다.', haNotifyHistory: 'HA 알림 이력', noHaNotifyHistory: 'HA 알림 이력이 없습니다.', customServices: '사용자 서비스', noCustomServices: '사용자 생성 서비스가 없습니다.', serviceLogs: '로그', serviceEnabled: '활성화', servicePid: 'PID', serviceRestarts: '재시작', notifySuccess: '성공', notifyFailed: '실패', show: '보기', hide: '숨기기', translateButton: '번역', themeButton: '테마', rebootButton: '재부팅', dialogAlert: '알림', dialogConfirm: '확인', dialogInput: '입력', dialogOk: '확인', dialogCancel: '취소', noWifiClients: '연결된 WiFi 클라이언트 없음', 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.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 = event => { if (event.target === layer) onCancel(); }; 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)); 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(json?.message || json?.error || 'API error'); } return json.data; } 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.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.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.windows) && remaining.windows.length > 0) { lines.push(`반영 구간: ${remaining.windows.map(row => `${row.minutes}분`).join(', ')}`); } return lines.join('\n'); } 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) + '%'); setText(els.wifi24, data.wifi?.count24 ?? 0); setText(els.wifi5, data.wifi?.count5 ?? 0); 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 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 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; els.wifiTable.innerHTML = rows.length ? rows.map(row => ` ${td(row.band)} ${td(row.hostname)} ${td(row.ip)} ${td(row.mac)} ${td(row.signal)} ${td(row.tx_bitrate)} ${td(row.rx_bitrate)} ${td(wifiConnectedTime(row.connected_time))} ${td(row.inactive_time)} `).join('') : `${escapeHtml(t('noWifiClients'))}`; } 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)); setText(els.statusBatteryVoltage, battery.voltage === null || battery.voltage === undefined ? '-' : `${Number(battery.voltage).toFixed(3)} V`); setText(els.statusBatterySoc, battery.percent === null || battery.percent === undefined ? '-' : `${Number(battery.percent).toFixed(2)}%`); setText(els.statusBatteryRemaining, battery.remaining?.display || '-'); if (els.statusBatteryRemaining) { els.statusBatteryRemaining.title = 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 = []; if (Math.abs(m.tempDelta) >= 3) reasons.push(t(m.tempDelta >= 0 ? 'tempHigher' : 'tempLower')); if (Math.abs(m.rpmDelta) >= 1000) 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 renderHaNotifyHistory(rows = []) { if (!els.haNotifyList) return; els.haNotifyList.innerHTML = rows.length ? rows.map((row, index) => { const ok = row.event === 'send_success'; const statusText = ok ? t('notifySuccess') : t('notifyFailed'); const detail = [ row.target ? `${t('hostLabel')}: ${row.target}` : '', row.http_code ? `HTTP ${row.http_code}` : '', row.channel || '', row.error || '', ].filter(Boolean).join(' / '); return `
${escapeHtml(row.created_at || '-')} · ${escapeHtml(statusText)} ${escapeHtml(row.title || '-')} ${escapeHtml(row.tag || '-')} ${detail ? `${escapeHtml(detail)}` : ''}
`; }).join('') : `
${escapeHtml(t('noHaNotifyHistory'))}
`; } function renderCustomServices(rows = []) { if (!els.customServiceList) return; 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'))}
`; } 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 || []); renderHaNotifyHistory(data.ha_notify_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('fanRpmChart', 'RPM', rows, 'fan_rpm', '#3b82f6', ' RPM', dynamicScale(rows, 'fan_rpm', { minSpan: 1000, padding: 400 })); chart('fanEfficiencyChart', 'FANEFF', rows, 'fan_efficiency', '#84cc16', '', dynamicScale(rows, 'fan_efficiency', { minSpan: 20, ceiling: 100 })); chart('cpuWattChart', 'CPUW', rows, 'cpu_watts', '#a855f7', 'W', dynamicScale(rows, 'cpu_watts', { minSpan: 1, padding: 0.4 }, cpuWattSmoothing), { transform: cpuWattSmoothing }); chart('batterySocChart', 'BATTERYSOC', rows, 'battery_percent', '#f59e0b', '%', dynamicScale(rows, 'battery_percent', { minSpan: 10, ceiling: 110 })); chart('remainingChart', 'REMAINING', rows, 'battery_remaining_seconds', '#06b6d4', 's', dynamicScale(rows, 'battery_remaining_seconds', { minSpan: 1800, padding: 600 }), { tooltipFormat: formatDurationSeconds }); 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) { renderTop(data); renderSystemStatus(data); renderWifi(data); renderCharts(data); 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: t('rebootPhrase'), autocomplete: 'off', }); if (phrase === null) return; if (String(phrase).trim() !== t('rebootPhrase')) { 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: '재부팅', 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); }); [els.secondaryChartDetails, els.processDetails, els.diagnosticDetails].forEach(node => { node?.addEventListener('toggle', resizeChartsSoon); }); els.rebootBtn?.addEventListener('click', () => { requestReboot(); }); 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(); } } }); initPreferences(); connectControlSocket(); startStatusFallback(); })();