Add language and theme preferences

This commit is contained in:
seo
2026-06-07 17:54:20 +09:00
parent 4a02f7f354
commit 7af96612ab
5 changed files with 434 additions and 112 deletions
+7 -1
View File
@@ -22,6 +22,8 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
- WakeLock 버튼으로 대시보드 화면 꺼짐 방지 - WakeLock 버튼으로 대시보드 화면 꺼짐 방지
- Reboot 버튼으로 2단계 확인 후 시스템 재부팅 요청 - Reboot 버튼으로 2단계 확인 후 시스템 재부팅 요청
- 기본 브라우저 alert/confirm/prompt 대신 대시보드 디자인에 맞춘 custom dialog 사용 - 기본 브라우저 alert/confirm/prompt 대신 대시보드 디자인에 맞춘 custom dialog 사용
- Translate 버튼으로 `en`, `ko` UI 언어 토글
- Theme 버튼으로 `dark`, `light` UI 테마 토글
## 주요 API ## 주요 API
@@ -120,7 +122,9 @@ control-push-healthcheck.service
5. Push 등록은 권한 요청과 구독 저장이 끝나면 즉시 상태를 갱신합니다. 5. Push 등록은 권한 요청과 구독 저장이 끝나면 즉시 상태를 갱신합니다.
6. WakeLock 버튼은 활성 상태를 초록색 버튼으로 표시합니다. 6. WakeLock 버튼은 활성 상태를 초록색 버튼으로 표시합니다.
7. Reboot 버튼은 `재부팅` 단어 입력과 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다. 7. Reboot 버튼은 `재부팅` 단어 입력과 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다.
8. Push 발송, 수신, 표시, 클릭 이벤트를 기록하고 구독 DB의 건강 상태를 갱신합니다. 8. Translate 버튼은 최초 접속 시 디바이스 언어를 기본값으로 사용하고, 변경값은 `localStorage``controlLang`에 저장합니다.
9. Theme 버튼은 기본값 `dark`로 시작하고, 변경값은 `localStorage``controlTheme`에 저장합니다.
10. Push 발송, 수신, 표시, 클릭 이벤트를 기록하고 구독 DB의 건강 상태를 갱신합니다.
## 주요 함수/모듈 ## 주요 함수/모듈
@@ -132,6 +136,7 @@ control-push-healthcheck.service
- `send_push_healthcheck_if_due()`: 쿨다운 기반 헬스체크 알림 발송 - `send_push_healthcheck_if_due()`: 쿨다운 기반 헬스체크 알림 발송
- `assets/wakelock.js`: WakeLock 버튼 상태와 Screen Wake Lock API 제어 - `assets/wakelock.js`: WakeLock 버튼 상태와 Screen Wake Lock API 제어
- `customAlert()`, `customConfirm()`, `customPrompt()`: 대시보드 공통 확인/입력 dialog - `customAlert()`, `customConfirm()`, `customPrompt()`: 대시보드 공통 확인/입력 dialog
- `controlLang`, `controlTheme`: 재접속 후에도 유지되는 언어/테마 localStorage key
## 보안 ## 보안
@@ -150,3 +155,4 @@ control-push-healthcheck.service
- Push 헬스체크 timer 상태는 `systemctl list-timers --all control-push-healthcheck.timer`로 확인합니다. - Push 헬스체크 timer 상태는 `systemctl list-timers --all control-push-healthcheck.timer`로 확인합니다.
- `failed`, `stale` 기기는 알림 권한, 브라우저 데이터 삭제, PWA 재설치 여부를 확인합니다. - `failed`, `stale` 기기는 알림 권한, 브라우저 데이터 삭제, PWA 재설치 여부를 확인합니다.
- Reboot API 사용 전 웹 서버 실행 계정의 sudoers에 `/usr/sbin/reboot` 비밀번호 없는 실행 권한이 제한적으로 설정되어 있는지 확인합니다. - Reboot API 사용 전 웹 서버 실행 계정의 sudoers에 `/usr/sbin/reboot` 비밀번호 없는 실행 권한이 제한적으로 설정되어 있는지 확인합니다.
- 언어/테마 표시가 예상과 다르면 브라우저 localStorage의 `controlLang`, `controlTheme` 값을 확인합니다.
+350 -60
View File
@@ -30,6 +30,8 @@
pushDeviceList: $('#pushDeviceList'), pushDeviceList: $('#pushDeviceList'),
pushHealthcheckBtn: $('#pushHealthcheckBtn'), pushHealthcheckBtn: $('#pushHealthcheckBtn'),
rebootBtn: $('#rebootBtn'), rebootBtn: $('#rebootBtn'),
translateBtn: $('#translateBtn'),
themeBtn: $('#themeBtn'),
customDialog: $('#customDialog'), customDialog: $('#customDialog'),
customDialogTitle: $('#customDialogTitle'), customDialogTitle: $('#customDialogTitle'),
customDialogMessage: $('#customDialogMessage'), customDialogMessage: $('#customDialogMessage'),
@@ -61,8 +63,287 @@
wsReconnectTimer: null, wsReconnectTimer: null,
wsReconnectDelay: 1000, wsReconnectDelay: 1000,
pushDevicesLastRefresh: 0, pushDevicesLastRefresh: 0,
lang: 'en',
theme: 'dark',
}; };
const storageKeys = {
lang: 'controlLang',
theme: 'controlTheme',
};
const messages = {
en: {
updated: 'Updated',
logout: 'Logout',
fanStatus: 'Fan Status',
cpuTemp: 'CPU Temp',
fanRpm: 'Fan RPM',
wifiStatus: 'WiFi Status',
clients24: '2.4G Clients',
clients5: '5G Clients',
systemStatus: 'System Status',
loadAvg: 'Load Avg',
activeUsers: 'Active Users',
sensorHistory: 'Sensor History',
temperature: 'Temperature',
rp1Temp: 'RP1 Temp',
fanEfficiency: 'Fan Efficiency',
cpuWatt: 'CPU Watt',
remaining: 'Remaining',
batteryVoltage: 'Battery Voltage',
wifiClients: 'WiFi Clients',
band: 'Band',
hostname: 'Hostname',
signal: 'Signal',
connected: 'Connected',
inactive: 'Inactive',
systemNotice: 'System Notice',
noticeHistory: 'Notice History',
noNoticeHistory: 'No system notice history.',
pushDevices: 'Push Devices',
healthCheck: 'Health Check',
pushStatusChecking: 'Push status checking...',
noPushDevices: 'No push devices.',
show: 'Show',
hide: 'Hide',
translateButton: 'Translate',
themeButton: 'Theme',
rebootButton: 'Reboot',
themeDark: 'Theme: Dark',
themeLight: 'Theme: Light',
langEn: 'Translate: EN',
langKo: 'Translate: KO',
dialogAlert: 'Alert',
dialogConfirm: 'Confirm',
dialogInput: 'Input',
dialogOk: 'OK',
dialogCancel: 'Cancel',
noWifiClients: 'No connected WiFi clients',
noProcessActivity: 'No process activity',
noCause: 'No candidate',
recordedReason: 'Recorded reason',
instantChange: 'instant change',
tempHigher: 'temperature above average',
tempLower: 'temperature below average',
rpmHigher: 'fan RPM above average',
rpmLower: 'fan RPM below average',
average: 'Average',
current: 'Current',
causeCandidate: 'Candidate',
pushSummary: ({ total, healthy, watch, stale, failed, pending }) => `Push devices: ${total} total / ${healthy} healthy / ${watch} watch / ${stale} stale / ${failed} failed / ${pending} pending`,
statusUnsupported: 'Push status: browser unsupported',
statusDenied: 'Push status: permission denied',
statusUngrant: 'Push status: permission not granted',
statusManualOff: 'Push status: manually disabled',
browserHas: 'browser registered',
browserMissing: 'browser missing',
serverHas: 'server registered',
serverMissing: 'server missing',
serverPending: 'server not checked',
pushStatus: 'Push status',
registeredRefresh: 'Registered refresh',
lastSendSuccess: 'Last send success',
lastReceived: 'Last received',
lastShown: 'Last shown',
lastClick: 'Last click',
failures: 'Failures',
unknownDevice: 'unknown device',
unnamed: 'Unnamed',
pendingReceive: 'Pending receive',
ago: 'ago',
healthSent: ({ sent, failed }) => `Health check sent ${sent} / failed ${failed}`,
healthFailed: 'Health check failed',
refreshFailed: 'refresh failed',
applyingFan: 'Applying fan policy...',
fanUpdated: 'Fan updated',
fanUpdateFailed: 'fan update failed',
dmesgUnavailable: 'dmesg log is not available yet.',
dmesgRefreshFailed: 'dmesg refresh failed',
rebootPrompt: 'Type reboot below to continue.',
rebootPhrase: 'reboot',
rebootTitle: 'System Reboot',
next: 'Next',
rebootCanceled: 'Reboot canceled',
rebootPhraseMismatch: 'Confirmation word does not match.',
adminConfirm: 'Admin Check',
passwordPrompt: 'Enter the administrator password again.',
rebootSending: 'Sending reboot command...',
rebootStarted: 'Reboot Started',
rebootStartedBody: 'Reboot command sent. The connection may drop shortly.',
rebootFailed: 'Reboot Failed',
pushDeviceNamePrompt: 'Enter a device name. Korean names require at least 2 Hangul characters.',
pushRegisterTitle: 'Push Register',
register: 'Register',
phone: 'Phone',
pushDeviceNameInvalid: 'Device name must contain at least 2 Hangul characters.',
autoRepair: 'Auto Repair',
pushDeletePrompt: 'Delete this browser Push subscription?',
pushDeleteTitle: 'Delete Push',
delete: 'Delete',
pushErrorTitle: 'Push Error',
wakeLockTitle: 'Prevent screen sleep',
wakeLockUnsupported: 'WakeLock is not supported by this browser.',
wakeLockErrorTitle: 'WakeLock Error',
},
ko: {
updated: '갱신',
logout: '로그아웃',
fanStatus: '팬 상태',
cpuTemp: 'CPU 온도',
fanRpm: '팬 RPM',
wifiStatus: 'WiFi 상태',
clients24: '2.4G 접속',
clients5: '5G 접속',
systemStatus: '시스템 상태',
loadAvg: '부하 평균',
activeUsers: '활성 사용자',
sensorHistory: '센서 이력',
temperature: '온도',
rp1Temp: 'RP1 온도',
fanEfficiency: '팬 효율',
cpuWatt: 'CPU 전력',
remaining: '잔여 시간',
batteryVoltage: '배터리 전압',
wifiClients: 'WiFi 클라이언트',
band: '대역',
hostname: '호스트명',
signal: '신호',
connected: '연결 시간',
inactive: '비활성',
systemNotice: '시스템 알림',
noticeHistory: '알림 이력',
noNoticeHistory: '시스템 알림 이력이 없습니다.',
pushDevices: 'Push 기기',
healthCheck: '상태 점검',
pushStatusChecking: 'Push 상태 확인 중...',
noPushDevices: 'Push 기기가 없습니다.',
show: '보기',
hide: '숨기기',
translateButton: '번역',
themeButton: '테마',
rebootButton: '재부팅',
themeDark: '테마: 다크',
themeLight: '테마: 라이트',
langEn: '번역: EN',
langKo: '번역: KO',
dialogAlert: '알림',
dialogConfirm: '확인',
dialogInput: '입력',
dialogOk: '확인',
dialogCancel: '취소',
noWifiClients: '연결된 WiFi 클라이언트 없음',
noProcessActivity: '프로세스 활동 없음',
noCause: '원인 후보 없음',
recordedReason: '기록된 이유',
instantChange: '순간 변화',
tempHigher: '온도 평균보다 상승',
tempLower: '온도 평균보다 하강',
rpmHigher: '팬RPM 평균보다 상승',
rpmLower: '팬RPM 평균보다 하강',
average: '평균',
current: '현재',
causeCandidate: '원인 후보',
pushSummary: ({ total, healthy, watch, stale, failed, pending }) => `Push devices: ${total} total / ${healthy} healthy / ${watch} watch / ${stale} stale / ${failed} failed / ${pending} pending`,
statusUnsupported: 'Push 상태: 브라우저 미지원',
statusDenied: 'Push 상태: 권한 꺼짐',
statusUngrant: 'Push 상태: 권한 미허용',
statusManualOff: 'Push 상태: 사용자가 직접 해제함',
browserHas: '브라우저에는 있음',
browserMissing: '브라우저에는 없음',
serverHas: '서버에는 있음',
serverMissing: '서버에는 없음',
serverPending: '서버 확인 전',
pushStatus: 'Push 상태',
registeredRefresh: '등록 갱신',
lastSendSuccess: '마지막 발송 성공',
lastReceived: '마지막 수신',
lastShown: '마지막 표시',
lastClick: '마지막 클릭',
failures: '실패',
unknownDevice: '알 수 없는 기기',
unnamed: '이름 없음',
pendingReceive: '수신 대기',
ago: '전',
healthSent: ({ sent, failed }) => `상태 점검 발송 ${sent} / 실패 ${failed}`,
healthFailed: '상태 점검 실패',
refreshFailed: '갱신 실패',
applyingFan: '팬 정책 적용 중...',
fanUpdated: '팬 갱신 완료',
fanUpdateFailed: '팬 갱신 실패',
dmesgUnavailable: 'dmesg 로그를 아직 사용할 수 없습니다.',
dmesgRefreshFailed: 'dmesg 갱신 실패',
rebootPrompt: '재부팅을 계속하려면 아래에 재부팅을 입력하세요.',
rebootPhrase: '재부팅',
rebootTitle: '시스템 재부팅',
next: '다음',
rebootCanceled: '재부팅 취소',
rebootPhraseMismatch: '확인 단어가 일치하지 않습니다.',
adminConfirm: '관리자 확인',
passwordPrompt: '관리자 암호를 다시 입력하세요.',
rebootSending: '재부팅 명령 전송 중...',
rebootStarted: '재부팅 시작',
rebootStartedBody: '재부팅 명령을 전송했습니다. 잠시 후 연결이 끊어질 수 있습니다.',
rebootFailed: '재부팅 실패',
pushDeviceNamePrompt: '등록할 기기 이름을 입력하세요. 한글 2글자 이상이어야 합니다.',
pushRegisterTitle: 'Push 등록',
register: '등록',
phone: '휴대폰',
pushDeviceNameInvalid: '기기 이름은 한글 2글자 이상이어야 합니다.',
autoRepair: '자동복구',
pushDeletePrompt: '현재 브라우저의 Push 구독을 삭제할까요?',
pushDeleteTitle: 'Push 삭제',
delete: '삭제',
pushErrorTitle: 'Push 오류',
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 = state.theme === 'dark' ? t('themeDark') : t('themeLight'));
}
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 = state.lang === 'ko' ? t('langKo') : t('langEn'));
els.themeBtn && (els.themeBtn.textContent = state.theme === 'dark' ? t('themeDark') : t('themeLight'));
els.rebootBtn && (els.rebootBtn.textContent = t('rebootButton'));
if (els.dmesgToggle) {
els.dmesgToggle.textContent = state.dmesgOpen ? t('hide') : t('show');
}
}
function initPreferences() {
state.lang = localStorage.getItem(storageKeys.lang) || defaultLang();
state.theme = localStorage.getItem(storageKeys.theme) || 'dark';
applyTheme(state.theme);
applyLang(state.lang);
}
function markFanDirty() { function markFanDirty() {
state.fanDirty = true; state.fanDirty = true;
} }
@@ -170,10 +451,10 @@
if (event.key === 'Enter' && (document.activeElement === input || !needsInput)) onOk(); if (event.key === 'Enter' && (document.activeElement === input || !needsInput)) onOk();
}; };
els.customDialogTitle.textContent = options.title || '확인'; els.customDialogTitle.textContent = options.title || t('dialogConfirm');
els.customDialogMessage.textContent = options.message || ''; els.customDialogMessage.textContent = options.message || '';
ok.textContent = options.okText || '확인'; ok.textContent = options.okText || t('dialogOk');
cancel.textContent = options.cancelText || '취소'; cancel.textContent = options.cancelText || t('dialogCancel');
ok.classList.toggle('red', options.danger === true); ok.classList.toggle('red', options.danger === true);
cancel.hidden = mode === 'alert'; cancel.hidden = mode === 'alert';
input.hidden = !needsInput; input.hidden = !needsInput;
@@ -195,17 +476,17 @@
window.customAlert = (message, options = {}) => customDialog(Object.assign({ window.customAlert = (message, options = {}) => customDialog(Object.assign({
mode: 'alert', mode: 'alert',
title: '알림', title: t('dialogAlert'),
message, message,
}, options)); }, options));
window.customConfirm = (message, options = {}) => customDialog(Object.assign({ window.customConfirm = (message, options = {}) => customDialog(Object.assign({
mode: 'confirm', mode: 'confirm',
title: '확인', title: t('dialogConfirm'),
message, message,
}, options)); }, options));
window.customPrompt = (message, options = {}) => customDialog(Object.assign({ window.customPrompt = (message, options = {}) => customDialog(Object.assign({
mode: 'prompt', mode: 'prompt',
title: '입력', title: t('dialogInput'),
message, message,
}, options)); }, options));
@@ -425,7 +706,7 @@
function timeAgo(seconds) { function timeAgo(seconds) {
const formatted = formatDhms(seconds); const formatted = formatDhms(seconds);
return formatted ? `${formatted} ago` : '-'; return formatted ? `${formatted} ${t('ago')}` : '-';
} }
function renderWifi(data) { function renderWifi(data) {
@@ -445,7 +726,7 @@
${td(row.inactive_time)} ${td(row.inactive_time)}
</tr> </tr>
`).join('') `).join('')
: '<tr><td colspan="9">연결된 WiFi 클라이언트 없음</td></tr>'; : `<tr><td colspan="9">${escapeHtml(t('noWifiClients'))}</td></tr>`;
} }
function renderSystemStatus(data) { function renderSystemStatus(data) {
@@ -491,7 +772,7 @@
</tr> </tr>
`; `;
}).join('') }).join('')
: '<tr><td colspan="4">No process activity</td></tr>'; : `<tr><td colspan="4">${escapeHtml(t('noProcessActivity'))}</td></tr>`;
} }
function shortProcessName(p) { function shortProcessName(p) {
@@ -556,7 +837,7 @@
parts.push(`RAM ${Number(mem.mem_percent || 0).toFixed(1)}% ${shortProcessName(mem)}`); parts.push(`RAM ${Number(mem.mem_percent || 0).toFixed(1)}% ${shortProcessName(mem)}`);
} }
return parts.length ? parts.join(' / ') : '원인 후보 없음'; return parts.length ? parts.join(' / ') : t('noCause');
} }
function signedCompact(value, digits = 0, suffix = '') { function signedCompact(value, digits = 0, suffix = '') {
@@ -590,20 +871,20 @@
const m = noticeMetrics(row); const m = noticeMetrics(row);
const reasons = []; const reasons = [];
if (Math.abs(m.tempDelta) >= 3) reasons.push(`온도 평균보다 ${m.tempDelta >= 0 ? '상승' : '하강'}`); if (Math.abs(m.tempDelta) >= 3) reasons.push(t(m.tempDelta >= 0 ? 'tempHigher' : 'tempLower'));
if (Math.abs(m.rpmDelta) >= 1000) reasons.push(`팬RPM 평균보다 ${m.rpmDelta >= 0 ? '상승' : '하강'}`); if (Math.abs(m.rpmDelta) >= 1000) reasons.push(t(m.rpmDelta >= 0 ? 'rpmHigher' : 'rpmLower'));
return `기록된 이유: ${reasons.length ? reasons.join(', ') : '순간 변화'}`; return `${t('recordedReason')}: ${reasons.length ? reasons.join(', ') : t('instantChange')}`;
} }
function noticePreviousText(row) { function noticePreviousText(row) {
const m = noticeMetrics(row); const m = noticeMetrics(row);
return `평균: ${m.avgTemp.toFixed(1)}°C / ${Math.round(m.avgRpm).toLocaleString()} RPM / PWM ${Math.round(m.avgPwm)}`; return `${t('average')}: ${m.avgTemp.toFixed(1)}°C / ${Math.round(m.avgRpm).toLocaleString()} RPM / PWM ${Math.round(m.avgPwm)}`;
} }
function noticeCurrentText(row) { function noticeCurrentText(row) {
return `현재: ${Number(row.current_temp || 0).toFixed(1)}°C / ${Number(row.current_rpm || 0).toLocaleString()} RPM / PWM ${Number(row.current_pwm || 0).toFixed(0)}`; 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 = []) { function renderSpikeHistory(rows = []) {
@@ -619,11 +900,11 @@
<span>${escapeHtml(noticeChangeText(row))}</span> <span>${escapeHtml(noticeChangeText(row))}</span>
<span>${escapeHtml(noticePreviousText(row))}</span> <span>${escapeHtml(noticePreviousText(row))}</span>
<span>${escapeHtml(noticeCurrentText(row))}</span> <span>${escapeHtml(noticeCurrentText(row))}</span>
${processText === '원인 후보 없음' ? '' : `<span>원인 후보: ${escapeHtml(processText)}</span>`} ${processText === t('noCause') ? '' : `<span>${escapeHtml(t('causeCandidate'))}: ${escapeHtml(processText)}</span>`}
</div> </div>
`; `;
}).join('') }).join('')
: '<div class="spike-log-empty">No system notice history.</div>'; : `<div class="spike-log-empty">${escapeHtml(t('noNoticeHistory'))}</div>`;
} }
function renderPushDevices(devices = [], summary = null) { function renderPushDevices(devices = [], summary = null) {
@@ -636,32 +917,32 @@
const stale = Number(summary.stale || 0); const stale = Number(summary.stale || 0);
const failed = Number(summary.failed || 0); const failed = Number(summary.failed || 0);
const pending = Number(summary.pending || 0); const pending = Number(summary.pending || 0);
els.pushStatus.textContent = `Push devices: ${total} total / ${healthy} healthy / ${watch} watch / ${stale} stale / ${failed} failed / ${pending} pending`; els.pushStatus.textContent = t('pushSummary', { total, healthy, watch, stale, failed, pending });
} }
els.pushDeviceList.innerHTML = devices.length els.pushDeviceList.innerHTML = devices.length
? devices.map(device => ` ? devices.map(device => `
<div class="push-device-row ${escapeHtml(device.health_status || 'pending')}"> <div class="push-device-row ${escapeHtml(device.health_status || 'pending')}">
<div class="push-device-main"> <div class="push-device-main">
<strong>${escapeHtml(device.device_name || '이름 없음')}</strong> <strong>${escapeHtml(device.device_name || t('unnamed'))}</strong>
<span class="push-device-badge ${escapeHtml(device.health_status || 'pending')}">${escapeHtml(device.health_text || '수신 대기')}</span> <span class="push-device-badge ${escapeHtml(device.health_status || 'pending')}">${escapeHtml(device.health_text || t('pendingReceive'))}</span>
<span>Host: ${escapeHtml(device.host || 'unknown')}</span> <span>Host: ${escapeHtml(device.host || 'unknown')}</span>
<span>IP: ${escapeHtml(device.actor_ip || '-')}</span> <span>IP: ${escapeHtml(device.actor_ip || '-')}</span>
<span>Created: ${escapeHtml(device.created_at || '-')}</span> <span>Created: ${escapeHtml(device.created_at || '-')}</span>
<span>Registered refresh: ${escapeHtml(device.last_seen_at || '-')} (${escapeHtml(timeAgo(device.last_seen_seconds))})</span> <span>${escapeHtml(t('registeredRefresh'))}: ${escapeHtml(device.last_seen_at || '-')} (${escapeHtml(timeAgo(device.last_seen_seconds))})</span>
<span>Last send success: ${escapeHtml(device.last_send_success_at || '-')} (${escapeHtml(timeAgo(device.last_send_success_seconds))})</span> <span>${escapeHtml(t('lastSendSuccess'))}: ${escapeHtml(device.last_send_success_at || '-')} (${escapeHtml(timeAgo(device.last_send_success_seconds))})</span>
<span>Last received: ${escapeHtml(device.last_received_at || '-')} (${escapeHtml(timeAgo(device.last_received_seconds))})</span> <span>${escapeHtml(t('lastReceived'))}: ${escapeHtml(device.last_received_at || '-')} (${escapeHtml(timeAgo(device.last_received_seconds))})</span>
<span>Last shown: ${escapeHtml(device.last_notification_at || '-')} (${escapeHtml(timeAgo(device.last_notification_seconds))})</span> <span>${escapeHtml(t('lastShown'))}: ${escapeHtml(device.last_notification_at || '-')} (${escapeHtml(timeAgo(device.last_notification_seconds))})</span>
<span>Last click: ${escapeHtml(device.last_click_at || '-')}</span> <span>${escapeHtml(t('lastClick'))}: ${escapeHtml(device.last_click_at || '-')}</span>
<span>Failures: ${Number(device.failure_count || 0).toLocaleString()}${device.last_failure_reason ? ` / ${escapeHtml(device.last_failure_reason)}` : ''}</span> <span>${escapeHtml(t('failures'))}: ${Number(device.failure_count || 0).toLocaleString()}${device.last_failure_reason ? ` / ${escapeHtml(device.last_failure_reason)}` : ''}</span>
<span>Encoding: ${escapeHtml(device.content_encoding || '-')}</span> <span>Encoding: ${escapeHtml(device.content_encoding || '-')}</span>
<span>Hash: ${escapeHtml(device.hash || '-')}</span> <span>Hash: ${escapeHtml(device.hash || '-')}</span>
<span>Endpoint: ${escapeHtml(device.endpoint || '-')}</span> <span>Endpoint: ${escapeHtml(device.endpoint || '-')}</span>
<span>UA: ${escapeHtml(device.user_agent || 'unknown device')}</span> <span>UA: ${escapeHtml(device.user_agent || t('unknownDevice'))}</span>
</div> </div>
</div> </div>
`).join('') `).join('')
: '<div class="spike-log-empty">No push devices.</div>'; : `<div class="spike-log-empty">${escapeHtml(t('noPushDevices'))}</div>`;
} }
function renderPushStatus(detail = {}) { function renderPushStatus(detail = {}) {
@@ -676,19 +957,19 @@
let text = ''; let text = '';
if (!supported) { if (!supported) {
text = 'Push 상태: 브라우저 미지원'; text = t('statusUnsupported');
} else if (permission === 'denied') { } else if (permission === 'denied') {
text = 'Push 상태: 권한 꺼짐'; text = t('statusDenied');
} else if (permission !== 'granted') { } else if (permission !== 'granted') {
text = 'Push 상태: 권한 미허용'; text = t('statusUngrant');
} else if (manualDisabled) { } else if (manualDisabled) {
text = 'Push 상태: 사용자가 직접 해제함'; text = t('statusManualOff');
} else { } else {
const browserText = hasBrowserSubscription ? '브라우저에는 있음' : '브라우저에는 없음'; const browserText = hasBrowserSubscription ? t('browserHas') : t('browserMissing');
const serverText = serverChecked const serverText = serverChecked
? (hasServerSubscription ? '서버에는 있음' : '서버에는 없음') ? (hasServerSubscription ? t('serverHas') : t('serverMissing'))
: '서버 확인 전'; : t('serverPending');
text = `Push 상태: ${browserText} / ${serverText}`; text = `${t('pushStatus')}: ${browserText} / ${serverText}`;
} }
els.pushStatus.textContent = text; els.pushStatus.textContent = text;
@@ -716,9 +997,9 @@
const data = await api('send_push_healthcheck', {}); const data = await api('send_push_healthcheck', {});
renderPushDevices(data.devices || [], data.summary || null); renderPushDevices(data.devices || [], data.summary || null);
const result = data.result || {}; const result = data.result || {};
notice(`Health check sent ${Number(result.sent || 0)} / failed ${Number(result.failed || 0)}`, result.failed ? 'error' : 'success'); notice(t('healthSent', { sent: Number(result.sent || 0), failed: Number(result.failed || 0) }), result.failed ? 'error' : 'success');
} catch (e) { } catch (e) {
notice(e.message || 'Health check failed', 'error'); notice(e.message || t('healthFailed'), 'error');
} finally { } finally {
els.pushHealthcheckBtn.disabled = false; els.pushHealthcheckBtn.disabled = false;
} }
@@ -1052,7 +1333,7 @@
if (!els.dmesgOutput || !els.dmesgMeta) return; if (!els.dmesgOutput || !els.dmesgMeta) return;
if (!data?.available) { if (!data?.available) {
els.dmesgOutput.textContent = data?.message || 'dmesg log is not available yet.'; els.dmesgOutput.textContent = data?.message || t('dmesgUnavailable');
els.dmesgMeta.textContent = '/tmp/dmesg.log unavailable'; els.dmesgMeta.textContent = '/tmp/dmesg.log unavailable';
return; return;
} }
@@ -1078,7 +1359,7 @@
} catch (e) { } catch (e) {
console.error(e); console.error(e);
if (els.dmesgOutput) { if (els.dmesgOutput) {
els.dmesgOutput.textContent = e.message || 'dmesg refresh failed'; els.dmesgOutput.textContent = e.message || t('dmesgRefreshFailed');
} }
} }
} }
@@ -1101,7 +1382,7 @@
els.dmesgOutput.hidden = !open; els.dmesgOutput.hidden = !open;
} }
if (els.dmesgToggle) { if (els.dmesgToggle) {
els.dmesgToggle.textContent = open ? 'Hide' : 'Show'; els.dmesgToggle.textContent = open ? t('hide') : t('show');
} }
stopDmesgFallback(); stopDmesgFallback();
@@ -1121,7 +1402,7 @@
render(await api('status')); render(await api('status'));
} catch (e) { } catch (e) {
console.error(e); console.error(e);
notice(e.message || 'refresh failed', 'error'); notice(e.message || t('refreshFailed'), 'error');
} finally { } finally {
state.loading = false; state.loading = false;
} }
@@ -1159,7 +1440,7 @@
try { try {
state.fanApplying = true; state.fanApplying = true;
if (!quiet) notice('Applying fan policy...', 'info'); if (!quiet) notice(t('applyingFan'), 'info');
const data = await api('fan', { const data = await api('fan', {
mode, mode,
pwm, pwm,
@@ -1167,10 +1448,10 @@
state.fanDirty = false; state.fanDirty = false;
state.fanApplying = false; state.fanApplying = false;
render(data); render(data);
if (!quiet) notice('Fan updated', 'success'); if (!quiet) notice(t('fanUpdated'), 'success');
} catch (e) { } catch (e) {
console.error(e); console.error(e);
notice(e.message || 'fan update failed', 'error'); notice(e.message || t('fanUpdateFailed'), 'error');
} finally { } finally {
state.fanApplying = false; state.fanApplying = false;
const pending = state.fanApplyPending; const pending = state.fanApplyPending;
@@ -1180,26 +1461,26 @@
} }
async function requestReboot() { async function requestReboot() {
const phrase = await window.customPrompt('재부팅을 계속하려면 아래에 재부팅을 입력하세요.', { const phrase = await window.customPrompt(t('rebootPrompt'), {
title: '시스템 재부팅', title: t('rebootTitle'),
okText: '다음', okText: t('next'),
danger: true, danger: true,
placeholder: '재부팅', placeholder: t('rebootPhrase'),
autocomplete: 'off', autocomplete: 'off',
}); });
if (phrase === null) return; if (phrase === null) return;
if (String(phrase).trim() !== '재부팅') { if (String(phrase).trim() !== t('rebootPhrase')) {
await window.customAlert('확인 단어가 일치하지 않습니다.', { await window.customAlert(t('rebootPhraseMismatch'), {
title: '재부팅 취소', title: t('rebootCanceled'),
danger: true, danger: true,
}); });
return; return;
} }
const password = await window.customPrompt('관리자 암호를 다시 입력하세요.', { const password = await window.customPrompt(t('passwordPrompt'), {
title: '관리자 확인', title: t('adminConfirm'),
okText: '재부팅', okText: t('rebootButton'),
danger: true, danger: true,
inputType: 'password', inputType: 'password',
autocomplete: 'current-password', autocomplete: 'current-password',
@@ -1209,20 +1490,20 @@
try { try {
els.rebootBtn.disabled = true; els.rebootBtn.disabled = true;
notice('Reboot command sending...', 'info'); notice(t('rebootSending'), 'info');
await api('reboot', { await api('reboot', {
phrase: '재부팅', phrase: '재부팅',
password, password,
}); });
await window.customAlert('재부팅 명령을 전송했습니다. 잠시 후 연결이 끊어질 수 있습니다.', { await window.customAlert(t('rebootStartedBody'), {
title: '재부팅 시작', title: t('rebootStarted'),
okText: '확인', okText: t('dialogOk'),
}); });
} catch (e) { } catch (e) {
console.error(e); console.error(e);
els.rebootBtn.disabled = false; els.rebootBtn.disabled = false;
await window.customAlert(e.message || 'reboot failed', { await window.customAlert(e.message || 'reboot failed', {
title: '재부팅 실패', title: t('rebootFailed'),
danger: true, danger: true,
}); });
} }
@@ -1254,6 +1535,14 @@
els.rebootBtn?.addEventListener('click', () => { els.rebootBtn?.addEventListener('click', () => {
requestReboot(); requestReboot();
}); });
els.translateBtn?.addEventListener('click', () => {
applyLang(state.lang === 'ko' ? 'en' : 'ko');
refreshPushDevices(true);
});
els.themeBtn?.addEventListener('click', () => {
applyTheme(state.theme === 'dark' ? 'light' : 'dark');
refreshStatus();
});
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (!document.hidden) { if (!document.hidden) {
if (!sendWs({ type: 'status_refresh' })) { if (!sendWs({ type: 'status_refresh' })) {
@@ -1265,6 +1554,7 @@
} }
}); });
initPreferences();
connectControlSocket(); connectControlSocket();
startStatusFallback(); startStatusFallback();
})(); })();
+6 -2
View File
@@ -7,6 +7,10 @@
let wakeLock = null; let wakeLock = null;
let wanted = false; let wanted = false;
function tr(key, fallback) {
return typeof window.controlT === 'function' ? window.controlT(key) : fallback;
}
function supported() { function supported() {
return 'wakeLock' in navigator; return 'wakeLock' in navigator;
} }
@@ -20,7 +24,7 @@
button.disabled = !supported(); button.disabled = !supported();
button.classList.toggle('wake-active', isActive); button.classList.toggle('wake-active', isActive);
button.textContent = 'WakeLock'; button.textContent = 'WakeLock';
button.title = supported() ? '화면 꺼짐 방지' : '현재 브라우저에서 WakeLock을 지원하지 않습니다.'; button.title = supported() ? tr('wakeLockTitle', 'Prevent screen sleep') : tr('wakeLockUnsupported', 'WakeLock is not supported by this browser.');
} }
async function requestWakeLock() { async function requestWakeLock() {
@@ -57,7 +61,7 @@
wakeLock = null; wakeLock = null;
const alertFn = window.customAlert || (() => Promise.resolve()); const alertFn = window.customAlert || (() => Promise.resolve());
alertFn(error.message || 'WakeLock failed', { alertFn(error.message || 'WakeLock failed', {
title: 'WakeLock 오류', title: tr('wakeLockErrorTitle', 'WakeLock Error'),
danger: true, danger: true,
}); });
render(); render();
+56 -38
View File
@@ -71,6 +71,14 @@ if ($loggedIn && array_key_exists('push', $_GET)) {
<link rel="apple-touch-icon" href="/assets/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/assets/apple-touch-icon.png">
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0f1115"> <meta name="theme-color" content="#0f1115">
<script>
try {
const savedTheme = localStorage.getItem('controlTheme') || 'dark';
document.documentElement.dataset.theme = savedTheme === 'light' ? 'light' : 'dark';
const savedLang = localStorage.getItem('controlLang');
document.documentElement.lang = savedLang || ((navigator.language || '').toLowerCase().startsWith('ko') ? 'ko' : 'en');
} catch (e) {}
</script>
<?php if ($loggedIn): ?> <?php if ($loggedIn): ?>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<?php endif; ?> <?php endif; ?>
@@ -78,8 +86,15 @@ if ($loggedIn && array_key_exists('push', $_GET)) {
:root{ :root{
--bg:#0f1115;--card:#171a21;--card2:#202633;--line:#2e3747; --bg:#0f1115;--card:#171a21;--card2:#202633;--line:#2e3747;
--text:#edf1f7;--sub:#a9b4c7;--blue:#3b82f6;--green:#35c46b; --text:#edf1f7;--sub:#a9b4c7;--blue:#3b82f6;--green:#35c46b;
--input:#11151d;--table-head:#1d2330;--code-bg:#070a10;
--red:#ff5f57;--yellow:#ffcc00;--shadow:0 12px 28px rgba(0,0,0,.20); --red:#ff5f57;--yellow:#ffcc00;--shadow:0 12px 28px rgba(0,0,0,.20);
} }
html[data-theme="light"]{
--bg:#f4f7fb;--card:#ffffff;--card2:#eef3f9;--line:#cfd8e5;
--text:#172033;--sub:#657189;--blue:#2563eb;--green:#198754;
--input:#ffffff;--table-head:#e6edf6;--code-bg:#f8fafc;
--red:#c62828;--yellow:#9a6a00;--shadow:0 12px 28px rgba(36,52,78,.13);
}
*{box-sizing:border-box} *{box-sizing:border-box}
html,body{margin:0;background:var(--bg);color:var(--text);font-family:"Gowun Dodum",system-ui,sans-serif} html,body{margin:0;background:var(--bg);color:var(--text);font-family:"Gowun Dodum",system-ui,sans-serif}
button,input,select{font:inherit} button,input,select{font:inherit}
@@ -87,27 +102,28 @@ a{color:inherit;text-decoration:none}
.login-wrap{min-height:100vh;display:grid;place-items:center;padding:24px} .login-wrap{min-height:100vh;display:grid;place-items:center;padding:24px}
.login-box{width:min(420px,100%);background:var(--card);border:1px solid var(--line);border-radius:24px;padding:32px;box-shadow:var(--shadow)} .login-box{width:min(420px,100%);background:var(--card);border:1px solid var(--line);border-radius:24px;padding:32px;box-shadow:var(--shadow)}
.login-box h1{margin:0 0 10px;font-size:28px}.login-box p{margin:0 0 24px;color:var(--sub)} .login-box h1{margin:0 0 10px;font-size:28px}.login-box p{margin:0 0 24px;color:var(--sub)}
.input{width:100%;height:52px;border-radius:14px;border:1px solid var(--line);background:#11151d;color:#fff;padding:0 16px;outline:none} .input{width:100%;height:52px;border-radius:14px;border:1px solid var(--line);background:var(--input);color:var(--text);padding:0 16px;outline:none}
.btn{display:inline-flex;align-items:center;justify-content:center;border:0;cursor:pointer;border-radius:14px;min-height:44px;padding:0 18px;background:var(--blue);color:#fff;font-weight:500;line-height:1.15;text-align:center;transition:filter .12s ease,transform .12s ease} .btn{display:inline-flex;align-items:center;justify-content:center;border:0;cursor:pointer;border-radius:14px;min-height:44px;padding:0 18px;background:var(--blue);color:#fff;font-weight:500;line-height:1.15;text-align:center;transition:filter .12s ease,transform .12s ease}
.btn:hover{filter:brightness(1.05)}.btn:active{transform:translateY(1px)} .btn:hover{filter:brightness(1.05)}.btn:active{transform:translateY(1px)}
.remember-row{display:flex;align-items:center;gap:9px;margin-top:14px;color:var(--sub);font-size:14px}.remember-row input{width:18px;height:18px;margin:0;accent-color:var(--blue)} .remember-row{display:flex;align-items:center;gap:9px;margin-top:14px;color:var(--sub);font-size:14px}.remember-row input{width:18px;height:18px;margin:0;accent-color:var(--blue)}
.btn.secondary{background:#2b3342}.btn.secondary[data-active="1"],.btn.wake-active{background:#198754}.btn.red{background:#c62828}.btn.warn{background:#8a6d1b}.login-btn{width:100%;margin-top:16px}.error{margin-top:16px;color:#ff7b7b} .btn.secondary{background:#2b3342}.btn.secondary[data-active="1"],.btn.wake-active{background:#198754}.btn.red{background:#c62828}.btn.warn{background:#8a6d1b}.login-btn{width:100%;margin-top:16px}.error{margin-top:16px;color:#ff7b7b}
html[data-theme="light"] .btn.secondary{background:#d9e2ef;color:#172033}html[data-theme="light"] .btn.secondary[data-active="1"],html[data-theme="light"] .btn.wake-active{background:#198754;color:#fff}html[data-theme="light"] .btn.warn,html[data-theme="light"] .btn.red{color:#fff}
.app{padding:22px}.topbar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:18px}.topbar h1{margin:0;font-size:29px;font-weight:500;letter-spacing:.01em}.topbar p{margin:6px 0 0;color:var(--sub);font-size:14px}.topbar-right{display:flex;gap:10px;flex-wrap:wrap}.topbar-right .btn{min-width:88px} .app{padding:22px}.topbar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:18px}.topbar h1{margin:0;font-size:29px;font-weight:500;letter-spacing:.01em}.topbar p{margin:6px 0 0;color:var(--sub);font-size:14px}.topbar-right{display:flex;gap:10px;flex-wrap:wrap}.topbar-right .btn{min-width:88px}
.layout{display:grid;grid-template-columns:440px minmax(0,1fr);gap:18px;align-items:start} .layout{display:grid;grid-template-columns:440px minmax(0,1fr);gap:18px;align-items:start}
.stack{display:grid;gap:18px}.card{background:var(--card);border:1px solid rgba(84,101,128,.62);border-radius:22px;padding:18px;box-shadow:var(--shadow);min-width:0}.card h2{margin:0 0 16px;font-size:18px;font-weight:500} .stack{display:grid;gap:18px}.card{background:var(--card);border:1px solid rgba(84,101,128,.62);border-radius:22px;padding:18px;box-shadow:var(--shadow);min-width:0}.card h2{margin:0 0 16px;font-size:18px;font-weight:500}
.stat-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.stat{background:var(--card2);border-radius:16px;padding:14px}.stat.featured{grid-column:1/-1;min-height:112px;display:flex;flex-direction:column;justify-content:center}.stat.featured .stat-value{font-size:36px}.stat-label{color:var(--sub);font-size:13px;margin-bottom:8px}.stat-value{font-size:24px;font-weight:500;word-break:break-word} .stat-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.stat{background:var(--card2);border-radius:16px;padding:14px}.stat.featured{grid-column:1/-1;min-height:112px;display:flex;flex-direction:column;justify-content:center}.stat.featured .stat-value{font-size:36px}.stat-label{color:var(--sub);font-size:13px;margin-bottom:8px}.stat-value{font-size:24px;font-weight:500;word-break:break-word}
.row{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:center;gap:10px;margin-bottom:12px}.small{color:var(--sub);font-size:13px}.select{width:130px;height:42px;border-radius:12px;border:1px solid var(--line);background:#11151d;color:#fff;padding:0 12px}.mode-options{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px}.mode-options label{display:block;min-width:0}.mode-options input{position:absolute;opacity:0;pointer-events:none}.mode-options span{display:grid;place-items:center;min-height:42px;border:1px solid var(--line);border-radius:12px;background:#11151d;color:var(--sub);cursor:pointer;transition:background .12s ease,border-color .12s ease,color .12s ease}.mode-options input:checked+span{background:var(--blue);border-color:var(--blue);color:#fff}.mode-options input[value="off"]:checked+span,.mode-options input[value="disabled"]:checked+span{background:var(--red);border-color:var(--red)}.mode-options input:focus-visible+span{outline:2px solid rgba(59,130,246,.65);outline-offset:2px} .row{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:center;gap:10px;margin-bottom:12px}.small{color:var(--sub);font-size:13px}.select{width:130px;height:42px;border-radius:12px;border:1px solid var(--line);background:var(--input);color:var(--text);padding:0 12px}.mode-options{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px}.mode-options label{display:block;min-width:0}.mode-options input{position:absolute;opacity:0;pointer-events:none}.mode-options span{display:grid;place-items:center;min-height:42px;border:1px solid var(--line);border-radius:12px;background:var(--input);color:var(--sub);cursor:pointer;transition:background .12s ease,border-color .12s ease,color .12s ease}.mode-options input:checked+span{background:var(--blue);border-color:var(--blue);color:#fff}.mode-options input[value="off"]:checked+span,.mode-options input[value="disabled"]:checked+span{background:var(--red);border-color:var(--red)}.mode-options input:focus-visible+span{outline:2px solid rgba(59,130,246,.65);outline-offset:2px}
.slider-value{width:62px;text-align:right;font-weight:500;font-variant-numeric:tabular-nums} .slider-value{width:62px;text-align:right;font-weight:500;font-variant-numeric:tabular-nums}
.slider-wrap{display:flex;gap:12px;align-items:center;margin-top:12px}.slider-wrap[hidden]{display:none}.slider{width:100%;accent-color:var(--blue)} .slider-wrap{display:flex;gap:12px;align-items:center;margin-top:12px}.slider-wrap[hidden]{display:none}.slider{width:100%;accent-color:var(--blue)}
.select:focus,.slider:focus{outline:2px solid rgba(59,130,246,.65);outline-offset:2px} .select:focus,.slider:focus{outline:2px solid rgba(59,130,246,.65);outline-offset:2px}
.btn:disabled{opacity:.55;cursor:not-allowed} .btn:disabled{opacity:.55;cursor:not-allowed}
.status-list{display:grid;gap:10px}.status-row{display:grid;grid-template-columns:112px minmax(0,1fr);gap:12px;align-items:baseline;padding:10px 12px;background:var(--card2);border-radius:14px}.status-key{color:var(--sub);font-size:13px}.status-value{overflow:visible;white-space:normal;word-break:break-word;font-variant-numeric:tabular-nums} .status-list{display:grid;gap:10px}.status-row{display:grid;grid-template-columns:112px minmax(0,1fr);gap:12px;align-items:baseline;padding:10px 12px;background:var(--card2);border-radius:14px}.status-key{color:var(--sub);font-size:13px}.status-value{overflow:visible;white-space:normal;word-break:break-word;font-variant-numeric:tabular-nums}
.table-wrap{max-width:100%;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;border-radius:16px;border:1px solid var(--line)}.wifi-table{width:100%;min-width:1080px;table-layout:fixed;border-collapse:collapse}.wifi-table th,.wifi-table td{padding:11px 13px;border-bottom:1px solid rgba(255,255,255,.06);text-align:left;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.wifi-table th{position:sticky;top:0;background:#1d2330;z-index:1;font-weight:500}.wifi-table tr:hover{background:rgba(255,255,255,.03)}.wifi-table .col-band{width:72px}.wifi-table .col-host{width:240px}.wifi-table .col-ip{width:132px}.wifi-table .col-mac{width:170px}.wifi-table .col-signal{width:100px}.wifi-table .col-rate{width:120px}.wifi-table .col-time{width:130px} .table-wrap{max-width:100%;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;border-radius:16px;border:1px solid var(--line)}.wifi-table{width:100%;min-width:1080px;table-layout:fixed;border-collapse:collapse}.wifi-table th,.wifi-table td{padding:11px 13px;border-bottom:1px solid rgba(128,145,170,.18);text-align:left;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.wifi-table th{position:sticky;top:0;background:var(--table-head);z-index:1;font-weight:500}.wifi-table tr:hover{background:rgba(128,145,170,.09)}.wifi-table .col-band{width:72px}.wifi-table .col-host{width:240px}.wifi-table .col-ip{width:132px}.wifi-table .col-mac{width:170px}.wifi-table .col-signal{width:100px}.wifi-table .col-rate{width:120px}.wifi-table .col-time{width:130px}
.chart-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}.chart-box{height:280px;background:#11151d;border:1px solid rgba(84,101,128,.58);border-radius:16px;padding:12px 12px 18px}.chart-box h3{margin:0 0 8px;color:#c6d3e6;font-size:13px}.chart-canvas{position:relative;height:calc(100% - 27px);min-height:0}.chart-canvas canvas{width:100%!important;height:100%!important} .chart-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}.chart-box{height:280px;background:var(--input);border:1px solid rgba(84,101,128,.58);border-radius:16px;padding:12px 12px 18px}.chart-box h3{margin:0 0 8px;color:var(--sub);font-size:13px}.chart-canvas{position:relative;height:calc(100% - 27px);min-height:0}.chart-canvas canvas{width:100%!important;height:100%!important}
.resource-card{margin-top:18px}.resource-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:16px}.resource-head h2{margin:0}.baseline-pill{color:var(--sub);font-size:12px;font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap}.spike-log-box{margin-top:16px}.spike-log-box h3{margin:0 0 9px;color:#c6d3e6;font-size:14px;font-weight:500}.spike-log-list{display:grid;gap:8px;max-height:260px;overflow:auto;padding-right:4px}.spike-log-item{display:grid;gap:4px;padding:10px 12px;border:1px solid rgba(255,204,0,.35);background:rgba(138,109,27,.16);border-radius:12px}.spike-log-item.latest{border:1px solid rgba(255,204,0,.58);background:rgba(138,109,27,.28);box-shadow:0 0 0 1px rgba(255,204,0,.14) inset;}.spike-log-item strong{font-size:13px;font-weight:500;color:#f3e3a3}.spike-log-item span{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums}.spike-log-empty{padding:10px 12px;border-radius:12px;background:var(--card2);color:var(--sub);font-size:13px}.dmesg-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.dmesg-meta{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums}.dmesg-log{margin:10px 0 0;max-height:420px;overflow:auto;border:1px solid var(--line);border-radius:12px;background:#070a10;color:#d9e2f2;padding:12px;font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre-wrap;word-break:break-word}.dmesg-log[hidden]{display:none} .resource-card{margin-top:18px}.resource-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:16px}.resource-head h2{margin:0}.baseline-pill{color:var(--sub);font-size:12px;font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap}.spike-log-box{margin-top:16px}.spike-log-box h3{margin:0 0 9px;color:#c6d3e6;font-size:14px;font-weight:500}.spike-log-list{display:grid;gap:8px;max-height:260px;overflow:auto;padding-right:4px}.spike-log-item{display:grid;gap:4px;padding:10px 12px;border:1px solid rgba(255,204,0,.35);background:rgba(138,109,27,.16);border-radius:12px}.spike-log-item.latest{border:1px solid rgba(255,204,0,.58);background:rgba(138,109,27,.28);box-shadow:0 0 0 1px rgba(255,204,0,.14) inset;}.spike-log-item strong{font-size:13px;font-weight:500;color:#f3e3a3}.spike-log-item span{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums}.spike-log-empty{padding:10px 12px;border-radius:12px;background:var(--card2);color:var(--sub);font-size:13px}.dmesg-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.dmesg-meta{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums}.dmesg-log{margin:10px 0 0;max-height:420px;overflow:auto;border:1px solid var(--line);border-radius:12px;background:#070a10;color:#d9e2f2;padding:12px;font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre-wrap;word-break:break-word}.dmesg-log[hidden]{display:none}
.resource-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.resource-box{min-width:0}.resource-box h3{margin:0 0 9px;color:#c6d3e6;font-size:14px;font-weight:500}.resource-table{width:100%;min-width:620px;table-layout:fixed;border-collapse:collapse;border:1px solid var(--line);border-radius:14px;overflow:hidden}.resource-table th,.resource-table td{padding:9px 10px;border-bottom:1px solid rgba(255,255,255,.06);background:#11151d;text-align:left;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.resource-table th{background:#1d2330;color:#c6d3e6;font-weight:500}.resource-table .pid{width:72px}.resource-table .metric{width:82px}.resource-table .service{width:150px}.resource-table .cmd{width:auto}.push-device-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px}.push-device-head h3{margin:0}.push-device-actions{display:flex;gap:8px;flex-wrap:wrap}.push-device-list{display:grid;gap:8px;margin-top:8px}.push-device-row{padding:10px 12px;border:1px solid rgba(84,101,128,.58);background:#11151d;border-radius:12px}.push-device-row.healthy{border-color:rgba(53,196,107,.45)}.push-device-row.watch{border-color:rgba(255,204,0,.48)}.push-device-row.stale,.push-device-row.failed{border-color:rgba(255,95,87,.55)}.push-device-main{min-width:0;color:var(--sub);font-size:12px;line-height:1.45;display:grid;gap:2px;word-break:break-all}.push-device-main strong{color:#edf1f7;font-size:13px}.push-device-badge{display:inline-flex;align-items:center;width:max-content;border-radius:999px;padding:2px 8px;background:#2b3342;color:#edf1f7;font-size:11px}.push-device-badge.healthy{background:#198754}.push-device-badge.watch{background:#8a6d1b}.push-device-badge.stale,.push-device-badge.failed{background:#c62828} .resource-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.resource-box{min-width:0}.resource-box h3{margin:0 0 9px;color:var(--sub);font-size:14px;font-weight:500}.resource-table{width:100%;min-width:620px;table-layout:fixed;border-collapse:collapse;border:1px solid var(--line);border-radius:14px;overflow:hidden}.resource-table th,.resource-table td{padding:9px 10px;border-bottom:1px solid rgba(128,145,170,.18);background:var(--input);text-align:left;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.resource-table th{background:var(--table-head);color:var(--sub);font-weight:500}.resource-table .pid{width:72px}.resource-table .metric{width:82px}.resource-table .service{width:150px}.resource-table .cmd{width:auto}.push-device-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px}.push-device-head h3{margin:0}.push-device-actions{display:flex;gap:8px;flex-wrap:wrap}.push-device-list{display:grid;gap:8px;margin-top:8px}.push-device-row{padding:10px 12px;border:1px solid rgba(84,101,128,.58);background:var(--input);border-radius:12px}.push-device-row.healthy{border-color:rgba(53,196,107,.45)}.push-device-row.watch{border-color:rgba(255,204,0,.48)}.push-device-row.stale,.push-device-row.failed{border-color:rgba(255,95,87,.55)}.push-device-main{min-width:0;color:var(--sub);font-size:12px;line-height:1.45;display:grid;gap:2px;word-break:break-all}.push-device-main strong{color:var(--text);font-size:13px}.push-device-badge{display:inline-flex;align-items:center;width:max-content;border-radius:999px;padding:2px 8px;background:#2b3342;color:#edf1f7;font-size:11px}.push-device-badge.healthy{background:#198754}.push-device-badge.watch{background:#8a6d1b}.push-device-badge.stale,.push-device-badge.failed{background:#c62828}
.notice{position:fixed;right:20px;bottom:20px;min-width:220px;max-width:420px;padding:14px 16px;border-radius:14px;font-weight:500;background:#1d2330;border:1px solid var(--line);box-shadow:var(--shadow);z-index:99}.notice:empty{display:none}.notice[data-type=success]{border-color:rgba(53,196,107,.5)}.notice[data-type=error]{border-color:rgba(255,95,87,.5)} .notice{position:fixed;right:20px;bottom:20px;min-width:220px;max-width:420px;padding:14px 16px;border-radius:14px;font-weight:500;background:#1d2330;border:1px solid var(--line);box-shadow:var(--shadow);z-index:99}.notice:empty{display:none}.notice[data-type=success]{border-color:rgba(53,196,107,.5)}.notice[data-type=error]{border-color:rgba(255,95,87,.5)}
.dialog-layer{position:fixed;inset:0;display:none;align-items:center;justify-content:center;padding:22px;background:rgba(4,7,12,.68);backdrop-filter:blur(8px);z-index:120}.dialog-layer[data-open="1"]{display:flex}.dialog-box{width:min(430px,100%);border:1px solid rgba(84,101,128,.78);border-radius:20px;background:#171a21;box-shadow:0 24px 70px rgba(0,0,0,.42);padding:20px}.dialog-box h3{margin:0 0 8px;font-size:19px;font-weight:500}.dialog-message{margin:0 0 16px;color:var(--sub);line-height:1.5;white-space:pre-wrap}.dialog-input{width:100%;height:48px;margin-bottom:14px;border-radius:14px;border:1px solid var(--line);background:#11151d;color:var(--text);padding:0 14px;outline:none}.dialog-input:focus{outline:2px solid rgba(59,130,246,.65);outline-offset:2px}.dialog-actions{display:flex;justify-content:flex-end;gap:10px}.dialog-actions .btn{min-width:84px}.dialog-actions .btn.red{background:#c62828} .dialog-layer{position:fixed;inset:0;display:none;align-items:center;justify-content:center;padding:22px;background:rgba(4,7,12,.68);backdrop-filter:blur(8px);z-index:120}.dialog-layer[data-open="1"]{display:flex}.dialog-box{width:min(430px,100%);border:1px solid rgba(84,101,128,.78);border-radius:20px;background:var(--card);box-shadow:0 24px 70px rgba(0,0,0,.42);padding:20px}.dialog-box h3{margin:0 0 8px;font-size:19px;font-weight:500}.dialog-message{margin:0 0 16px;color:var(--sub);line-height:1.5;white-space:pre-wrap}.dialog-input{width:100%;height:48px;margin-bottom:14px;border-radius:14px;border:1px solid var(--line);background:var(--input);color:var(--text);padding:0 14px;outline:none}.dialog-input:focus{outline:2px solid rgba(59,130,246,.65);outline-offset:2px}.dialog-actions{display:flex;justify-content:flex-end;gap:10px}.dialog-actions .btn{min-width:84px}.dialog-actions .btn.red{background:#c62828}
@media(max-width:1320px){.chart-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} @media(max-width:1320px){.chart-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:1100px){.layout{grid-template-columns:1fr}.chart-grid,.resource-grid{grid-template-columns:1fr}.topbar{align-items:flex-start;flex-direction:column}.topbar-right{width:100%}.topbar-right .btn{flex:1}.stat-grid{grid-template-columns:1fr}.resource-head{align-items:flex-start;flex-direction:column}.baseline-pill{text-align:left;white-space:normal}} @media(max-width:1100px){.layout{grid-template-columns:1fr}.chart-grid,.resource-grid{grid-template-columns:1fr}.topbar{align-items:flex-start;flex-direction:column}.topbar-right{width:100%}.topbar-right .btn{flex:1}.stat-grid{grid-template-columns:1fr}.resource-head{align-items:flex-start;flex-direction:column}.baseline-pill{text-align:left;white-space:normal}}
</style> </style>
@@ -132,23 +148,25 @@ a{color:inherit;text-decoration:none}
<div class="topbar"> <div class="topbar">
<div> <div>
<h1><?= e(APP_NAME) ?></h1> <h1><?= e(APP_NAME) ?></h1>
<p>Updated: <span id="updatedAt">loading...</span></p> <p><span data-i18n="updated">Updated</span>: <span id="updatedAt">loading...</span></p>
</div> </div>
<div class="topbar-right"> <div class="topbar-right">
<button id="pushEnableBtn" class="btn secondary" type="button">Push</button> <button id="pushEnableBtn" class="btn secondary" type="button">Push</button>
<button id="wakeLockBtn" class="btn secondary" type="button">WakeLock</button> <button id="wakeLockBtn" class="btn secondary" type="button">WakeLock</button>
<button id="translateBtn" class="btn secondary" type="button">Translate</button>
<button id="themeBtn" class="btn secondary" type="button">Theme</button>
<button id="rebootBtn" class="btn warn" type="button">Reboot</button> <button id="rebootBtn" class="btn warn" type="button">Reboot</button>
<a href="/?logout=1" class="btn red">Logout</a> <a href="/?logout=1" class="btn red" data-i18n="logout">Logout</a>
</div> </div>
</div> </div>
<main class="layout"> <main class="layout">
<section class="stack"> <section class="stack">
<div class="card"> <div class="card">
<h2>Fan Status</h2> <h2 data-i18n="fanStatus">Fan Status</h2>
<div class="stat-grid"> <div class="stat-grid">
<div class="stat featured"><div class="stat-label">CPU Temp</div><div class="stat-value" id="tempValue">-</div></div> <div class="stat featured"><div class="stat-label" data-i18n="cpuTemp">CPU Temp</div><div class="stat-value" id="tempValue">-</div></div>
<div class="stat"><div class="stat-label">Fan RPM</div><div class="stat-value" id="fanRpm">-</div></div> <div class="stat"><div class="stat-label" data-i18n="fanRpm">Fan RPM</div><div class="stat-value" id="fanRpm">-</div></div>
<div class="stat"><div class="stat-label">PWM %</div><div class="stat-value" id="fanPercent">-</div></div> <div class="stat"><div class="stat-label">PWM %</div><div class="stat-value" id="fanPercent">-</div></div>
</div> </div>
<div style="height:18px"></div> <div style="height:18px"></div>
@@ -164,19 +182,19 @@ a{color:inherit;text-decoration:none}
</div> </div>
<div class="card"> <div class="card">
<h2>WiFi Status</h2> <h2 data-i18n="wifiStatus">WiFi Status</h2>
<div class="stat-grid"> <div class="stat-grid">
<div class="stat"><div class="stat-label">2.4G Clients</div><div class="stat-value" id="wifi24">-</div></div> <div class="stat"><div class="stat-label" data-i18n="clients24">2.4G Clients</div><div class="stat-value" id="wifi24">-</div></div>
<div class="stat"><div class="stat-label">5G Clients</div><div class="stat-value" id="wifi5">-</div></div> <div class="stat"><div class="stat-label" data-i18n="clients5">5G Clients</div><div class="stat-value" id="wifi5">-</div></div>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<h2>System Status</h2> <h2 data-i18n="systemStatus">System Status</h2>
<div class="status-list"> <div class="status-list">
<div class="status-row"><div class="status-key">Host</div><div class="status-value" id="statusHost">-</div></div> <div class="status-row"><div class="status-key">Host</div><div class="status-value" id="statusHost">-</div></div>
<div class="status-row"><div class="status-key">Load Avg</div><div class="status-value" id="statusLoad">-</div></div> <div class="status-row"><div class="status-key" data-i18n="loadAvg">Load Avg</div><div class="status-value" id="statusLoad">-</div></div>
<div class="status-row"><div class="status-key">Active Users</div><div class="status-value" id="statusUsers">-</div></div> <div class="status-row"><div class="status-key" data-i18n="activeUsers">Active Users</div><div class="status-value" id="statusUsers">-</div></div>
<div class="status-row"><div class="status-key">Disk /</div><div class="status-value" id="statusDisk">-</div></div> <div class="status-row"><div class="status-key">Disk /</div><div class="status-value" id="statusDisk">-</div></div>
<div class="status-row"><div class="status-key">Memory</div><div class="status-value" id="statusMemory">-</div></div> <div class="status-row"><div class="status-key">Memory</div><div class="status-value" id="statusMemory">-</div></div>
<div class="status-row"><div class="status-key">Uptime</div><div class="status-value" id="statusUptime">-</div></div> <div class="status-row"><div class="status-key">Uptime</div><div class="status-value" id="statusUptime">-</div></div>
@@ -190,21 +208,21 @@ a{color:inherit;text-decoration:none}
<section class="stack"> <section class="stack">
<div class="card"> <div class="card">
<h2>Sensor History</h2> <h2 data-i18n="sensorHistory">Sensor History</h2>
<div class="chart-grid"> <div class="chart-grid">
<div class="chart-box"><h3>Temperature</h3><div class="chart-canvas"><canvas id="tempChart"></canvas></div></div> <div class="chart-box"><h3 data-i18n="temperature">Temperature</h3><div class="chart-canvas"><canvas id="tempChart"></canvas></div></div>
<div class="chart-box"><h3>RP1 Temp</h3><div class="chart-canvas"><canvas id="rp1TempChart"></canvas></div></div> <div class="chart-box"><h3 data-i18n="rp1Temp">RP1 Temp</h3><div class="chart-canvas"><canvas id="rp1TempChart"></canvas></div></div>
<div class="chart-box"><h3>Fan RPM</h3><div class="chart-canvas"><canvas id="fanRpmChart"></canvas></div></div> <div class="chart-box"><h3 data-i18n="fanRpm">Fan RPM</h3><div class="chart-canvas"><canvas id="fanRpmChart"></canvas></div></div>
<div class="chart-box"><h3>Fan Efficiency</h3><div class="chart-canvas"><canvas id="fanEfficiencyChart"></canvas></div></div> <div class="chart-box"><h3 data-i18n="fanEfficiency">Fan Efficiency</h3><div class="chart-canvas"><canvas id="fanEfficiencyChart"></canvas></div></div>
<div class="chart-box"><h3>CPU Watt</h3><div class="chart-canvas"><canvas id="cpuWattChart"></canvas></div></div> <div class="chart-box"><h3 data-i18n="cpuWatt">CPU Watt</h3><div class="chart-canvas"><canvas id="cpuWattChart"></canvas></div></div>
<div class="chart-box"><h3>Battery SOC</h3><div class="chart-canvas"><canvas id="batterySocChart"></canvas></div></div> <div class="chart-box"><h3>Battery SOC</h3><div class="chart-canvas"><canvas id="batterySocChart"></canvas></div></div>
<div class="chart-box"><h3>Remaining</h3><div class="chart-canvas"><canvas id="remainingChart"></canvas></div></div> <div class="chart-box"><h3 data-i18n="remaining">Remaining</h3><div class="chart-canvas"><canvas id="remainingChart"></canvas></div></div>
<div class="chart-box"><h3>Battery Voltage</h3><div class="chart-canvas"><canvas id="batteryVoltageChart"></canvas></div></div> <div class="chart-box"><h3 data-i18n="batteryVoltage">Battery Voltage</h3><div class="chart-canvas"><canvas id="batteryVoltageChart"></canvas></div></div>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<h2>WiFi Clients</h2> <h2 data-i18n="wifiClients">WiFi Clients</h2>
<div class="table-wrap"> <div class="table-wrap">
<table class="wifi-table"> <table class="wifi-table">
<colgroup> <colgroup>
@@ -218,7 +236,7 @@ a{color:inherit;text-decoration:none}
<col class="col-time"> <col class="col-time">
<col class="col-time"> <col class="col-time">
</colgroup> </colgroup>
<thead><tr><th>Band</th><th>Hostname</th><th>IP</th><th>MAC</th><th>Signal</th><th>TX Rate</th><th>RX Rate</th><th>Connected</th><th>Inactive</th></tr></thead> <thead><tr><th data-i18n="band">Band</th><th data-i18n="hostname">Hostname</th><th>IP</th><th>MAC</th><th data-i18n="signal">Signal</th><th>TX Rate</th><th>RX Rate</th><th data-i18n="connected">Connected</th><th data-i18n="inactive">Inactive</th></tr></thead>
<tbody id="wifiTable"></tbody> <tbody id="wifiTable"></tbody>
</table> </table>
</div> </div>
@@ -228,7 +246,7 @@ a{color:inherit;text-decoration:none}
<section class="card resource-card"> <section class="card resource-card">
<div class="resource-head"> <div class="resource-head">
<h2>System Notice</h2> <h2 data-i18n="systemNotice">System Notice</h2>
<div id="noticeBaseline" class="baseline-pill">Baseline: -</div> <div id="noticeBaseline" class="baseline-pill">Baseline: -</div>
</div> </div>
<div class="resource-grid"> <div class="resource-grid">
@@ -255,29 +273,29 @@ a{color:inherit;text-decoration:none}
</div> </div>
<div class="spike-log-box"> <div class="spike-log-box">
<h3>Notice History</h3> <h3 data-i18n="noticeHistory">Notice History</h3>
<div id="spikeLogList" class="spike-log-list"> <div id="spikeLogList" class="spike-log-list">
<div class="spike-log-empty">No system notice history.</div> <div class="spike-log-empty" data-i18n="noNoticeHistory">No system notice history.</div>
</div> </div>
</div> </div>
<div class="spike-log-box"> <div class="spike-log-box">
<div class="push-device-head"> <div class="push-device-head">
<h3>Push Devices</h3> <h3 data-i18n="pushDevices">Push Devices</h3>
<div class="push-device-actions"> <div class="push-device-actions">
<button class="btn secondary" id="pushHealthcheckBtn" type="button">Health Check</button> <button class="btn secondary" id="pushHealthcheckBtn" type="button" data-i18n="healthCheck">Health Check</button>
</div> </div>
</div> </div>
<div id="pushStatus" class="spike-log-empty">Push status checking...</div> <div id="pushStatus" class="spike-log-empty" data-i18n="pushStatusChecking">Push status checking...</div>
<div id="pushDeviceList" class="push-device-list"> <div id="pushDeviceList" class="push-device-list">
<div class="spike-log-empty">No push devices.</div> <div class="spike-log-empty" data-i18n="noPushDevices">No push devices.</div>
</div> </div>
</div> </div>
<div class="spike-log-box"> <div class="spike-log-box">
<div class="dmesg-head"> <div class="dmesg-head">
<h3>dmesg</h3> <h3>dmesg</h3>
<button class="btn secondary" id="dmesgToggle" type="button">Show</button> <button class="btn secondary" id="dmesgToggle" type="button" data-i18n="show">Show</button>
</div> </div>
<div class="dmesg-meta" id="dmesgMeta">/tmp/dmesg.log</div> <div class="dmesg-meta" id="dmesgMeta">/tmp/dmesg.log</div>
<pre class="dmesg-log" id="dmesgOutput" hidden></pre> <pre class="dmesg-log" id="dmesgOutput" hidden></pre>
@@ -296,9 +314,9 @@ a{color:inherit;text-decoration:none}
</div> </div>
</div> </div>
</div> </div>
<script src="/assets/app.js?v=20260607_reboot1"></script> <script src="/assets/app.js?v=20260607_prefs1"></script>
<script src="/assets/wakelock.js?v=20260607_dialog1"></script> <script src="/assets/wakelock.js?v=20260607_prefs1"></script>
<script src="/push_subscribe.js?v=20260607_dialog1"></script> <script src="/push_subscribe.js?v=20260607_prefs1"></script>
<?php endif; ?> <?php endif; ?>
</body> </body>
</html> </html>
+14 -10
View File
@@ -8,6 +8,10 @@
const pushDisabledStorageKey = 'controlPushDisabled'; const pushDisabledStorageKey = 'controlPushDisabled';
let pushAutoRepairRunning = false; let pushAutoRepairRunning = false;
function tr(key, fallback) {
return typeof window.controlT === 'function' ? window.controlT(key) : fallback;
}
function setButton(text, disabled = false, active = false) { function setButton(text, disabled = false, active = false) {
if (!button) return; if (!button) return;
button.textContent = text || 'Push'; button.textContent = text || 'Push';
@@ -34,10 +38,10 @@
async function deviceNameFromUser() { async function deviceNameFromUser() {
const promptFn = window.customPrompt || (() => Promise.resolve(null)); const promptFn = window.customPrompt || (() => Promise.resolve(null));
const name = await promptFn('등록할 기기 이름을 입력하세요. 한글 2글자 이상이어야 합니다.', { const name = await promptFn(tr('pushDeviceNamePrompt', 'Enter a device name. Korean names require at least 2 Hangul characters.'), {
title: 'Push 등록', title: tr('pushRegisterTitle', 'Push Register'),
okText: '등록', okText: tr('register', 'Register'),
placeholder: '휴대폰', placeholder: tr('phone', 'Phone'),
autocomplete: 'off', autocomplete: 'off',
}); });
@@ -48,7 +52,7 @@
const trimmed = String(name || '').trim(); const trimmed = String(name || '').trim();
if (hangulCount(trimmed) < 2) { if (hangulCount(trimmed) < 2) {
throw new Error('기기 이름은 한글 2글자 이상이어야 합니다.'); throw new Error(tr('pushDeviceNameInvalid', 'Device name must contain at least 2 Hangul characters.'));
} }
return trimmed; return trimmed;
@@ -65,7 +69,7 @@
function savedDeviceName() { function savedDeviceName() {
const stored = String(localStorage.getItem(pushDeviceNameStorageKey) || '').trim(); const stored = String(localStorage.getItem(pushDeviceNameStorageKey) || '').trim();
return hangulCount(stored) >= 2 ? stored : '자동복구'; return hangulCount(stored) >= 2 ? stored : tr('autoRepair', 'Auto Repair');
} }
function urlBase64ToUint8Array(value) { function urlBase64ToUint8Array(value) {
@@ -290,9 +294,9 @@
async function unsubscribe() { async function unsubscribe() {
const confirmFn = window.customConfirm || (() => Promise.resolve(false)); const confirmFn = window.customConfirm || (() => Promise.resolve(false));
if (!await confirmFn('현재 브라우저의 Push 구독을 삭제할까요?', { if (!await confirmFn(tr('pushDeletePrompt', 'Delete this browser Push subscription?'), {
title: 'Push 삭제', title: tr('pushDeleteTitle', 'Delete Push'),
okText: '삭제', okText: tr('delete', 'Delete'),
danger: true, danger: true,
})) { })) {
await refreshButton(); await refreshButton();
@@ -329,7 +333,7 @@
job.catch(error => { job.catch(error => {
const alertFn = window.customAlert || (() => Promise.resolve()); const alertFn = window.customAlert || (() => Promise.resolve());
alertFn(error.message || 'Push failed', { alertFn(error.message || 'Push failed', {
title: 'Push 오류', title: tr('pushErrorTitle', 'Push Error'),
danger: true, danger: true,
}); });
refreshButton().catch(() => {}); refreshButton().catch(() => {});