(() => { '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'), statusBatteryVoltage: $('#statusBatteryVoltage'), statusBatterySoc: $('#statusBatterySoc'), statusBatteryRemaining: $('#statusBatteryRemaining'), spikeLogList: $('#spikeLogList'), noticeBaseline: $('#noticeBaseline'), pushStatus: $('#pushStatus'), pushDeviceList: $('#pushDeviceList'), processCpuTable: $('#processCpuTable'), processMemoryTable: $('#processMemoryTable'), dmesgToggle: $('#dmesgToggle'), dmesgOutput: $('#dmesgOutput'), dmesgMeta: $('#dmesgMeta'), }; 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, pushDevicesLastRefresh: 0, }; 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); } 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 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 (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 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('') : '연결된 WiFi 클라이언트 없음'; } function renderSystemStatus(data) { const system = data.system || {}; const disk = system.disk || {}; const memory = system.memory || {}; const battery = data.battery || {}; 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.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.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('') : 'No process activity'; } 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(' / ') : '원인 후보 없음'; } 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(`온도 평균보다 ${m.tempDelta >= 0 ? '상승' : '하강'}`); if (Math.abs(m.rpmDelta) >= 1000) reasons.push(`팬RPM 평균보다 ${m.rpmDelta >= 0 ? '상승' : '하강'}`); return `기록된 이유: ${reasons.length ? reasons.join(', ') : '순간 변화'}`; } function noticePreviousText(row) { const m = noticeMetrics(row); return `평균: ${m.avgTemp.toFixed(1)}°C / ${Math.round(m.avgRpm).toLocaleString()} RPM / PWM ${Math.round(m.avgPwm)}`; } function noticeCurrentText(row) { return `현재: ${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; els.spikeLogList.innerHTML = rows.length ? rows.map((row, index) => { const processText = spikeProcessText(row); return `
${escapeHtml(row.created_at || row.time || '-')} ${escapeHtml(noticeChangeText(row))} ${escapeHtml(noticePreviousText(row))} ${escapeHtml(noticeCurrentText(row))} ${processText === '원인 후보 없음' ? '' : `원인 후보: ${escapeHtml(processText)}`}
`; }).join('') : '
No system notice history.
'; } function renderPushDevices(devices = []) { if (!els.pushDeviceList) return; els.pushDeviceList.innerHTML = devices.length ? devices.map(device => `
${escapeHtml(device.device_name || '이름 없음')} Host: ${escapeHtml(device.host || 'unknown')} IP: ${escapeHtml(device.actor_ip || '-')} Created: ${escapeHtml(device.created_at || '-')} Last seen: ${escapeHtml(device.last_seen_at || '-')} Encoding: ${escapeHtml(device.content_encoding || '-')} Hash: ${escapeHtml(device.hash || '-')} Endpoint: ${escapeHtml(device.endpoint || '-')} UA: ${escapeHtml(device.user_agent || 'unknown device')}
`).join('') : '
No push devices.
'; } function renderPushStatus(detail = {}) { if (!els.pushStatus) return; const supported = detail.supported === true; const permission = detail.permission || 'unknown'; const hasBrowserSubscription = detail.browser_subscription === true; const hasServerSubscription = detail.server_subscription === true; const manualDisabled = detail.manual_disabled === true; const serverChecked = detail.server_checked === true; let text = ''; if (!supported) { text = 'Push 상태: 브라우저 미지원'; } else if (permission === 'denied') { text = 'Push 상태: 권한 꺼짐'; } else if (permission !== 'granted') { text = 'Push 상태: 권한 미허용'; } else if (manualDisabled) { text = 'Push 상태: 사용자가 직접 해제함'; } else { const browserText = hasBrowserSubscription ? '브라우저에는 있음' : '브라우저에는 없음'; const serverText = serverChecked ? (hasServerSubscription ? '서버에는 있음' : '서버에는 없음') : '서버 확인 전'; text = `Push 상태: ${browserText} / ${serverText}`; } els.pushStatus.textContent = text; } async function refreshPushDevices(force = false) { if (!force && Date.now() - state.pushDevicesLastRefresh < 30000) { return; } state.pushDevicesLastRefresh = Date.now(); try { const data = await api('push_devices'); renderPushDevices(data.devices || []); } catch (e) { console.error(e); } } window.addEventListener('pushdevices:refresh', () => { refreshPushDevices(true); }); window.addEventListener('pushstatus:update', event => { renderPushStatus(event.detail || {}); }); 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 || []); setText(els.noticeBaseline, baselineText); renderProcessRows(els.processCpuTable, processes.cpu || [], 'cpu'); renderProcessRows(els.processMemoryTable, processes.memory || [], 'memory'); refreshPushDevices(); } 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 (days > 0) { parts.push(`${days}일`); } if (days > 0 || hours > 0) { parts.push(`${hours}시`); } parts.push(`${minutes}분`); 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, `${targetAge / 60}m ago`); } }); 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 [ `MAX: ${format(max)}`, `AVG: ${format(avg)}`, `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 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 || 'dmesg log is not available yet.'; 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 || 'dmesg refresh failed'; } } } 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 ? 'Hide' : '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 || 'refresh failed', '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('Applying fan policy...', 'info'); const data = await api('fan', { mode, pwm, }); state.fanDirty = false; state.fanApplying = false; render(data); if (!quiet) notice('Fan updated', 'success'); } catch (e) { console.error(e); notice(e.message || 'fan update failed', 'error'); } finally { state.fanApplying = false; const pending = state.fanApplyPending; state.fanApplyPending = null; if (pending) fanApply(pending.mode, pending.pwm, pending.quiet); } } async function wifiAction(button) { try { button.disabled = true; notice('Applying WiFi command...', 'info'); render(await api('wifi', { unit: button.dataset.wifiUnit, verb: button.dataset.wifiAction, })); notice('WiFi command completed', 'success'); } catch (e) { console.error(e); notice(e.message || 'wifi command failed', 'error'); } finally { button.disabled = 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); }); }); document.querySelectorAll('[data-wifi-action]').forEach(button => { button.addEventListener('click', () => wifiAction(button)); }); els.dmesgToggle?.addEventListener('click', () => { setDmesgOpen(!state.dmesgOpen); }); document.addEventListener('visibilitychange', () => { if (!document.hidden) { if (!sendWs({ type: 'status_refresh' })) { refreshStatus(); } if (state.dmesgOpen && !sendWs({ type: 'dmesg', open: true })) { refreshDmesg(); } } }); connectControlSocket(); startStatusFallback(); })();