Add guarded reboot control
This commit is contained in:
@@ -20,6 +20,8 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
|
|||||||
- Web Push 구독 등록, 자동 복구, 구독 기기 목록 표시
|
- Web Push 구독 등록, 자동 복구, 구독 기기 목록 표시
|
||||||
- Push 기기 건강 상태 추적과 헬스체크 수동/자동 실행
|
- Push 기기 건강 상태 추적과 헬스체크 수동/자동 실행
|
||||||
- WakeLock 버튼으로 대시보드 화면 꺼짐 방지
|
- WakeLock 버튼으로 대시보드 화면 꺼짐 방지
|
||||||
|
- Reboot 버튼으로 2단계 확인 후 시스템 재부팅 요청
|
||||||
|
- 기본 브라우저 alert/confirm/prompt 대신 대시보드 디자인에 맞춘 custom dialog 사용
|
||||||
|
|
||||||
## 주요 API
|
## 주요 API
|
||||||
|
|
||||||
@@ -32,6 +34,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
|
|||||||
- `public/api.php?action=send_push_healthcheck`: 등록된 Push 기기 전체에 상태 확인 알림 발송
|
- `public/api.php?action=send_push_healthcheck`: 등록된 Push 기기 전체에 상태 확인 알림 발송
|
||||||
- `public/api.php?action=delete_push_endpoint`: 현재 endpoint 구독 삭제
|
- `public/api.php?action=delete_push_endpoint`: 현재 endpoint 구독 삭제
|
||||||
- `public/api.php?action=dmesg`: dmesg 로그 조회
|
- `public/api.php?action=dmesg`: dmesg 로그 조회
|
||||||
|
- `public/api.php?action=reboot`: 확인 단어와 관리자 암호 재검증 후 sudo reboot 실행
|
||||||
|
|
||||||
## 구성
|
## 구성
|
||||||
|
|
||||||
@@ -116,7 +119,8 @@ control-push-healthcheck.service
|
|||||||
4. 팬 조작은 상태 저장, 정책 적용, 로그 저장 순서로 처리합니다.
|
4. 팬 조작은 상태 저장, 정책 적용, 로그 저장 순서로 처리합니다.
|
||||||
5. Push 등록은 권한 요청과 구독 저장이 끝나면 즉시 상태를 갱신합니다.
|
5. Push 등록은 권한 요청과 구독 저장이 끝나면 즉시 상태를 갱신합니다.
|
||||||
6. WakeLock 버튼은 활성 상태를 초록색 버튼으로 표시합니다.
|
6. WakeLock 버튼은 활성 상태를 초록색 버튼으로 표시합니다.
|
||||||
7. Push 발송, 수신, 표시, 클릭 이벤트를 기록하고 구독 DB의 건강 상태를 갱신합니다.
|
7. Reboot 버튼은 `재부팅` 단어 입력과 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다.
|
||||||
|
8. Push 발송, 수신, 표시, 클릭 이벤트를 기록하고 구독 DB의 건강 상태를 갱신합니다.
|
||||||
|
|
||||||
## 주요 함수/모듈
|
## 주요 함수/모듈
|
||||||
|
|
||||||
@@ -127,12 +131,14 @@ control-push-healthcheck.service
|
|||||||
- `push_health_summary()`: 상태별 Push 기기 수 집계
|
- `push_health_summary()`: 상태별 Push 기기 수 집계
|
||||||
- `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
|
||||||
|
|
||||||
## 보안
|
## 보안
|
||||||
|
|
||||||
- 로그인 세션과 CSRF token을 사용합니다.
|
- 로그인 세션과 CSRF token을 사용합니다.
|
||||||
- POST 조작 API는 CSRF 검증을 통과해야 합니다.
|
- POST 조작 API는 CSRF 검증을 통과해야 합니다.
|
||||||
- sysfs 쓰기와 systemctl 실행 권한은 sudoers 범위로 제한해야 합니다.
|
- sysfs 쓰기와 systemctl 실행 권한은 sudoers 범위로 제한해야 합니다.
|
||||||
|
- 재부팅 API는 CSRF, 로그인 세션, 확인 단어 `재부팅`, 관리자 암호 재검증을 모두 요구합니다.
|
||||||
- VAPID key와 앱 비밀번호는 저장소 밖 secret 파일로 관리합니다.
|
- VAPID key와 앱 비밀번호는 저장소 밖 secret 파일로 관리합니다.
|
||||||
|
|
||||||
## 운영 체크포인트
|
## 운영 체크포인트
|
||||||
@@ -143,3 +149,4 @@ control-push-healthcheck.service
|
|||||||
- Push 구독 자동 복구가 과도한 반복 등록을 만들지 않는지 확인합니다.
|
- Push 구독 자동 복구가 과도한 반복 등록을 만들지 않는지 확인합니다.
|
||||||
- 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` 비밀번호 없는 실행 권한이 제한적으로 설정되어 있는지 확인합니다.
|
||||||
|
|||||||
@@ -1751,6 +1751,51 @@ function control_api_dispatch(): void
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($action === 'reboot') {
|
||||||
|
$phrase = trim((string)($_POST['phrase'] ?? ''));
|
||||||
|
$password = (string)($_POST['password'] ?? '');
|
||||||
|
|
||||||
|
if ($phrase !== '재부팅') {
|
||||||
|
json_out([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'bad_reboot_phrase',
|
||||||
|
'message' => '재부팅 확인 단어가 올바르지 않습니다.',
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hash_equals(APP_PASSWORD, $password)) {
|
||||||
|
json_out([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'bad_reboot_password',
|
||||||
|
'message' => '관리자 암호가 올바르지 않습니다.',
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
add_fan_action(
|
||||||
|
'system_reboot',
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
'web reboot requested',
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = sh(['/usr/sbin/reboot'], true, 5);
|
||||||
|
if ($result['code'] !== 0) {
|
||||||
|
json_out([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'reboot_failed',
|
||||||
|
'message' => trim($result['out']) !== '' ? trim($result['out']) : 'reboot command failed',
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
json_out([
|
||||||
|
'ok' => true,
|
||||||
|
'data' => [
|
||||||
|
'message' => 'reboot_requested',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
if ($action === 'wifi') {
|
if ($action === 'wifi') {
|
||||||
$verb = (string)($_POST['verb'] ?? '');
|
$verb = (string)($_POST['verb'] ?? '');
|
||||||
$unit = (string)($_POST['unit'] ?? '');
|
$unit = (string)($_POST['unit'] ?? '');
|
||||||
|
|||||||
@@ -29,6 +29,13 @@
|
|||||||
pushStatus: $('#pushStatus'),
|
pushStatus: $('#pushStatus'),
|
||||||
pushDeviceList: $('#pushDeviceList'),
|
pushDeviceList: $('#pushDeviceList'),
|
||||||
pushHealthcheckBtn: $('#pushHealthcheckBtn'),
|
pushHealthcheckBtn: $('#pushHealthcheckBtn'),
|
||||||
|
rebootBtn: $('#rebootBtn'),
|
||||||
|
customDialog: $('#customDialog'),
|
||||||
|
customDialogTitle: $('#customDialogTitle'),
|
||||||
|
customDialogMessage: $('#customDialogMessage'),
|
||||||
|
customDialogInput: $('#customDialogInput'),
|
||||||
|
customDialogCancel: $('#customDialogCancel'),
|
||||||
|
customDialogOk: $('#customDialogOk'),
|
||||||
processCpuTable: $('#processCpuTable'),
|
processCpuTable: $('#processCpuTable'),
|
||||||
processMemoryTable: $('#processMemoryTable'),
|
processMemoryTable: $('#processMemoryTable'),
|
||||||
dmesgToggle: $('#dmesgToggle'),
|
dmesgToggle: $('#dmesgToggle'),
|
||||||
@@ -111,6 +118,97 @@
|
|||||||
}, 2600);
|
}, 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 = event => {
|
||||||
|
if (event.target === layer) onCancel();
|
||||||
|
};
|
||||||
|
const onKeydown = event => {
|
||||||
|
if (event.key === 'Escape') onCancel();
|
||||||
|
if (event.key === 'Enter' && (document.activeElement === input || !needsInput)) onOk();
|
||||||
|
};
|
||||||
|
|
||||||
|
els.customDialogTitle.textContent = options.title || '확인';
|
||||||
|
els.customDialogMessage.textContent = options.message || '';
|
||||||
|
ok.textContent = options.okText || '확인';
|
||||||
|
cancel.textContent = options.cancelText || '취소';
|
||||||
|
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: '알림',
|
||||||
|
message,
|
||||||
|
}, options));
|
||||||
|
window.customConfirm = (message, options = {}) => customDialog(Object.assign({
|
||||||
|
mode: 'confirm',
|
||||||
|
title: '확인',
|
||||||
|
message,
|
||||||
|
}, options));
|
||||||
|
window.customPrompt = (message, options = {}) => customDialog(Object.assign({
|
||||||
|
mode: 'prompt',
|
||||||
|
title: '입력',
|
||||||
|
message,
|
||||||
|
}, options));
|
||||||
|
|
||||||
async function api(action, body = null) {
|
async function api(action, body = null) {
|
||||||
const opts = {
|
const opts = {
|
||||||
method: body ? 'POST' : 'GET',
|
method: body ? 'POST' : 'GET',
|
||||||
@@ -1081,6 +1179,55 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requestReboot() {
|
||||||
|
const phrase = await window.customPrompt('재부팅을 계속하려면 아래에 재부팅을 입력하세요.', {
|
||||||
|
title: '시스템 재부팅',
|
||||||
|
okText: '다음',
|
||||||
|
danger: true,
|
||||||
|
placeholder: '재부팅',
|
||||||
|
autocomplete: 'off',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (phrase === null) return;
|
||||||
|
if (String(phrase).trim() !== '재부팅') {
|
||||||
|
await window.customAlert('확인 단어가 일치하지 않습니다.', {
|
||||||
|
title: '재부팅 취소',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = await window.customPrompt('관리자 암호를 다시 입력하세요.', {
|
||||||
|
title: '관리자 확인',
|
||||||
|
okText: '재부팅',
|
||||||
|
danger: true,
|
||||||
|
inputType: 'password',
|
||||||
|
autocomplete: 'current-password',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (password === null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
els.rebootBtn.disabled = true;
|
||||||
|
notice('Reboot command sending...', 'info');
|
||||||
|
await api('reboot', {
|
||||||
|
phrase: '재부팅',
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
await window.customAlert('재부팅 명령을 전송했습니다. 잠시 후 연결이 끊어질 수 있습니다.', {
|
||||||
|
title: '재부팅 시작',
|
||||||
|
okText: '확인',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
els.rebootBtn.disabled = false;
|
||||||
|
await window.customAlert(e.message || 'reboot failed', {
|
||||||
|
title: '재부팅 실패',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (els.fanSlider && els.fanSliderValue) {
|
if (els.fanSlider && els.fanSliderValue) {
|
||||||
els.fanSlider.addEventListener('input', () => {
|
els.fanSlider.addEventListener('input', () => {
|
||||||
markFanDirty();
|
markFanDirty();
|
||||||
@@ -1104,6 +1251,9 @@
|
|||||||
els.dmesgToggle?.addEventListener('click', () => {
|
els.dmesgToggle?.addEventListener('click', () => {
|
||||||
setDmesgOpen(!state.dmesgOpen);
|
setDmesgOpen(!state.dmesgOpen);
|
||||||
});
|
});
|
||||||
|
els.rebootBtn?.addEventListener('click', () => {
|
||||||
|
requestReboot();
|
||||||
|
});
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (!document.hidden) {
|
if (!document.hidden) {
|
||||||
if (!sendWs({ type: 'status_refresh' })) {
|
if (!sendWs({ type: 'status_refresh' })) {
|
||||||
|
|||||||
@@ -55,7 +55,11 @@
|
|||||||
job.catch(error => {
|
job.catch(error => {
|
||||||
wanted = false;
|
wanted = false;
|
||||||
wakeLock = null;
|
wakeLock = null;
|
||||||
alert(error.message || 'WakeLock failed');
|
const alertFn = window.customAlert || (() => Promise.resolve());
|
||||||
|
alertFn(error.message || 'WakeLock failed', {
|
||||||
|
title: 'WakeLock 오류',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+16
-3
@@ -107,6 +107,7 @@ a{color:inherit;text-decoration: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-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:#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}
|
||||||
.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}
|
||||||
@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>
|
||||||
@@ -136,6 +137,7 @@ a{color:inherit;text-decoration:none}
|
|||||||
<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="rebootBtn" class="btn warn" type="button">Reboot</button>
|
||||||
<a href="/?logout=1" class="btn red">Logout</a>
|
<a href="/?logout=1" class="btn red">Logout</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -283,9 +285,20 @@ a{color:inherit;text-decoration:none}
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<div id="notice" class="notice"></div>
|
<div id="notice" class="notice"></div>
|
||||||
<script src="/assets/app.js?v=20260607_pushhealth1"></script>
|
<div id="customDialog" class="dialog-layer" aria-hidden="true">
|
||||||
<script src="/assets/wakelock.js?v=20260606_wakelock2"></script>
|
<div class="dialog-box" role="dialog" aria-modal="true" aria-labelledby="customDialogTitle">
|
||||||
<script src="/push_subscribe.js?v=20260606_noappend1"></script>
|
<h3 id="customDialogTitle">확인</h3>
|
||||||
|
<p id="customDialogMessage" class="dialog-message"></p>
|
||||||
|
<input id="customDialogInput" class="dialog-input" type="text" autocomplete="off" hidden>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button id="customDialogCancel" class="btn secondary" type="button">취소</button>
|
||||||
|
<button id="customDialogOk" class="btn" type="button">확인</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/assets/app.js?v=20260607_reboot1"></script>
|
||||||
|
<script src="/assets/wakelock.js?v=20260607_dialog1"></script>
|
||||||
|
<script src="/push_subscribe.js?v=20260607_dialog1"></script>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -32,8 +32,14 @@
|
|||||||
return (String(value || '').match(/[가-힣ㄱ-ㅎㅏ-ㅣ]/g) || []).length;
|
return (String(value || '').match(/[가-힣ㄱ-ㅎㅏ-ㅣ]/g) || []).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
function deviceNameFromUser() {
|
async function deviceNameFromUser() {
|
||||||
const name = prompt('등록할까요? 등록하려면 디바이스 이름을 지정하세요 최소 2글자.', '');
|
const promptFn = window.customPrompt || (() => Promise.resolve(null));
|
||||||
|
const name = await promptFn('등록할 기기 이름을 입력하세요. 한글 2글자 이상이어야 합니다.', {
|
||||||
|
title: 'Push 등록',
|
||||||
|
okText: '등록',
|
||||||
|
placeholder: '휴대폰',
|
||||||
|
autocomplete: 'off',
|
||||||
|
});
|
||||||
|
|
||||||
if (name === null) {
|
if (name === null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -251,7 +257,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const deviceName = deviceNameFromUser();
|
const deviceName = await deviceNameFromUser();
|
||||||
if (deviceName === null) {
|
if (deviceName === null) {
|
||||||
await refreshButton();
|
await refreshButton();
|
||||||
return;
|
return;
|
||||||
@@ -283,7 +289,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function unsubscribe() {
|
async function unsubscribe() {
|
||||||
if (!confirm('푸시 기기 삭제할까요?')) {
|
const confirmFn = window.customConfirm || (() => Promise.resolve(false));
|
||||||
|
if (!await confirmFn('현재 브라우저의 Push 구독을 삭제할까요?', {
|
||||||
|
title: 'Push 삭제',
|
||||||
|
okText: '삭제',
|
||||||
|
danger: true,
|
||||||
|
})) {
|
||||||
await refreshButton();
|
await refreshButton();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -316,7 +327,11 @@
|
|||||||
const active = button.dataset.active === '1';
|
const active = button.dataset.active === '1';
|
||||||
const job = active ? unsubscribe() : subscribe();
|
const job = active ? unsubscribe() : subscribe();
|
||||||
job.catch(error => {
|
job.catch(error => {
|
||||||
alert(error.message || 'Push failed');
|
const alertFn = window.customAlert || (() => Promise.resolve());
|
||||||
|
alertFn(error.message || 'Push failed', {
|
||||||
|
title: 'Push 오류',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
refreshButton().catch(() => {});
|
refreshButton().catch(() => {});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user