3129 lines
127 KiB
JavaScript
3129 lines
127 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'),
|
|
wifiTotal: $('#wifiTotal'),
|
|
wifi24: $('#wifi24'),
|
|
wifi5: $('#wifi5'),
|
|
wifiLan: $('#wifiLan'),
|
|
wifiTable: $('#wifiTable'),
|
|
signalTooltip: $('#signalTooltip'),
|
|
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'),
|
|
remoteLogList: $('#remoteLogList'),
|
|
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'),
|
|
synologyLogToggle: $('#synologyLogToggle'),
|
|
synologyLogOutput: $('#synologyLogOutput'),
|
|
synologyLogMeta: $('#synologyLogMeta'),
|
|
processDetails: $('#processDetails'),
|
|
diagnosticDetails: $('#diagnosticDetails'),
|
|
};
|
|
|
|
const state = {
|
|
loading: false,
|
|
charts: {},
|
|
fanDirty: false,
|
|
fanApplying: false,
|
|
fanApplyTimer: null,
|
|
fanApplyPending: null,
|
|
touchRuntimeApplying: false,
|
|
touchRuntimeTimer: null,
|
|
touchRuntimePending: null,
|
|
latestFanPwm: 0,
|
|
fanCauseTick: 0,
|
|
dmesgOpen: false,
|
|
dmesgTimer: null,
|
|
dmesgLatestKey: null,
|
|
synologyLogOpen: false,
|
|
synologyLogTimer: null,
|
|
synologyLogLatestKey: null,
|
|
synologyLogScrollHoldUntil: 0,
|
|
ws: null,
|
|
wsConnected: false,
|
|
wsFallbackTimer: null,
|
|
wsReconnectTimer: null,
|
|
wsReconnectDelay: 1000,
|
|
rebootRequesting: false,
|
|
lang: 'en',
|
|
theme: 'dark',
|
|
batteryTooltipOpen: false,
|
|
batteryTooltipText: '',
|
|
signalTooltipOpen: false,
|
|
signalTooltipTarget: null,
|
|
signalTooltipKey: '',
|
|
signalTooltipText: '',
|
|
scanScrollHoldUntil: 0,
|
|
settingsPayload: null,
|
|
settingsOpen: false,
|
|
settingsDirty: false,
|
|
customServiceScrollHoldUntil: 0,
|
|
};
|
|
|
|
const storageKeys = {
|
|
lang: 'controlLang',
|
|
theme: 'controlTheme',
|
|
};
|
|
|
|
const messages = {
|
|
en: {
|
|
updated: 'Updated',
|
|
logout: 'Logout',
|
|
fanStatus: 'Fan Status',
|
|
cpuTemp: 'CPU Temp',
|
|
fanRpm: 'Fan RPM',
|
|
wifiStatus: 'DHCP Status',
|
|
clientsTotal: 'All Devices',
|
|
clients24: '2.4G',
|
|
clients5: '5G',
|
|
clientsLan: 'LAN',
|
|
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',
|
|
temperature: 'CPU Temperature',
|
|
rp1Temp: 'RP1 Temp',
|
|
cpuWatt: 'CPU Watt',
|
|
networkHistory: 'Network History',
|
|
wanHistory: 'WAN History',
|
|
lanHistory: 'LAN History',
|
|
wifi24History: '2.4G WiFi History',
|
|
wifi5History: '5G WiFi History',
|
|
downloadRxPackets: 'Download / RX Packets',
|
|
uploadTxPackets: 'Upload / TX Packets',
|
|
downloadLine: 'Download',
|
|
uploadLine: 'Upload',
|
|
rxPacketsLine: 'RX Packets',
|
|
txPacketsLine: 'TX Packets',
|
|
remaining: 'Remaining',
|
|
batteryVoltage: 'Battery Voltage',
|
|
wifiClients: 'DHCP Clients',
|
|
deviceName: 'Device',
|
|
address: 'Address',
|
|
paired: 'Paired',
|
|
trusted: 'Trusted',
|
|
blocked: 'Blocked',
|
|
observed: 'Observed',
|
|
ssid: 'SSID',
|
|
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',
|
|
noticeHistory: 'Notice History',
|
|
noNoticeHistory: 'No system notice history.',
|
|
customServices: 'Custom Services',
|
|
noCustomServices: 'No custom services.',
|
|
remoteLogs: 'Remote Logs',
|
|
remoteLogsLoading: 'Remote log status loading...',
|
|
remotePiToSynology: 'Raspberry -> Synology',
|
|
remoteSynologyToPi: 'Synology -> Raspberry',
|
|
remotePersistentJournal: 'Persistent Journal',
|
|
remoteCrashLogs: 'Crash Logs',
|
|
remoteOk: 'OK',
|
|
remoteWarn: 'Check',
|
|
remoteTarget: 'Target',
|
|
remoteTransport: 'Transport',
|
|
remoteQueue: 'Queue',
|
|
remoteListener: 'Listener',
|
|
remoteLatest: 'Latest',
|
|
remoteFiles: 'Files',
|
|
remoteUsage: 'Usage',
|
|
remoteBoots: 'Boots',
|
|
remoteSaved: 'Saved',
|
|
remotePending: 'Pending',
|
|
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.',
|
|
wolConfirmTitle: 'Wake LAN Device',
|
|
wolConfirm: ({ hostname, mac }) => `Send Wake-on-LAN request to ${hostname || mac}?`,
|
|
wolSent: 'Wake-on-LAN request sent.',
|
|
wolFailed: 'Wake-on-LAN request failed',
|
|
wolAlreadyOnline: 'LAN device is already online.',
|
|
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',
|
|
touchDisplaySettings: 'Touch Display',
|
|
touchDisplayStatus: 'Status',
|
|
touchDisplayDetected: 'Detected',
|
|
touchDisplayConnected: 'Connected',
|
|
touchDisplayMode: 'Mode',
|
|
touchDisplayFramebuffer: 'Framebuffer',
|
|
touchBacklight: 'Backlight',
|
|
touchBrightness: 'Brightness',
|
|
touchBacklightEnabled: 'Backlight power',
|
|
touchDisplayOverlay: 'Manual overlay',
|
|
touchDisplayAutoDetect: 'Auto detect',
|
|
touchDisplayEnabled: 'LCD output',
|
|
touchInputEnabled: 'Touch input',
|
|
touchRotation: 'Console rotation',
|
|
touchInvx: 'Invert X',
|
|
touchInvy: 'Invert Y',
|
|
touchSwapxy: 'Swap X/Y',
|
|
touchSizeX: 'Touch X size',
|
|
touchSizeY: 'Touch Y size',
|
|
touchDisplaySave: 'Save display boot settings',
|
|
touchDisplaySaved: 'Display settings saved. Reboot is required.',
|
|
touchDisplayNoChange: 'Display settings already match.',
|
|
rebootButton: 'Reboot',
|
|
dialogAlert: 'Alert',
|
|
dialogConfirm: 'Confirm',
|
|
dialogInput: 'Input',
|
|
dialogOk: 'OK',
|
|
dialogCancel: 'Cancel',
|
|
noWifiClients: 'No connected DHCP 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',
|
|
synologyLog: 'Synology Log',
|
|
synologyLogUnavailable: 'Synology log is not available yet.',
|
|
synologyLogRefreshFailed: 'Synology log 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: 'DHCP 상태',
|
|
clientsTotal: '전체 기기',
|
|
clients24: '2.4G',
|
|
clients5: '5G',
|
|
clientsLan: 'LAN',
|
|
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: '센서 이력',
|
|
temperature: 'CPU 온도',
|
|
rp1Temp: 'RP1 온도',
|
|
cpuWatt: 'CPU 전력',
|
|
networkHistory: '네트워크 이력',
|
|
wanHistory: 'WAN 이력',
|
|
lanHistory: 'LAN 이력',
|
|
wifi24History: '2.4G WiFi 이력',
|
|
wifi5History: '5G WiFi 이력',
|
|
downloadRxPackets: '다운로드 / RX 패킷',
|
|
uploadTxPackets: '업로드 / TX 패킷',
|
|
downloadLine: '다운로드',
|
|
uploadLine: '업로드',
|
|
rxPacketsLine: 'RX 패킷',
|
|
txPacketsLine: 'TX 패킷',
|
|
remaining: '잔여 시간',
|
|
batteryVoltage: '배터리 전압',
|
|
wifiClients: 'DHCP 클라이언트',
|
|
deviceName: '장치',
|
|
address: '주소',
|
|
paired: '페어링',
|
|
trusted: '신뢰',
|
|
blocked: '차단',
|
|
observed: '관측',
|
|
ssid: 'SSID',
|
|
channel: '채널',
|
|
security: '보안',
|
|
lastSeen: '마지막 감지',
|
|
band: '대역',
|
|
hostname: '호스트명',
|
|
signal: '신호',
|
|
txRate: '송신 속도',
|
|
rxRate: '수신 속도',
|
|
connected: '연결 시간',
|
|
inactive: '비활성',
|
|
systemNotice: '시스템 알림',
|
|
processDetails: '프로세스 상세',
|
|
diagnostics: '진단',
|
|
noticeHistory: '알림 이력',
|
|
noNoticeHistory: '시스템 알림 이력이 없습니다.',
|
|
customServices: '사용자 서비스',
|
|
noCustomServices: '사용자 생성 서비스가 없습니다.',
|
|
remoteLogs: '원격 로그',
|
|
remoteLogsLoading: '원격 로그 상태 확인 중...',
|
|
remotePiToSynology: '라즈베리파이 -> 시놀로지',
|
|
remoteSynologyToPi: '시놀로지 -> 라즈베리파이',
|
|
remotePersistentJournal: '영구 저널',
|
|
remoteCrashLogs: '커널 크래시',
|
|
remoteOk: '정상',
|
|
remoteWarn: '확인',
|
|
remoteTarget: '대상',
|
|
remoteTransport: '전송',
|
|
remoteQueue: '대기열',
|
|
remoteListener: '수신',
|
|
remoteLatest: '최근',
|
|
remoteFiles: '파일',
|
|
remoteUsage: '사용량',
|
|
remoteBoots: '부팅 이력',
|
|
remoteSaved: '저장',
|
|
remotePending: '대기',
|
|
serviceLogs: '로그',
|
|
serviceEnabled: '활성화',
|
|
servicePid: 'PID',
|
|
serviceRestarts: '재시작',
|
|
wifiAliasTitle: 'WiFi 호스트명',
|
|
wifiAliasPrompt: '이 MAC 주소에 계속 표시할 이름을 입력하세요.',
|
|
wifiAliasSaved: 'WiFi 호스트명을 저장했습니다.',
|
|
wolConfirmTitle: 'LAN 기기 켜기',
|
|
wolConfirm: ({ hostname, mac }) => `${hostname || mac} 기기에 WOL 요청을 보낼까요?`,
|
|
wolSent: 'WOL 요청을 보냈습니다.',
|
|
wolFailed: 'WOL 요청 실패',
|
|
wolAlreadyOnline: 'LAN 기기가 이미 켜져 있습니다.',
|
|
show: '보기',
|
|
hide: '숨기기',
|
|
translateButton: '번역',
|
|
themeButton: '테마',
|
|
settingsButton: '설정',
|
|
settingsTitle: '설정',
|
|
settingsSaved: '설정을 저장했습니다.',
|
|
settingsResetDone: '설정을 기본값으로 되돌렸습니다.',
|
|
settingsSaveFailed: '설정 저장 실패',
|
|
settingsResetConfirm: 'Control 설정을 모두 기본값으로 되돌릴까요?',
|
|
settingsDefault: '기본값',
|
|
touchDisplaySettings: '터치 디스플레이',
|
|
touchDisplayStatus: '상태',
|
|
touchDisplayDetected: '인식',
|
|
touchDisplayConnected: '연결',
|
|
touchDisplayMode: '모드',
|
|
touchDisplayFramebuffer: '프레임버퍼',
|
|
touchBacklight: '백라이트',
|
|
touchBrightness: '밝기',
|
|
touchBacklightEnabled: '백라이트 전원',
|
|
touchDisplayOverlay: '수동 overlay',
|
|
touchDisplayAutoDetect: '자동 감지',
|
|
touchDisplayEnabled: 'LCD 출력',
|
|
touchInputEnabled: '터치 입력',
|
|
touchRotation: '콘솔 회전',
|
|
touchInvx: '터치 X 반전',
|
|
touchInvy: '터치 Y 반전',
|
|
touchSwapxy: '터치 X/Y 교환',
|
|
touchSizeX: '터치 X 크기',
|
|
touchSizeY: '터치 Y 크기',
|
|
touchDisplaySave: '디스플레이 부팅 설정 저장',
|
|
touchDisplaySaved: '디스플레이 설정을 저장했습니다. 재부팅이 필요합니다.',
|
|
touchDisplayNoChange: '디스플레이 설정이 이미 동일합니다.',
|
|
rebootButton: '재부팅',
|
|
dialogAlert: '알림',
|
|
dialogConfirm: '확인',
|
|
dialogInput: '입력',
|
|
dialogOk: '확인',
|
|
dialogCancel: '취소',
|
|
noWifiClients: '연결된 DHCP 클라이언트 없음',
|
|
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 갱신 실패',
|
|
synologyLog: '시놀로지 로그',
|
|
synologyLogUnavailable: '시놀로지 로그를 아직 사용할 수 없습니다.',
|
|
synologyLogRefreshFailed: '시놀로지 로그 갱신 실패',
|
|
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');
|
|
}
|
|
if (els.synologyLogToggle) {
|
|
els.synologyLogToggle.textContent = state.synologyLogOpen ? 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('<', '<')
|
|
.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);
|
|
}
|
|
|
|
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 = () => {};
|
|
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));
|
|
|
|
function apiErrorMessage(message) {
|
|
const map = {
|
|
device_already_online: t('wolAlreadyOnline'),
|
|
};
|
|
return map[message] || message;
|
|
}
|
|
|
|
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(apiErrorMessage(json?.message || json?.error || 'API error'));
|
|
}
|
|
return json.data;
|
|
}
|
|
|
|
function settingDomId(key) {
|
|
return `setting-${String(key).replace(/[^A-Za-z0-9_-]/g, '-')}`;
|
|
}
|
|
|
|
function displayDomId(key) {
|
|
return `touch-display-${String(key).replace(/[^A-Za-z0-9_-]/g, '-')}`;
|
|
}
|
|
|
|
function boolLabel(value) {
|
|
return value ? 'ON' : 'OFF';
|
|
}
|
|
|
|
function renderTouchDisplaySettings(display) {
|
|
if (!display || !display.config) return '';
|
|
const config = display.config || {};
|
|
const statusRows = [
|
|
[t('touchDisplayDetected'), boolLabel(!!display.detected)],
|
|
[t('touchDisplayConnected'), `${display.status || 'unknown'} / ${display.enabled_state || 'unknown'} / ${display.dpms || 'unknown'}`],
|
|
[t('touchDisplayMode'), display.current_mode || '-'],
|
|
[t('touchDisplayFramebuffer'), display.framebuffer || '-'],
|
|
[t('touchBacklight'), display.backlight?.available ? `${display.backlight.brightness ?? '-'} / ${display.backlight.max_brightness ?? '-'} (${display.backlight.percent ?? '-'}%)` : '-'],
|
|
[t('touchDisplayAutoDetect'), boolLabel(!!config.display_auto_detect)],
|
|
[t('touchDisplayOverlay'), boolLabel(!!config.manual_overlay)],
|
|
];
|
|
const checkboxField = (key, label, checked) => {
|
|
const id = escapeHtml(displayDomId(key));
|
|
return `
|
|
<div class="settings-field">
|
|
<span class="settings-label"><span>${escapeHtml(label)}</span><span></span></span>
|
|
<span class="settings-control">
|
|
<span class="settings-toggle">
|
|
<input id="${id}" data-display-key="${escapeHtml(key)}" type="checkbox" value="1" autocomplete="off" ${checked ? 'checked' : ''}>
|
|
<span class="settings-toggle-on">켜기</span>
|
|
<span class="settings-toggle-off">끄기</span>
|
|
</span>
|
|
</span>
|
|
</div>
|
|
`;
|
|
};
|
|
const numberField = (key, label, value, min, max, step = 1) => `
|
|
<div class="settings-field">
|
|
<span class="settings-label"><span>${escapeHtml(label)}</span><span></span></span>
|
|
<span class="settings-control">
|
|
<input id="${escapeHtml(displayDomId(key))}" data-display-key="${escapeHtml(key)}" type="number" min="${min}" max="${max}" step="${step}" autocomplete="off" value="${escapeHtml(value)}">
|
|
</span>
|
|
</div>
|
|
`;
|
|
const backlight = display.backlight || {};
|
|
const maxBrightness = Number(backlight.max_brightness || 255);
|
|
const brightness = Number(backlight.brightness ?? maxBrightness);
|
|
|
|
return `
|
|
<section class="settings-group touch-display-settings">
|
|
<h4>${escapeHtml(t('touchDisplaySettings'))}</h4>
|
|
<div class="touch-display-status">
|
|
${statusRows.map(([key, value]) => `
|
|
<div><span>${escapeHtml(key)}</span><strong>${escapeHtml(value)}</strong></div>
|
|
`).join('')}
|
|
</div>
|
|
<div class="settings-grid">
|
|
<div class="settings-field">
|
|
<span class="settings-label"><span>${escapeHtml(t('touchRotation'))}</span><span>deg</span></span>
|
|
<span class="settings-control">
|
|
<select data-display-key="rotation" class="settings-select" autocomplete="off">
|
|
${[0, 90, 180, 270].map(value => `
|
|
<option value="${value}" ${Number(config.rotation || 0) === value ? 'selected' : ''}>${value}</option>
|
|
`).join('')}
|
|
</select>
|
|
</span>
|
|
</div>
|
|
${checkboxField('manual_overlay', t('touchDisplayOverlay'), !!config.manual_overlay)}
|
|
${checkboxField('display_enabled', t('touchDisplayEnabled'), !!config.display_enabled)}
|
|
${checkboxField('touch_enabled', t('touchInputEnabled'), !!config.touch_enabled)}
|
|
${checkboxField('touch_invx', t('touchInvx'), !!config.touch_invx)}
|
|
${checkboxField('touch_invy', t('touchInvy'), !!config.touch_invy)}
|
|
${checkboxField('touch_swapxy', t('touchSwapxy'), !!config.touch_swapxy)}
|
|
${numberField('touch_sizex', t('touchSizeX'), config.touch_sizex || 800, 100, 4096)}
|
|
${numberField('touch_sizey', t('touchSizeY'), config.touch_sizey || 480, 100, 4096)}
|
|
</div>
|
|
${backlight.available ? `
|
|
<div class="touch-runtime-box">
|
|
<h4>${escapeHtml(t('touchBacklight'))}</h4>
|
|
<div class="settings-grid">
|
|
<div class="settings-field">
|
|
<span class="settings-label"><span>${escapeHtml(t('touchBrightness'))}</span><span id="touchBrightnessValue">${escapeHtml(brightness)} / ${escapeHtml(maxBrightness)}</span></span>
|
|
<span class="settings-control">
|
|
<input data-display-runtime-key="brightness" type="range" min="0" max="${escapeHtml(maxBrightness)}" step="1" autocomplete="off" value="${escapeHtml(brightness)}" class="slider">
|
|
</span>
|
|
</div>
|
|
${checkboxField('backlight_enabled', t('touchBacklightEnabled'), backlight.enabled !== false).replaceAll('data-display-key', 'data-display-runtime-key')}
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
<div class="settings-inline-actions">
|
|
<button id="touchDisplaySave" class="btn" type="button">${escapeHtml(t('touchDisplaySave'))}</button>
|
|
<span class="settings-note">/boot/firmware/config.txt, /boot/firmware/cmdline.txt</span>
|
|
</div>
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
function renderSettings(payload = state.settingsPayload, options = {}) {
|
|
if (!els.settingsBody || !payload) return;
|
|
if (state.settingsOpen && state.settingsDirty && !options.force) 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 id = escapeHtml(settingDomId(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 id="${id}" name="${key}" data-setting-key="${key}" type="checkbox" value="1" autocomplete="off" ${checked ? 'checked' : ''}>
|
|
<span class="settings-toggle-on">켜기</span>
|
|
<span class="settings-toggle-off">끄기</span>
|
|
</span>
|
|
`;
|
|
}
|
|
if (type === 'select') {
|
|
const options = item.options || {};
|
|
return `
|
|
<select id="${id}" name="${key}" data-setting-key="${key}" class="settings-select" autocomplete="off">
|
|
${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
|
|
id="${id}"
|
|
name="${key}"
|
|
data-setting-key="${key}"
|
|
type="text"
|
|
maxlength="${escapeHtml(item.max_length ?? 255)}"
|
|
autocomplete="off"
|
|
value="${escapeHtml(value)}">
|
|
`;
|
|
}
|
|
return `
|
|
<input
|
|
id="${id}"
|
|
name="${key}"
|
|
data-setting-key="${key}"
|
|
type="number"
|
|
min="${escapeHtml(item.min ?? '')}"
|
|
max="${escapeHtml(item.max ?? '')}"
|
|
step="${escapeHtml(item.step ?? '1')}"
|
|
autocomplete="off"
|
|
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 = renderTouchDisplaySettings(payload.touch_display) + Object.entries(byGroup).map(([group, items]) => `
|
|
<section class="settings-group">
|
|
<h4>${escapeHtml(groups[group] || group)}</h4>
|
|
<div class="settings-grid">
|
|
${items.map(item => `
|
|
<div 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>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</section>
|
|
`).join('');
|
|
els.settingsBody.querySelectorAll('[data-setting-key],[data-display-key]').forEach(input => {
|
|
input.addEventListener('input', () => {
|
|
state.settingsDirty = true;
|
|
});
|
|
input.addEventListener('change', () => {
|
|
state.settingsDirty = true;
|
|
});
|
|
});
|
|
els.settingsBody.querySelectorAll('.settings-toggle input').forEach(input => {
|
|
input.addEventListener('change', () => {
|
|
const toggle = input.closest('.settings-toggle');
|
|
if (toggle) toggle.dataset.checked = input.checked ? '1' : '0';
|
|
});
|
|
const toggle = input.closest('.settings-toggle');
|
|
toggle?.setAttribute('data-checked', input.checked ? '1' : '0');
|
|
toggle?.addEventListener('click', () => {
|
|
input.checked = !input.checked;
|
|
input.dispatchEvent(new Event('change'));
|
|
});
|
|
});
|
|
$('#touchDisplaySave', els.settingsBody)?.addEventListener('click', saveTouchDisplaySettings);
|
|
const brightnessSlider = els.settingsBody.querySelector('[data-display-runtime-key="brightness"]');
|
|
const brightnessValue = $('#touchBrightnessValue', els.settingsBody);
|
|
brightnessSlider?.addEventListener('input', () => {
|
|
if (brightnessValue) {
|
|
brightnessValue.textContent = `${brightnessSlider.value} / ${brightnessSlider.max}`;
|
|
}
|
|
scheduleTouchDisplayRuntimeApply();
|
|
});
|
|
brightnessSlider?.addEventListener('change', scheduleTouchDisplayRuntimeApply);
|
|
const backlightToggle = els.settingsBody.querySelector('[data-display-runtime-key="backlight_enabled"]');
|
|
backlightToggle?.addEventListener('change', () => {
|
|
if (!backlightToggle.checked && brightnessSlider) {
|
|
brightnessSlider.value = '0';
|
|
if (brightnessValue) {
|
|
brightnessValue.textContent = `0 / ${brightnessSlider.max}`;
|
|
}
|
|
} else if (backlightToggle.checked && brightnessSlider && Number(brightnessSlider.value) <= 0) {
|
|
brightnessSlider.value = brightnessSlider.max || '255';
|
|
if (brightnessValue) {
|
|
brightnessValue.textContent = `${brightnessSlider.value} / ${brightnessSlider.max}`;
|
|
}
|
|
}
|
|
scheduleTouchDisplayRuntimeApply();
|
|
});
|
|
}
|
|
|
|
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;
|
|
state.settingsDirty = false;
|
|
els.settingsDialog.dataset.open = '1';
|
|
els.settingsDialog.setAttribute('aria-hidden', 'false');
|
|
}
|
|
|
|
function closeSettings() {
|
|
if (!els.settingsDialog) return;
|
|
state.settingsOpen = false;
|
|
state.settingsDirty = 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;
|
|
}
|
|
|
|
function currentTouchDisplayFormValues() {
|
|
const values = {};
|
|
els.settingsBody?.querySelectorAll('[data-display-key]').forEach(input => {
|
|
values[input.dataset.displayKey] = input.type === 'checkbox'
|
|
? (input.checked ? '1' : '0')
|
|
: input.value;
|
|
});
|
|
return values;
|
|
}
|
|
|
|
function currentTouchDisplayRuntimeValues() {
|
|
const values = {};
|
|
els.settingsBody?.querySelectorAll('[data-display-runtime-key]').forEach(input => {
|
|
values[input.dataset.displayRuntimeKey] = input.type === 'checkbox'
|
|
? (input.checked ? '1' : '0')
|
|
: input.value;
|
|
});
|
|
if (values.backlight_enabled === '0') {
|
|
values.brightness = '0';
|
|
}
|
|
return values;
|
|
}
|
|
|
|
async function saveTouchDisplaySettings() {
|
|
if (!await window.customConfirm('디스플레이 부팅 설정을 저장할까요? 저장 후 재부팅해야 적용됩니다.', { title: t('touchDisplaySettings') })) {
|
|
return;
|
|
}
|
|
try {
|
|
const data = await api('touch_display_save', {
|
|
display: JSON.stringify(currentTouchDisplayFormValues()),
|
|
});
|
|
state.settingsDirty = false;
|
|
render(data);
|
|
renderSettings(data.settings, { force: true });
|
|
const changed = data.touch_display_save?.changed || [];
|
|
notice(changed.length ? t('touchDisplaySaved') : t('touchDisplayNoChange'), 'success');
|
|
} catch (e) {
|
|
console.error(e);
|
|
notice(e.message || t('settingsSaveFailed'), 'error');
|
|
}
|
|
}
|
|
|
|
function scheduleTouchDisplayRuntimeApply(values = currentTouchDisplayRuntimeValues()) {
|
|
clearTimeout(state.touchRuntimeTimer);
|
|
state.touchRuntimeTimer = setTimeout(() => {
|
|
applyTouchDisplayRuntime(values);
|
|
}, 150);
|
|
}
|
|
|
|
async function applyTouchDisplayRuntime(values = currentTouchDisplayRuntimeValues()) {
|
|
if (state.touchRuntimeApplying) {
|
|
state.touchRuntimePending = values;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
state.touchRuntimeApplying = true;
|
|
const data = await api('touch_display_runtime_save', {
|
|
runtime: JSON.stringify(values),
|
|
});
|
|
state.settingsDirty = false;
|
|
state.touchRuntimeApplying = false;
|
|
render(data);
|
|
renderSettings(data.settings, { force: true });
|
|
} catch (e) {
|
|
console.error(e);
|
|
notice(e.message || t('settingsSaveFailed'), 'error');
|
|
} finally {
|
|
state.touchRuntimeApplying = false;
|
|
const pending = state.touchRuntimePending;
|
|
state.touchRuntimePending = null;
|
|
if (pending) applyTouchDisplayRuntime(pending);
|
|
}
|
|
}
|
|
|
|
async function saveSettings() {
|
|
try {
|
|
const data = await api('settings_save', {
|
|
settings: JSON.stringify(currentSettingsFormValues()),
|
|
});
|
|
state.settingsDirty = false;
|
|
render(data);
|
|
renderSettings(data.settings, { force: true });
|
|
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', {});
|
|
state.settingsDirty = false;
|
|
render(data);
|
|
renderSettings(data.settings, { force: true });
|
|
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 });
|
|
}
|
|
if (state.synologyLogOpen) {
|
|
stopSynologyLogFallback();
|
|
sendWs({ type: 'synology_log', 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 === 'synology_log') {
|
|
renderSynologyLog(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();
|
|
}
|
|
if (state.synologyLogOpen) {
|
|
startSynologyLogFallback();
|
|
}
|
|
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.runtime_cap_seconds !== undefined && remaining.runtime_cap_seconds !== null) {
|
|
const capText = formatDurationSeconds(remaining.runtime_cap_seconds);
|
|
const cappedText = remaining.runtime_capped ? ' / 현실 상한 적용' : '';
|
|
lines.push(`현실 상한: ${capText}${cappedText}`);
|
|
}
|
|
if (remaining.original_seconds !== undefined && remaining.original_seconds !== null) {
|
|
lines.push(`상한 전 계산값: ${formatDurationSeconds(remaining.original_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') {
|
|
const cap = row.runtime_capped ? ` / 상한 전 ${formatDurationSeconds(row.original_seconds)}` : '';
|
|
lines.push(`- 장기 학습: ${row.intervals || 0}구간 / SOC대 ${row.soc_buckets || 0}개 / 누적감소 ${Number(row.drop_percent || 0).toFixed(3)}% / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}%${cap}`);
|
|
} 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)}%`;
|
|
const cap = row.runtime_capped ? ` / 상한 전 ${formatDurationSeconds(row.original_seconds)}` : '';
|
|
lines.push(`- 에너지 학습: ${row.intervals || 0}구간 / SOC대 ${row.soc_buckets || 0}개 / ${wh} / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}%${stability}${cap}`);
|
|
} 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)}%`;
|
|
const cap = row.runtime_capped ? ` / 상한 전 ${formatDurationSeconds(row.original_seconds)}` : '';
|
|
lines.push(`- 용량 전력 모델: ${capacity} / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}%${stability}${cap}`);
|
|
} 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`;
|
|
const cap = row.runtime_capped ? ` / 상한 전 ${formatDurationSeconds(row.original_seconds)}` : '';
|
|
lines.push(`- 전압 ${row.minutes}분: 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}% / 부하보정 ${load}${kept}${stability}${floor}${volts}${cap}`);
|
|
} 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)}%`;
|
|
const cap = row.runtime_capped ? ` / 상한 전 ${formatDurationSeconds(row.original_seconds)}` : '';
|
|
lines.push(`- ${prefix} ${row.minutes}분: 감소 ${Number(row.drop_percent || 0).toFixed(3)}% / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}% / 부하보정 ${load}${kept}${stability}${cap}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
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 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) + '%');
|
|
const count24 = Number(data.wifi?.count24 ?? 0);
|
|
const count5 = Number(data.wifi?.count5 ?? 0);
|
|
const countLan = Number(data.wifi?.countLan ?? 0);
|
|
setText(els.wifiTotal, count24 + count5 + countLan);
|
|
setText(els.wifiLan, countLan);
|
|
setText(els.wifi24, count24);
|
|
setText(els.wifi5, count5);
|
|
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("'", ''');
|
|
}
|
|
|
|
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 wifiBandCell(row) {
|
|
if (row?.band !== 'LAN' || !row?.wol_available) {
|
|
return td(row?.band || '-');
|
|
}
|
|
|
|
return `<td><button type="button" class="lan-wake-btn" data-mac="${attr(row.mac || '')}" data-hostname="${attr(row.hostname || '')}" title="${attr(t('wolConfirmTitle'))}">LAN</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 wifiRateCells(row) {
|
|
if (row?.band === 'LAN') {
|
|
return `<td colspan="2" class="lan-link-speed">${escapeHtml(wifiCellValue(row, 'tx_bitrate'))}</td>`;
|
|
}
|
|
|
|
return `${wifiMetricCell(row, 'tx_bitrate')}${wifiMetricCell(row, 'rx_bitrate')}`;
|
|
}
|
|
|
|
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');
|
|
}
|
|
}
|
|
|
|
async function wakeLanDevice(mac, hostname = '') {
|
|
if (!mac) return;
|
|
|
|
const confirmed = await window.customConfirm(t('wolConfirm', { hostname, mac }), {
|
|
title: t('wolConfirmTitle'),
|
|
okText: t('dialogOk'),
|
|
cancelText: t('dialogCancel'),
|
|
});
|
|
if (!confirmed) return;
|
|
|
|
try {
|
|
const data = await api('wake_lan', { mac });
|
|
notice(t('wolSent'), 'success');
|
|
render(data.status || await api('status'));
|
|
} catch (e) {
|
|
console.error(e);
|
|
notice(e.message || t('wolFailed'), '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 wifiInactiveTime(value) {
|
|
const text = String(value ?? '').trim();
|
|
if (text === '' || text.toUpperCase() === 'N/A' || text === '-') {
|
|
return '-';
|
|
}
|
|
|
|
const msMatch = text.match(/^(\d+(?:\.\d+)?)\s*ms$/i);
|
|
if (msMatch) {
|
|
const ms = Number(msMatch[1]);
|
|
if (Number.isFinite(ms) && ms <= 1000) {
|
|
return `${Math.round(ms)} ms`;
|
|
}
|
|
return formatDhms(ms / 1000) || text;
|
|
}
|
|
|
|
return formatDhms(parseWifiDurationSeconds(text)) || text;
|
|
}
|
|
|
|
function wifiCellValue(row, key) {
|
|
const value = row?.[key];
|
|
const text = String(value ?? '').trim();
|
|
if (row?.band === 'LAN' && (text === '' || text.toUpperCase() === 'N/A')) {
|
|
return '-';
|
|
}
|
|
return text || '-';
|
|
}
|
|
|
|
function wifiConnectedCellValue(row) {
|
|
if (row?.band === 'LAN' && wifiCellValue(row, 'connected_time') === '-') {
|
|
return '-';
|
|
}
|
|
|
|
return wifiConnectedTime(row?.connected_time);
|
|
}
|
|
|
|
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;
|
|
const tableWrap = els.wifiTable.closest('.table-wrap');
|
|
const scrollLeft = tableWrap ? tableWrap.scrollLeft : 0;
|
|
els.wifiTable.innerHTML = rows.length
|
|
? rows.map(row => `
|
|
<tr>
|
|
${wifiBandCell(row)}
|
|
${wifiHostCell(row)}
|
|
${td(row.ip)}
|
|
${td(row.mac)}
|
|
${wifiMetricCell(row, 'signal')}
|
|
${wifiRateCells(row)}
|
|
${td(wifiConnectedCellValue(row))}
|
|
${td(wifiInactiveTime(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 || {};
|
|
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));
|
|
if (els.statusBatteryVoltage) {
|
|
setText(els.statusBatteryVoltage, battery.voltage === null || battery.voltage === undefined ? '-' : `${Number(battery.voltage).toFixed(3)} V`);
|
|
}
|
|
if (els.statusBatterySoc) {
|
|
setText(els.statusBatterySoc, battery.percent === null || battery.percent === undefined ? '-' : `${Number(battery.percent).toFixed(2)}%`);
|
|
}
|
|
if (els.statusBatteryRemaining) {
|
|
setText(els.statusBatteryRemaining, battery.remaining?.display || '-');
|
|
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 remoteState(ok, warn = false) {
|
|
if (ok) {
|
|
return {
|
|
className: 'active',
|
|
label: t('remoteOk'),
|
|
};
|
|
}
|
|
|
|
return {
|
|
className: warn ? 'warning' : 'failed',
|
|
label: t('remoteWarn'),
|
|
};
|
|
}
|
|
|
|
function remoteMeta(items) {
|
|
return items
|
|
.filter(item => item.value !== null && item.value !== undefined && item.value !== '')
|
|
.map(item => `${item.label}: ${item.value}`)
|
|
.join(' · ') || '-';
|
|
}
|
|
|
|
function remoteAge(row) {
|
|
if (!row || row.age_seconds === null || row.age_seconds === undefined) {
|
|
return '-';
|
|
}
|
|
|
|
const formatted = formatDhms(Number(row.age_seconds));
|
|
return formatted ? `${formatted} ${t('ago')}` : '-';
|
|
}
|
|
|
|
function renderRemoteLogs(data = {}) {
|
|
if (!els.remoteLogList) return;
|
|
|
|
const piToSynology = data.raspberry_to_synology || {};
|
|
const synologyToPi = data.synology_to_raspberry || {};
|
|
const journal = data.journal || {};
|
|
const pstore = data.pstore || {};
|
|
const newest = synologyToPi.logs?.newest || null;
|
|
const queue = piToSynology.queue || {};
|
|
const rows = [
|
|
{
|
|
title: t('remotePiToSynology'),
|
|
desc: remoteMeta([
|
|
{ label: t('remoteTarget'), value: piToSynology.target || '-' },
|
|
{ label: t('remoteTransport'), value: piToSynology.transport || '-' },
|
|
]),
|
|
meta: remoteMeta([
|
|
{ label: 'rsyslog', value: `${piToSynology.rsyslog_active || '-'} / ${piToSynology.rsyslog_enabled || '-'}` },
|
|
{ label: t('remoteQueue'), value: queue.available === false ? '-' : `${Number(queue.file_count || 0).toLocaleString()} / ${queue.total_size || '-'}` },
|
|
]),
|
|
state: remoteState(Boolean(piToSynology.ok), true),
|
|
},
|
|
{
|
|
title: t('remoteSynologyToPi'),
|
|
desc: remoteMeta([
|
|
{ label: t('remoteListener'), value: synologyToPi.listener ? `:${synologyToPi.listen_port || 6514}` : '-' },
|
|
{ label: t('remoteTransport'), value: synologyToPi.transport || '-' },
|
|
]),
|
|
meta: remoteMeta([
|
|
{ label: t('remoteLatest'), value: newest ? `${newest.name || '-'} / ${remoteAge(newest)}` : '-' },
|
|
{ label: t('remoteFiles'), value: `${Number(synologyToPi.logs?.file_count || 0).toLocaleString()} / ${synologyToPi.logs?.total_size || '-'}` },
|
|
]),
|
|
state: remoteState(Boolean(synologyToPi.ok), true),
|
|
},
|
|
{
|
|
title: t('remotePersistentJournal'),
|
|
desc: journal.path || '-',
|
|
meta: remoteMeta([
|
|
{ label: t('remoteUsage'), value: journal.disk_usage || '-' },
|
|
{ label: t('remoteBoots'), value: journal.boot_count === null || journal.boot_count === undefined ? '-' : Number(journal.boot_count).toLocaleString() },
|
|
]),
|
|
state: remoteState(Boolean(journal.ok), true),
|
|
},
|
|
{
|
|
title: t('remoteCrashLogs'),
|
|
desc: pstore.saved_path || '-',
|
|
meta: remoteMeta([
|
|
{ label: 'pstore', value: pstore.mounted ? 'mounted' : '-' },
|
|
{ label: 'collector', value: pstore.collector_active || '-' },
|
|
{ label: t('remoteSaved'), value: Number(pstore.saved_files || 0).toLocaleString() },
|
|
{ label: t('remotePending'), value: Number(pstore.pending_files || 0).toLocaleString() },
|
|
]),
|
|
state: remoteState(Boolean(pstore.ok), true),
|
|
},
|
|
];
|
|
|
|
els.remoteLogList.innerHTML = rows.map(row => `
|
|
<div class="service-item">
|
|
<div class="service-head">
|
|
<div class="service-title">
|
|
<div class="service-name">${escapeHtml(row.title)}</div>
|
|
<div class="service-desc">${escapeHtml(row.desc)}</div>
|
|
</div>
|
|
<div class="service-state ${row.state.className}">${escapeHtml(row.state.label)}</div>
|
|
</div>
|
|
<div class="service-meta">${escapeHtml(row.meta)}</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function renderCustomServices(rows = []) {
|
|
if (!els.customServiceList) return;
|
|
if (Date.now() < state.customServiceScrollHoldUntil) return;
|
|
|
|
const scrollTop = els.customServiceList.scrollTop;
|
|
|
|
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>`;
|
|
|
|
els.customServiceList.querySelectorAll('.service-log').forEach(log => {
|
|
log.scrollTop = 0;
|
|
});
|
|
els.customServiceList.scrollTop = scrollTop;
|
|
}
|
|
|
|
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 || []);
|
|
renderRemoteLogs(data.remote_logs || {});
|
|
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 yTickCallback = value => {
|
|
const numeric = Number(value);
|
|
const display = Number.isFinite(numeric)
|
|
? numeric.toLocaleString(undefined, { maximumFractionDigits: 2 })
|
|
: String(value);
|
|
return suffix ? `${display}${suffix}` : display;
|
|
};
|
|
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, callback: yTickCallback },
|
|
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.ticks.callback = yTickCallback;
|
|
c.options.scales.y.min = scaleOptions.min ?? 0;
|
|
c.options.scales.y.max = scaleOptions.max;
|
|
c.update('none');
|
|
}
|
|
|
|
function timeAxis(rows) {
|
|
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 xTickMarkers = new Map();
|
|
[60, 120, 180].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 xTickCallback = (_value, index) => xTickMarkers.get(index) || '';
|
|
const xGridColor = context => {
|
|
const index = Number(context.index ?? context.tick?.value ?? -1);
|
|
return xTickMarkers.has(index) ? 'rgba(203,213,225,.18)' : 'rgba(203,213,225,0)';
|
|
};
|
|
|
|
return { labels, xTickCallback, xGridColor };
|
|
}
|
|
|
|
function formatChartNumber(value, suffix = '') {
|
|
const numeric = Number(value);
|
|
if (!Number.isFinite(numeric)) return '-';
|
|
return `${numeric.toLocaleString(undefined, { maximumFractionDigits: 2 })}${suffix}`;
|
|
}
|
|
|
|
function networkRowsForIface(rows, iface) {
|
|
return rows.filter(row => String(row.iface || '') === iface).slice(-180);
|
|
}
|
|
|
|
function networkChart(canvasId, rows, speedKey, speedLabel, packetKey, packetLabel, speedColor = '#0ea5e9') {
|
|
const canvas = $('#' + canvasId);
|
|
if (!canvas || typeof Chart === 'undefined') return;
|
|
|
|
const { labels, xTickCallback, xGridColor } = timeAxis(rows);
|
|
const speedValues = rows.map(row => {
|
|
const value = Number(row[speedKey]);
|
|
return Number.isFinite(value) ? value : null;
|
|
});
|
|
const packetValues = rows.map(row => {
|
|
const value = Number(row[packetKey]);
|
|
return Number.isFinite(value) ? value : null;
|
|
});
|
|
const speedScale = dynamicScaleForValues(speedValues, { minSpan: 1, padding: 0.3 });
|
|
const packetScale = dynamicScaleForValues(packetValues, { minSpan: 10, padding: 5 });
|
|
const datasets = [
|
|
{
|
|
label: speedLabel,
|
|
data: speedValues,
|
|
yAxisID: 'speed',
|
|
borderColor: speedColor,
|
|
backgroundColor: 'transparent',
|
|
borderWidth: 2.2,
|
|
pointRadius: 0,
|
|
tension: 0.22,
|
|
fill: false,
|
|
},
|
|
{
|
|
label: packetLabel,
|
|
data: packetValues,
|
|
yAxisID: 'packets',
|
|
borderColor: '#f97316',
|
|
backgroundColor: 'transparent',
|
|
borderWidth: 2,
|
|
borderDash: [5, 4],
|
|
pointRadius: 0,
|
|
tension: 0.22,
|
|
fill: false,
|
|
},
|
|
];
|
|
const options = {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
animation: false,
|
|
interaction: {
|
|
intersect: false,
|
|
mode: 'index',
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
display: true,
|
|
position: 'bottom',
|
|
labels: {
|
|
boxWidth: 18,
|
|
boxHeight: 2,
|
|
color: '#8d98aa',
|
|
padding: 10,
|
|
},
|
|
},
|
|
tooltip: {
|
|
displayColors: true,
|
|
padding: 8,
|
|
callbacks: {
|
|
label: item => {
|
|
const suffix = item.dataset.yAxisID === 'speed' ? ' MB/s' : ' pps';
|
|
return `${item.dataset.label}: ${formatChartNumber(item.parsed.y, suffix)}`;
|
|
},
|
|
},
|
|
},
|
|
},
|
|
scales: {
|
|
x: {
|
|
ticks: {
|
|
color: '#64748b',
|
|
maxRotation: 0,
|
|
autoSkip: false,
|
|
callback: xTickCallback,
|
|
},
|
|
grid: {
|
|
color: xGridColor,
|
|
drawTicks: false,
|
|
},
|
|
border: { display: false },
|
|
},
|
|
speed: {
|
|
min: speedScale.min,
|
|
max: speedScale.max,
|
|
position: 'left',
|
|
ticks: {
|
|
color: speedColor,
|
|
maxTicksLimit: 4,
|
|
callback: value => formatChartNumber(value, ' MB/s'),
|
|
},
|
|
grid: { color: 'rgba(255,255,255,.045)', drawTicks: false },
|
|
border: { display: false },
|
|
},
|
|
packets: {
|
|
min: packetScale.min,
|
|
max: packetScale.max,
|
|
position: 'right',
|
|
ticks: {
|
|
color: '#f97316',
|
|
maxTicksLimit: 4,
|
|
callback: value => formatChartNumber(value, ' pps'),
|
|
},
|
|
grid: { drawOnChartArea: false, drawTicks: false },
|
|
border: { display: false },
|
|
},
|
|
},
|
|
};
|
|
|
|
if (!state.charts[canvasId]) {
|
|
state.charts[canvasId] = new Chart(canvas, {
|
|
type: 'line',
|
|
data: { labels, datasets },
|
|
options,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const c = state.charts[canvasId];
|
|
c.data.labels = labels;
|
|
c.data.datasets = datasets;
|
|
c.options = options;
|
|
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('cpuWattChart', 'CPUW', rows, 'cpu_watts', '#a855f7', 'W', dynamicScale(rows, 'cpu_watts', { minSpan: 1, padding: 0.4 }, cpuWattSmoothing), { transform: cpuWattSmoothing });
|
|
chart('fanRpmChart', 'RPM', rows, 'fan_rpm', '#3b82f6', ' RPM', dynamicScale(rows, 'fan_rpm', { minSpan: 1000, padding: 400 }));
|
|
if (document.getElementById('batterySocChart')) {
|
|
chart('batterySocChart', 'BATTERYSOC', rows, 'battery_percent', '#f59e0b', '%', dynamicScale(rows, 'battery_percent', { minSpan: 10, ceiling: 110 }));
|
|
}
|
|
if (document.getElementById('remainingChart')) {
|
|
chart('remainingChart', 'REMAINING', rows, 'battery_remaining_seconds', '#06b6d4', 's', dynamicScale(rows, 'battery_remaining_seconds', { minSpan: 1800, padding: 600 }), { tooltipFormat: formatDurationSeconds });
|
|
}
|
|
if (document.getElementById('batteryVoltageChart')) {
|
|
chart('batteryVoltageChart', 'BATTERYV', rows, 'battery_voltage', '#14b8a6', 'V', dynamicScale(rows, 'battery_voltage', { minSpan: 0.2, padding: 0.06 }));
|
|
}
|
|
|
|
const networkRows = data.network_history || [];
|
|
const wanIface = String(data.network?.default_interface || 'eth0');
|
|
const wanRows = networkRowsForIface(networkRows, wanIface);
|
|
const lanRows = networkRowsForIface(networkRows, 'eth1');
|
|
const wifi24Rows = networkRowsForIface(networkRows, 'wlan0');
|
|
const wifi5Rows = networkRowsForIface(networkRows, 'wlan1');
|
|
networkChart('netWanRxChart', wanRows, 'rx_mbytes', t('downloadLine'), 'rx_pps', t('rxPacketsLine'), '#0ea5e9');
|
|
networkChart('netWanTxChart', wanRows, 'tx_mbytes', t('uploadLine'), 'tx_pps', t('txPacketsLine'), '#22c55e');
|
|
networkChart('netLanRxChart', lanRows, 'rx_mbytes', t('downloadLine'), 'rx_pps', t('rxPacketsLine'), '#0ea5e9');
|
|
networkChart('netLanTxChart', lanRows, 'tx_mbytes', t('uploadLine'), 'tx_pps', t('txPacketsLine'), '#22c55e');
|
|
networkChart('netWifi24RxChart', wifi24Rows, 'rx_mbytes', t('downloadLine'), 'rx_pps', t('rxPacketsLine'), '#0ea5e9');
|
|
networkChart('netWifi24TxChart', wifi24Rows, 'tx_mbytes', t('uploadLine'), 'tx_pps', t('txPacketsLine'), '#22c55e');
|
|
networkChart('netWifi5RxChart', wifi5Rows, 'rx_mbytes', t('downloadLine'), 'rx_pps', t('rxPacketsLine'), '#0ea5e9');
|
|
networkChart('netWifi5TxChart', wifi5Rows, 'tx_mbytes', t('uploadLine'), 'tx_pps', t('txPacketsLine'), '#22c55e');
|
|
}
|
|
|
|
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);
|
|
updateSignalTooltip();
|
|
|
|
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 });
|
|
}
|
|
}
|
|
|
|
function scrollSynologyLogToTop() {
|
|
if (!els.synologyLogOutput) return;
|
|
if (els.synologyLogOutput.hidden) return;
|
|
if (Date.now() < state.synologyLogScrollHoldUntil) return;
|
|
|
|
els.synologyLogOutput.scrollTop = 0;
|
|
}
|
|
|
|
function renderSynologyLog(data) {
|
|
if (!els.synologyLogOutput || !els.synologyLogMeta) return;
|
|
if (Date.now() < state.synologyLogScrollHoldUntil) return;
|
|
|
|
const scrollTop = els.synologyLogOutput.scrollTop;
|
|
|
|
if (!data?.available) {
|
|
els.synologyLogOutput.textContent = data?.message || t('synologyLogUnavailable');
|
|
els.synologyLogMeta.textContent = '/var/log/remote/synology/chaegeon.log unavailable';
|
|
return;
|
|
}
|
|
|
|
const lines = data.lines || [];
|
|
const latestKey = `${data.line_count || 0}\n${lines[0] || ''}`;
|
|
const hasNewLine = state.synologyLogLatestKey !== null && latestKey !== state.synologyLogLatestKey;
|
|
|
|
els.synologyLogOutput.textContent = lines.join('\n');
|
|
els.synologyLogMeta.textContent = `${data.path || '/var/log/remote/synology/chaegeon.log'} · ${data.line_count || 0} lines · updated ${data.updated_at || '-'}`;
|
|
state.synologyLogLatestKey = latestKey;
|
|
els.synologyLogOutput.scrollTop = Date.now() < state.synologyLogScrollHoldUntil ? scrollTop : 0;
|
|
|
|
if (hasNewLine) {
|
|
requestAnimationFrame(scrollSynologyLogToTop);
|
|
}
|
|
}
|
|
|
|
async function refreshSynologyLog() {
|
|
if (!state.synologyLogOpen) return;
|
|
|
|
try {
|
|
renderSynologyLog(await api('synology_log'));
|
|
} catch (e) {
|
|
console.error(e);
|
|
if (els.synologyLogOutput) {
|
|
els.synologyLogOutput.textContent = e.message || t('synologyLogRefreshFailed');
|
|
}
|
|
}
|
|
}
|
|
|
|
function stopSynologyLogFallback() {
|
|
clearInterval(state.synologyLogTimer);
|
|
state.synologyLogTimer = null;
|
|
}
|
|
|
|
function startSynologyLogFallback() {
|
|
if (state.synologyLogTimer) return;
|
|
|
|
refreshSynologyLog();
|
|
state.synologyLogTimer = setInterval(refreshSynologyLog, 1000);
|
|
}
|
|
|
|
function setSynologyLogOpen(open) {
|
|
state.synologyLogOpen = open;
|
|
if (els.synologyLogOutput) {
|
|
els.synologyLogOutput.hidden = !open;
|
|
}
|
|
if (els.synologyLogToggle) {
|
|
els.synologyLogToggle.textContent = open ? t('hide') : t('show');
|
|
}
|
|
|
|
stopSynologyLogFallback();
|
|
if (open) {
|
|
if (!sendWs({ type: 'synology_log', open: true })) {
|
|
startSynologyLogFallback();
|
|
}
|
|
} else {
|
|
sendWs({ type: 'synology_log', 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.synologyLogToggle?.addEventListener('click', () => {
|
|
setSynologyLogOpen(!state.synologyLogOpen);
|
|
});
|
|
const holdSynologyLogScroll = () => {
|
|
state.synologyLogScrollHoldUntil = Date.now() + 4000;
|
|
};
|
|
['scroll', 'wheel', 'touchstart', 'touchmove', 'pointerdown'].forEach(eventName => {
|
|
els.synologyLogOutput?.addEventListener(eventName, holdSynologyLogScroll, { passive: true });
|
|
});
|
|
const holdCustomServiceScroll = () => {
|
|
state.customServiceScrollHoldUntil = Date.now() + 4000;
|
|
};
|
|
['scroll', 'wheel', 'touchstart', 'touchmove', 'pointerdown'].forEach(eventName => {
|
|
els.customServiceList?.addEventListener(eventName, holdCustomServiceScroll, { passive: true });
|
|
});
|
|
els.wifiTable?.addEventListener('click', event => {
|
|
const hostButton = event.target?.closest?.('.wifi-host-edit');
|
|
if (hostButton) {
|
|
editWifiHostname(hostButton.dataset.mac || '', hostButton.dataset.hostname || '');
|
|
return;
|
|
}
|
|
|
|
const lanButton = event.target?.closest?.('.lan-wake-btn');
|
|
if (lanButton) {
|
|
wakeLanDevice(lanButton.dataset.mac || '', lanButton.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.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.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();
|
|
}
|
|
if (state.synologyLogOpen && !sendWs({ type: 'synology_log', open: true })) {
|
|
refreshSynologyLog();
|
|
}
|
|
}
|
|
});
|
|
document.addEventListener('keydown', event => {
|
|
if (event.key === 'Escape' && state.settingsOpen) {
|
|
closeSettings();
|
|
}
|
|
});
|
|
|
|
initPreferences();
|
|
connectControlSocket();
|
|
startStatusFallback();
|
|
})();
|