1121 lines
41 KiB
JavaScript
1121 lines
41 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'),
|
|
statusBatteryVoltage: $('#statusBatteryVoltage'),
|
|
statusBatterySoc: $('#statusBatterySoc'),
|
|
statusBatteryRemaining: $('#statusBatteryRemaining'),
|
|
spikeLogList: $('#spikeLogList'),
|
|
noticeBaseline: $('#noticeBaseline'),
|
|
pushStatus: $('#pushStatus'),
|
|
pushDeviceList: $('#pushDeviceList'),
|
|
pushHealthcheckBtn: $('#pushHealthcheckBtn'),
|
|
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 `<td>${escapeHtml(v)}</td>`;
|
|
}
|
|
|
|
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 timeAgo(seconds) {
|
|
const formatted = formatDhms(seconds);
|
|
return formatted ? `${formatted} 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)}
|
|
${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)}
|
|
</tr>
|
|
`).join('')
|
|
: '<tr><td colspan="9">연결된 WiFi 클라이언트 없음</td></tr>';
|
|
}
|
|
|
|
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')}<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">No process activity</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(' / ') : '원인 후보 없음';
|
|
}
|
|
|
|
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 `
|
|
<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 === '원인 후보 없음' ? '' : `<span>원인 후보: ${escapeHtml(processText)}</span>`}
|
|
</div>
|
|
`;
|
|
}).join('')
|
|
: '<div class="spike-log-empty">No system notice history.</div>';
|
|
}
|
|
|
|
function renderPushDevices(devices = [], summary = null) {
|
|
if (!els.pushDeviceList) return;
|
|
|
|
if (summary && els.pushStatus) {
|
|
const total = Number(summary.total || 0);
|
|
const healthy = Number(summary.healthy || 0);
|
|
const watch = Number(summary.watch || 0);
|
|
const stale = Number(summary.stale || 0);
|
|
const failed = Number(summary.failed || 0);
|
|
const pending = Number(summary.pending || 0);
|
|
els.pushStatus.textContent = `Push devices: ${total} total / ${healthy} healthy / ${watch} watch / ${stale} stale / ${failed} failed / ${pending} pending`;
|
|
}
|
|
|
|
els.pushDeviceList.innerHTML = devices.length
|
|
? devices.map(device => `
|
|
<div class="push-device-row ${escapeHtml(device.health_status || 'pending')}">
|
|
<div class="push-device-main">
|
|
<strong>${escapeHtml(device.device_name || '이름 없음')}</strong>
|
|
<span class="push-device-badge ${escapeHtml(device.health_status || 'pending')}">${escapeHtml(device.health_text || '수신 대기')}</span>
|
|
<span>Host: ${escapeHtml(device.host || 'unknown')}</span>
|
|
<span>IP: ${escapeHtml(device.actor_ip || '-')}</span>
|
|
<span>Created: ${escapeHtml(device.created_at || '-')}</span>
|
|
<span>Registered refresh: ${escapeHtml(device.last_seen_at || '-')} (${escapeHtml(timeAgo(device.last_seen_seconds))})</span>
|
|
<span>Last send success: ${escapeHtml(device.last_send_success_at || '-')} (${escapeHtml(timeAgo(device.last_send_success_seconds))})</span>
|
|
<span>Last received: ${escapeHtml(device.last_received_at || '-')} (${escapeHtml(timeAgo(device.last_received_seconds))})</span>
|
|
<span>Last shown: ${escapeHtml(device.last_notification_at || '-')} (${escapeHtml(timeAgo(device.last_notification_seconds))})</span>
|
|
<span>Last click: ${escapeHtml(device.last_click_at || '-')}</span>
|
|
<span>Failures: ${Number(device.failure_count || 0).toLocaleString()}${device.last_failure_reason ? ` / ${escapeHtml(device.last_failure_reason)}` : ''}</span>
|
|
<span>Encoding: ${escapeHtml(device.content_encoding || '-')}</span>
|
|
<span>Hash: ${escapeHtml(device.hash || '-')}</span>
|
|
<span>Endpoint: ${escapeHtml(device.endpoint || '-')}</span>
|
|
<span>UA: ${escapeHtml(device.user_agent || 'unknown device')}</span>
|
|
</div>
|
|
</div>
|
|
`).join('')
|
|
: '<div class="spike-log-empty">No push devices.</div>';
|
|
}
|
|
|
|
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 || [], data.summary || null);
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
}
|
|
|
|
async function sendPushHealthcheck() {
|
|
if (!els.pushHealthcheckBtn) return;
|
|
|
|
els.pushHealthcheckBtn.disabled = true;
|
|
try {
|
|
const data = await api('send_push_healthcheck', {});
|
|
renderPushDevices(data.devices || [], data.summary || null);
|
|
const result = data.result || {};
|
|
notice(`Health check sent ${Number(result.sent || 0)} / failed ${Number(result.failed || 0)}`, result.failed ? 'error' : 'success');
|
|
} catch (e) {
|
|
notice(e.message || 'Health check failed', 'error');
|
|
} finally {
|
|
els.pushHealthcheckBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
window.addEventListener('pushdevices:refresh', () => {
|
|
refreshPushDevices(true);
|
|
});
|
|
|
|
window.addEventListener('pushstatus:update', event => {
|
|
renderPushStatus(event.detail || {});
|
|
});
|
|
|
|
els.pushHealthcheckBtn?.addEventListener('click', sendPushHealthcheck);
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
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);
|
|
});
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (!document.hidden) {
|
|
if (!sendWs({ type: 'status_refresh' })) {
|
|
refreshStatus();
|
|
}
|
|
if (state.dmesgOpen && !sendWs({ type: 'dmesg', open: true })) {
|
|
refreshDmesg();
|
|
}
|
|
}
|
|
});
|
|
|
|
connectControlSocket();
|
|
startStatusFallback();
|
|
})();
|