WiFi 라우터 기능 복원

This commit is contained in:
seo
2026-07-20 11:26:40 +09:00
parent ada3c7dc74
commit 52b04453e7
10 changed files with 1022 additions and 16 deletions
+311
View File
@@ -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("'", '&#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) {
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 -1
View File
@@ -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",