WiFi 라우터 기능 복원
This commit is contained in:
@@ -12,6 +12,10 @@
|
||||
fanSliderWrap: $('#fanSliderWrap'),
|
||||
fanSliderValue: $('#fanSliderValue'),
|
||||
fanModeOptions: $('#fanModeOptions'),
|
||||
wifi24: $('#wifi24'),
|
||||
wifi5: $('#wifi5'),
|
||||
wifiTable: $('#wifiTable'),
|
||||
signalTooltip: $('#signalTooltip'),
|
||||
statusHost: $('#statusHost'),
|
||||
statusLoad: $('#statusLoad'),
|
||||
statusUsers: $('#statusUsers'),
|
||||
@@ -80,6 +84,10 @@
|
||||
theme: 'dark',
|
||||
batteryTooltipOpen: false,
|
||||
batteryTooltipText: '',
|
||||
signalTooltipOpen: false,
|
||||
signalTooltipTarget: null,
|
||||
signalTooltipKey: '',
|
||||
signalTooltipText: '',
|
||||
scanScrollHoldUntil: 0,
|
||||
settingsPayload: null,
|
||||
settingsOpen: false,
|
||||
@@ -98,6 +106,9 @@
|
||||
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',
|
||||
@@ -138,6 +149,7 @@
|
||||
cpuWatt: 'CPU Watt',
|
||||
remaining: 'Remaining',
|
||||
batteryVoltage: 'Battery Voltage',
|
||||
wifiClients: 'WiFi Clients',
|
||||
deviceName: 'Device',
|
||||
address: 'Address',
|
||||
paired: 'Paired',
|
||||
@@ -148,8 +160,13 @@
|
||||
channel: 'Channel',
|
||||
security: 'Security',
|
||||
lastSeen: 'Last Seen',
|
||||
band: 'Band',
|
||||
hostname: 'Hostname',
|
||||
signal: 'Signal',
|
||||
txRate: 'TX Rate',
|
||||
rxRate: 'RX Rate',
|
||||
connected: 'Connected',
|
||||
inactive: 'Inactive',
|
||||
systemNotice: 'System Notice',
|
||||
processDetails: 'Process Details',
|
||||
diagnostics: 'Diagnostics',
|
||||
@@ -161,6 +178,9 @@
|
||||
serviceEnabled: 'enabled',
|
||||
servicePid: 'pid',
|
||||
serviceRestarts: 'restarts',
|
||||
wifiAliasTitle: 'WiFi Hostname',
|
||||
wifiAliasPrompt: 'Enter a name to keep for this MAC address.',
|
||||
wifiAliasSaved: 'WiFi hostname saved.',
|
||||
show: 'Show',
|
||||
hide: 'Hide',
|
||||
translateButton: 'Translate',
|
||||
@@ -178,6 +198,7 @@
|
||||
dialogInput: 'Input',
|
||||
dialogOk: 'OK',
|
||||
dialogCancel: 'Cancel',
|
||||
noWifiClients: 'No connected WiFi clients',
|
||||
noProcessActivity: 'No process activity',
|
||||
noCause: 'No candidate',
|
||||
recordedReason: 'Recorded reason',
|
||||
@@ -232,6 +253,9 @@
|
||||
fanStatus: '팬 상태',
|
||||
cpuTemp: 'CPU 온도',
|
||||
fanRpm: '팬 RPM',
|
||||
wifiStatus: 'WiFi 상태',
|
||||
clients24: '2.4G 접속',
|
||||
clients5: '5G 접속',
|
||||
systemStatus: '시스템 상태',
|
||||
loadAvg: '부하 평균',
|
||||
activeUsers: '활성 사용자',
|
||||
@@ -272,6 +296,7 @@
|
||||
cpuWatt: 'CPU 전력',
|
||||
remaining: '잔여 시간',
|
||||
batteryVoltage: '배터리 전압',
|
||||
wifiClients: 'WiFi 클라이언트',
|
||||
deviceName: '장치',
|
||||
address: '주소',
|
||||
paired: '페어링',
|
||||
@@ -282,8 +307,13 @@
|
||||
channel: '채널',
|
||||
security: '보안',
|
||||
lastSeen: '마지막 감지',
|
||||
band: '대역',
|
||||
hostname: '호스트명',
|
||||
signal: '신호',
|
||||
txRate: '송신 속도',
|
||||
rxRate: '수신 속도',
|
||||
connected: '연결 시간',
|
||||
inactive: '비활성',
|
||||
systemNotice: '시스템 알림',
|
||||
processDetails: '프로세스 상세',
|
||||
diagnostics: '진단',
|
||||
@@ -295,6 +325,9 @@
|
||||
serviceEnabled: '활성화',
|
||||
servicePid: 'PID',
|
||||
serviceRestarts: '재시작',
|
||||
wifiAliasTitle: 'WiFi 호스트명',
|
||||
wifiAliasPrompt: '이 MAC 주소에 계속 표시할 이름을 입력하세요.',
|
||||
wifiAliasSaved: 'WiFi 호스트명을 저장했습니다.',
|
||||
show: '보기',
|
||||
hide: '숨기기',
|
||||
translateButton: '번역',
|
||||
@@ -312,6 +345,7 @@
|
||||
dialogInput: '입력',
|
||||
dialogOk: '확인',
|
||||
dialogCancel: '취소',
|
||||
noWifiClients: '연결된 WiFi 클라이언트 없음',
|
||||
noProcessActivity: '프로세스 활동 없음',
|
||||
noCause: '원인 후보 없음',
|
||||
recordedReason: '기록된 이유',
|
||||
@@ -1041,11 +1075,103 @@
|
||||
els.batteryRuntimeTooltip.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
|
||||
function signalTooltipTarget() {
|
||||
if (state.signalTooltipKey && typeof document.querySelector === 'function') {
|
||||
const selector = `[data-signal-key="${cssEscape(state.signalTooltipKey)}"]`;
|
||||
const fresh = document.querySelector(selector);
|
||||
if (fresh) {
|
||||
state.signalTooltipTarget = fresh;
|
||||
return fresh;
|
||||
}
|
||||
}
|
||||
return state.signalTooltipTarget?.isConnected ? state.signalTooltipTarget : null;
|
||||
}
|
||||
|
||||
function signalTooltipText(target = signalTooltipTarget()) {
|
||||
if (!target) return '-';
|
||||
const label = target.dataset.signalLabel || target.textContent || '-';
|
||||
const dbm = target.dataset.signalDbm || '-';
|
||||
return `${label}\n${dbm}`;
|
||||
}
|
||||
|
||||
function positionSignalTooltip() {
|
||||
if (!state.signalTooltipOpen || !els.signalTooltip) {
|
||||
return;
|
||||
}
|
||||
const target = signalTooltipTarget();
|
||||
if (!target) {
|
||||
hideSignalTooltip();
|
||||
return;
|
||||
}
|
||||
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const tip = els.signalTooltip;
|
||||
const margin = 14;
|
||||
tip.style.left = '0px';
|
||||
tip.style.top = '0px';
|
||||
const tipRect = tip.getBoundingClientRect();
|
||||
let left = targetRect.left + (targetRect.width / 2) - (tipRect.width / 2);
|
||||
let top = targetRect.bottom + 10;
|
||||
|
||||
if (left + tipRect.width > window.innerWidth - margin) {
|
||||
left = window.innerWidth - tipRect.width - margin;
|
||||
}
|
||||
if (left < margin) {
|
||||
left = margin;
|
||||
}
|
||||
if (top + tipRect.height > window.innerHeight - margin) {
|
||||
top = targetRect.top - tipRect.height - 10;
|
||||
}
|
||||
if (top < margin) {
|
||||
top = margin;
|
||||
}
|
||||
|
||||
tip.style.left = `${Math.round(left)}px`;
|
||||
tip.style.top = `${Math.round(top)}px`;
|
||||
}
|
||||
|
||||
function updateSignalTooltip() {
|
||||
if (!els.signalTooltip) return;
|
||||
state.signalTooltipText = signalTooltipText();
|
||||
els.signalTooltip.textContent = state.signalTooltipText || '-';
|
||||
if (state.signalTooltipOpen) {
|
||||
requestAnimationFrame(positionSignalTooltip);
|
||||
}
|
||||
}
|
||||
|
||||
function showSignalTooltip(target) {
|
||||
if (!els.signalTooltip || !target || target.classList.contains('unknown')) return;
|
||||
state.signalTooltipOpen = true;
|
||||
state.signalTooltipTarget = target;
|
||||
state.signalTooltipKey = target.dataset.signalKey || '';
|
||||
els.signalTooltip.dataset.open = '1';
|
||||
els.signalTooltip.setAttribute('aria-hidden', 'false');
|
||||
updateSignalTooltip();
|
||||
}
|
||||
|
||||
function hideSignalTooltip() {
|
||||
if (!els.signalTooltip) return;
|
||||
state.signalTooltipOpen = false;
|
||||
state.signalTooltipTarget = null;
|
||||
state.signalTooltipKey = '';
|
||||
delete els.signalTooltip.dataset.open;
|
||||
els.signalTooltip.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
|
||||
function cssEscape(value) {
|
||||
if (window.CSS?.escape) {
|
||||
return window.CSS.escape(value);
|
||||
}
|
||||
return String(value).replace(/["\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function renderTop(data) {
|
||||
setText(els.updated, data.generated_at || '-');
|
||||
setText(els.temp, Number(data.system?.temp_c || 0).toFixed(1) + '°C');
|
||||
setText(els.fanRpm, Number(data.fan?.rpm || 0).toLocaleString() + ' RPM');
|
||||
setText(els.fanPercent, Number(data.fan?.percent || 0).toFixed(1) + '%');
|
||||
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()) {
|
||||
@@ -1064,6 +1190,126 @@
|
||||
return `<td>${escapeHtml(v)}</td>`;
|
||||
}
|
||||
|
||||
function attr(v) {
|
||||
return escapeHtml(v).replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function wifiValueTitle(row, key) {
|
||||
const source = row?.[`${key}_source`];
|
||||
if (!source || source === 'iw' || source === 'lease') return '';
|
||||
if (source.startsWith('estimated_')) return '';
|
||||
const labels = {
|
||||
alias: 'DB 저장 이름',
|
||||
generated: 'MAC 기반 임시 이름',
|
||||
};
|
||||
return labels[source] || source;
|
||||
}
|
||||
|
||||
function wifiHostCell(row) {
|
||||
const title = wifiValueTitle(row, 'hostname') || t('wifiAliasPrompt');
|
||||
return `<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) {
|
||||
if (key !== 'signal') {
|
||||
return `<td>${escapeHtml(row[key] || '-')}</td>`;
|
||||
}
|
||||
|
||||
const dbm = wifiSignalDbm(row[key]);
|
||||
return `<td>${signalBadgeHtml(dbm, row[key] || '', `client:${row.mac || row.ip || row.hostname || ''}`)}</td>`;
|
||||
}
|
||||
|
||||
function wifiSignalDbm(value) {
|
||||
const match = String(value ?? '').match(/-?\d+(?:\.\d+)?/);
|
||||
return match ? Number(match[0]) : null;
|
||||
}
|
||||
|
||||
function signalQuality(dbm) {
|
||||
if (dbm === null || !Number.isFinite(dbm)) return 'unknown';
|
||||
if (dbm >= -45) return { className: 'best', label: '최상' };
|
||||
if (dbm >= -55) return { className: 'excellent', label: '매우 좋음' };
|
||||
if (dbm >= -65) return { className: 'good', label: '좋음' };
|
||||
if (dbm >= -72) return { className: 'fine', label: '양호' };
|
||||
if (dbm >= -80) return { className: 'normal', label: '보통' };
|
||||
if (dbm >= -88) return { className: 'weak', label: '약함' };
|
||||
return { className: 'bad', label: '매우 약함' };
|
||||
}
|
||||
|
||||
function signalDbmText(dbm, raw = '') {
|
||||
if (dbm !== null && Number.isFinite(dbm)) {
|
||||
return `${Number(dbm).toFixed(Number.isInteger(dbm) ? 0 : 1)} dBm`;
|
||||
}
|
||||
return String(raw || '').trim() || '-';
|
||||
}
|
||||
|
||||
function signalBadgeHtml(dbm, raw = '', key = '') {
|
||||
const quality = signalQuality(dbm);
|
||||
if (typeof quality === 'string') {
|
||||
return `<span class="wifi-signal unknown">-</span>`;
|
||||
}
|
||||
|
||||
const keyAttr = key ? ` data-signal-key="${attr(key)}"` : '';
|
||||
return `<span class="wifi-signal ${quality.className}" tabindex="0" role="button" aria-describedby="signalTooltip" data-signal-dbm="${attr(signalDbmText(dbm, raw))}" data-signal-label="${attr(quality.label)}"${keyAttr}>${escapeHtml(quality.label)}</span>`;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -1091,6 +1337,10 @@
|
||||
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');
|
||||
@@ -1145,6 +1395,31 @@
|
||||
return formatted ? `${formatted} ${t('ago')}` : '-';
|
||||
}
|
||||
|
||||
function renderWifi(data) {
|
||||
const rows = data.wifi?.clients || [];
|
||||
if (!els.wifiTable) return;
|
||||
const tableWrap = els.wifiTable.closest('.table-wrap');
|
||||
const scrollLeft = tableWrap ? tableWrap.scrollLeft : 0;
|
||||
els.wifiTable.innerHTML = rows.length
|
||||
? rows.map(row => `
|
||||
<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>`;
|
||||
if (tableWrap) {
|
||||
tableWrap.scrollLeft = scrollLeft;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSystemStatus(data) {
|
||||
const system = data.system || {};
|
||||
const disk = system.disk || {};
|
||||
@@ -1712,7 +1987,9 @@
|
||||
}
|
||||
renderTop(data);
|
||||
renderSystemStatus(data);
|
||||
renderWifi(data);
|
||||
renderCharts(data);
|
||||
updateSignalTooltip();
|
||||
|
||||
state.fanCauseTick = (state.fanCauseTick + 1) % 2;
|
||||
|
||||
@@ -1937,12 +2214,46 @@
|
||||
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);
|
||||
document.addEventListener('mouseover', event => {
|
||||
const target = event.target?.closest?.('.wifi-signal[data-signal-dbm]');
|
||||
if (target) showSignalTooltip(target);
|
||||
});
|
||||
document.addEventListener('mouseout', event => {
|
||||
const target = event.target?.closest?.('.wifi-signal[data-signal-dbm]');
|
||||
if (!target) return;
|
||||
const related = event.relatedTarget;
|
||||
if (related && target.contains(related)) return;
|
||||
hideSignalTooltip();
|
||||
});
|
||||
document.addEventListener('focusin', event => {
|
||||
const target = event.target?.closest?.('.wifi-signal[data-signal-dbm]');
|
||||
if (target) showSignalTooltip(target);
|
||||
});
|
||||
document.addEventListener('focusout', event => {
|
||||
const target = event.target?.closest?.('.wifi-signal[data-signal-dbm]');
|
||||
if (target) hideSignalTooltip();
|
||||
});
|
||||
document.addEventListener('pointerdown', event => {
|
||||
const target = event.target?.closest?.('.wifi-signal[data-signal-dbm]');
|
||||
if (target) {
|
||||
showSignalTooltip(target);
|
||||
return;
|
||||
}
|
||||
hideSignalTooltip();
|
||||
});
|
||||
window.addEventListener('resize', positionSignalTooltip);
|
||||
window.addEventListener('scroll', positionSignalTooltip, true);
|
||||
[els.secondaryChartDetails, els.processDetails, els.diagnosticDetails].forEach(node => {
|
||||
node?.addEventListener('toggle', resizeChartsSoon);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Seoul Control Center",
|
||||
"short_name": "Seoul Control",
|
||||
"description": "Fan and system control panel",
|
||||
"description": "Fan and WiFi control panel",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
|
||||
Reference in New Issue
Block a user