Files
control/public/assets/app.js
T

2098 lines
83 KiB
JavaScript

(() => {
'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'),
batteryRuntimeTooltip: $('#batteryRuntimeTooltip'),
spikeLogList: $('#spikeLogList'),
haNotifyList: $('#haNotifyList'),
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'),
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',
batteryTooltipOpen: false,
batteryTooltipText: '',
settingsPayload: null,
settingsOpen: false,
};
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',
wifiAliasTitle: 'WiFi Hostname',
wifiAliasPrompt: 'Enter a name to keep for this MAC address.',
wifiAliasSaved: 'WiFi hostname saved.',
notifySuccess: 'success',
notifyFailed: 'failed',
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',
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: '재시작',
wifiAliasTitle: 'WiFi 호스트명',
wifiAliasPrompt: '이 MAC 주소에 계속 표시할 이름을 입력하세요.',
wifiAliasSaved: 'WiFi 호스트명을 저장했습니다.',
notifySuccess: '성공',
notifyFailed: '실패',
show: '보기',
hide: '숨기기',
translateButton: '번역',
themeButton: '테마',
settingsButton: '설정',
settingsTitle: '설정',
settingsSaved: '설정을 저장했습니다.',
settingsResetDone: '설정을 기본값으로 되돌렸습니다.',
settingsSaveFailed: '설정 저장 실패',
settingsResetConfirm: 'Control 설정을 모두 기본값으로 되돌릴까요?',
settingsDefault: '기본값',
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.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('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
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 renderSettings(payload = state.settingsPayload) {
if (!els.settingsBody || !payload) 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 value = item.value ?? item.default ?? '';
const type = item.type || 'number';
if (type === 'boolean') {
const checked = value === true || value === 1 || value === '1' || value === 'true';
return `
<span class="settings-toggle">
<input data-setting-key="${key}" type="checkbox" value="1" ${checked ? 'checked' : ''}>
<span>${checked ? '켜기' : '끄기'}</span>
</span>
`;
}
if (type === 'select') {
const options = item.options || {};
return `
<select data-setting-key="${key}" class="settings-select">
${Object.entries(options).map(([optionValue, label]) => `
<option value="${escapeHtml(optionValue)}" ${String(optionValue) === String(value) ? 'selected' : ''}>${escapeHtml(label)}</option>
`).join('')}
</select>
`;
}
if (type === 'text') {
return `
<input
data-setting-key="${key}"
type="text"
maxlength="${escapeHtml(item.max_length ?? 255)}"
value="${escapeHtml(value)}">
`;
}
return `
<input
data-setting-key="${key}"
type="number"
min="${escapeHtml(item.min ?? '')}"
max="${escapeHtml(item.max ?? '')}"
step="${escapeHtml(item.step ?? '1')}"
value="${escapeHtml(value)}">
`;
};
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 = Object.entries(byGroup).map(([group, items]) => `
<section class="settings-group">
<h4>${escapeHtml(groups[group] || group)}</h4>
<div class="settings-grid">
${items.map(item => `
<label class="settings-field">
<span class="settings-label">
<span>${escapeHtml(item.label || item.key)}</span>
<span>${escapeHtml(item.unit || '')}</span>
</span>
<span class="settings-control">
${fieldHtml(item)}
<span class="settings-default">${t('settingsDefault')} ${escapeHtml(defaultValueText(item))}</span>
</span>
</label>
`).join('')}
</div>
</section>
`).join('');
els.settingsBody.querySelectorAll('.settings-toggle input').forEach(input => {
input.addEventListener('change', () => {
const label = input.closest('.settings-toggle')?.querySelector('span');
if (label) label.textContent = input.checked ? '켜기' : '끄기';
});
input.closest('.settings-toggle')?.addEventListener('click', () => {
input.checked = !input.checked;
input.dispatchEvent(new Event('change'));
});
});
}
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;
els.settingsDialog.dataset.open = '1';
els.settingsDialog.setAttribute('aria-hidden', 'false');
}
function closeSettings() {
if (!els.settingsDialog) return;
state.settingsOpen = 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;
}
async function saveSettings() {
try {
const data = await api('settings_save', {
settings: JSON.stringify(currentSettingsFormValues()),
});
render(data);
renderSettings(data.settings);
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', {});
render(data);
renderSettings(data.settings);
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.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') {
lines.push(`- 장기 학습: ${row.intervals || 0}구간 / SOC대 ${row.soc_buckets || 0}개 / 누적감소 ${Number(row.drop_percent || 0).toFixed(3)}% / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}%`);
} 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)}%`;
lines.push(`- 에너지 학습: ${row.intervals || 0}구간 / SOC대 ${row.soc_buckets || 0}개 / ${wh} / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}%${stability}`);
} 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)}%`;
lines.push(`- 용량 전력 모델: ${capacity} / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}%${stability}`);
} 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`;
lines.push(`- 전압 ${row.minutes}분: 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}% / 부하보정 ${load}${kept}${stability}${floor}${volts}`);
} 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)}%`;
lines.push(`- ${prefix} ${row.minutes}분: 감소 ${Number(row.drop_percent || 0).toFixed(3)}% / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}% / 부하보정 ${load}${kept}${stability}`);
}
});
}
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 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 `<td>${escapeHtml(v)}</td>`;
}
function attr(v) {
return escapeHtml(v).replaceAll("'", '&#039;');
}
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 `<td><button type="button" class="wifi-host-edit" data-mac="${attr(row.mac || '')}" data-hostname="${attr(row.hostname || '')}" title="${attr(title)}">${escapeHtml(row.hostname || '-')}</button></td>`;
}
function wifiMetricCell(row, key) {
return `<td>${escapeHtml(row[key] || '-')}</td>`;
}
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');
}
}
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 => `
<tr>
${td(row.band)}
${wifiHostCell(row)}
${td(row.ip)}
${td(row.mac)}
${wifiMetricCell(row, 'signal')}
${wifiMetricCell(row, 'tx_bitrate')}
${wifiMetricCell(row, 'rx_bitrate')}
${td(wifiConnectedTime(row.connected_time))}
${td(row.inactive_time)}
</tr>
`).join('')
: `<tr><td colspan="9">${escapeHtml(t('noWifiClients'))}</td></tr>`;
}
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.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')}<br><span class="small">${escapeHtml(names)}</span>`
: 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 `
<tr>
${td(row.pid)}
${td(value)}
${td(row.service || 'N/A')}
${td(row.command || row.name || 'N/A')}
</tr>
`;
}).join('')
: `<tr><td colspan="4">${escapeHtml(t('noProcessActivity'))}</td></tr>`;
}
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 `
<div class="spike-log-item ${index === 0 ? 'latest' : ''}">
<strong>${escapeHtml(row.created_at || row.time || '-')}</strong>
<span>${escapeHtml(noticeChangeText(row))}</span>
<span>${escapeHtml(noticePreviousText(row))}</span>
<span>${escapeHtml(noticeCurrentText(row))}</span>
${processText === t('noCause') ? '' : `<span>${escapeHtml(t('causeCandidate'))}: ${escapeHtml(processText)}</span>`}
</div>
`;
}).join('')
: `<div class="spike-log-empty">${escapeHtml(t('noNoticeHistory'))}</div>`;
}
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 `
<div class="spike-log-item ${index === 0 ? 'latest' : ''} ${ok ? '' : 'alert'}">
<strong>${escapeHtml(row.created_at || '-')} · ${escapeHtml(statusText)}</strong>
<span>${escapeHtml(row.title || '-')}</span>
<span>${escapeHtml(row.tag || '-')}</span>
${detail ? `<span>${escapeHtml(detail)}</span>` : ''}
</div>
`;
}).join('')
: `<div class="spike-log-empty">${escapeHtml(t('noHaNotifyHistory'))}</div>`;
}
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 `
<div class="service-item">
<div class="service-head">
<div class="service-title">
<div class="service-name">${escapeHtml(row.name || '-')}</div>
<div class="service-desc">${escapeHtml(row.description || row.fragment || '-')}</div>
</div>
<div class="service-state ${stateClass}">${escapeHtml(stateLabel)}</div>
</div>
<div class="service-meta">${escapeHtml(meta || row.fragment || '-')}</div>
<pre class="service-log">${escapeHtml(logLines)}</pre>
</div>
`;
}).join('')
: `<div class="spike-log-empty">${escapeHtml(t('noCustomServices'))}</div>`;
}
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) {
if (data.settings) {
state.settingsPayload = data.settings;
if (state.settingsOpen) {
renderSettings(data.settings);
}
}
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: 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);
});
els.wifiTable?.addEventListener('click', event => {
const button = event.target?.closest?.('.wifi-host-edit');
if (!button) return;
editWifiHostname(button.dataset.mac || '', button.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);
[els.secondaryChartDetails, 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.settingsDialog?.addEventListener('click', event => {
if (event.target === els.settingsDialog) {
closeSettings();
}
});
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();
})();