Use Home Assistant notifications
This commit is contained in:
+22
-78
@@ -1275,7 +1275,7 @@ function signed_delta_compact(float $value, int $decimals, string $suffix = ''):
|
||||
return $sign . number_format($value, $decimals) . $suffix;
|
||||
}
|
||||
|
||||
function push_process_name(array $row): string
|
||||
function display_process_name(array $row): string
|
||||
{
|
||||
$service = (string)($row['service'] ?? '');
|
||||
if ($service !== '' && $service !== 'N/A') {
|
||||
@@ -1344,7 +1344,7 @@ function expected_process_text(array $processes): string
|
||||
}
|
||||
|
||||
if ($cpu !== null && (float)($cpu['cpu_percent'] ?? 0) >= 1.0) {
|
||||
return 'CPU ' . push_process_name($cpu);
|
||||
return 'CPU ' . display_process_name($cpu);
|
||||
}
|
||||
|
||||
$mem = null;
|
||||
@@ -1358,7 +1358,7 @@ function expected_process_text(array $processes): string
|
||||
}
|
||||
|
||||
if ($mem !== null) {
|
||||
return 'MEM ' . push_process_name($mem);
|
||||
return 'MEM ' . display_process_name($mem);
|
||||
}
|
||||
|
||||
return 'CPU/MEM 원인 후보 없음';
|
||||
@@ -1398,17 +1398,17 @@ function expected_process_detail_text(array $processes): string
|
||||
}
|
||||
|
||||
if ($cpu !== null && $mem !== null && process_identity($mem) === process_identity($cpu)) {
|
||||
return push_process_name($cpu);
|
||||
return display_process_name($cpu);
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
|
||||
if ($cpu !== null) {
|
||||
$parts[] = sprintf('CPU %.1f%% %s', (float)($cpu['cpu_percent'] ?? 0), push_process_name($cpu));
|
||||
$parts[] = sprintf('CPU %.1f%% %s', (float)($cpu['cpu_percent'] ?? 0), display_process_name($cpu));
|
||||
}
|
||||
|
||||
if ($mem !== null) {
|
||||
$parts[] = sprintf('RAM %.1f%% %s', (float)($mem['mem_percent'] ?? 0), push_process_name($mem));
|
||||
$parts[] = sprintf('RAM %.1f%% %s', (float)($mem['mem_percent'] ?? 0), display_process_name($mem));
|
||||
}
|
||||
|
||||
return $parts === [] ? '원인 후보 없음' : implode(' / ', $parts);
|
||||
@@ -1439,7 +1439,7 @@ function notice_downward_only(float $tempDelta, float $rpmDelta): bool
|
||||
return true;
|
||||
}
|
||||
|
||||
function send_fan_spike_push(array $spike, array $fan, array $system, array $processes): array
|
||||
function send_fan_spike_notify(array $spike, array $fan, array $system, array $processes): array
|
||||
{
|
||||
$tempDelta = (float)($spike['temp_delta'] ?? 0);
|
||||
$rpmDelta = (float)($spike['rpm_delta'] ?? 0);
|
||||
@@ -1476,13 +1476,18 @@ function send_fan_spike_push(array $spike, array $fan, array $system, array $pro
|
||||
. number_format((float)($fan['pwm'] ?? 0), 0)
|
||||
. (notice_downward_only($tempDelta, $rpmDelta) ? '' : "\n원인 후보: " . expected_process_detail_text($processes));
|
||||
|
||||
return send_push_payload([
|
||||
return send_ha_notify([
|
||||
'title' => '시스템 유의사항',
|
||||
'body' => $body,
|
||||
'message' => $body,
|
||||
'level' => 'warning',
|
||||
'url' => '/',
|
||||
'tag' => 'system-notice-' . date('YmdHi'),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'tag' => 'control-system-notice',
|
||||
'data' => [
|
||||
'channel' => 'control_system',
|
||||
'notification_icon' => 'mdi:fan-alert',
|
||||
'color' => '#f59e0b',
|
||||
'sticky' => 'false',
|
||||
'renotify' => 'true',
|
||||
'summary' => $spike['summary'] ?? '',
|
||||
'rpm_delta' => $spike['rpm_delta'] ?? 0,
|
||||
'pwm_delta' => $spike['pwm_delta'] ?? 0,
|
||||
@@ -1492,16 +1497,9 @@ function send_fan_spike_push(array $spike, array $fan, array $system, array $pro
|
||||
]);
|
||||
}
|
||||
|
||||
function latest_system_notice_push_epoch(): int
|
||||
function latest_system_notice_notify_epoch(): int
|
||||
{
|
||||
$stmt = db()->query("
|
||||
SELECT UNIX_TIMESTAMP(MAX(created_at))
|
||||
FROM push_event_logs
|
||||
WHERE event = 'send_success'
|
||||
AND meta LIKE '%\"tag\":\"system-notice-%'
|
||||
");
|
||||
|
||||
return (int)($stmt->fetchColumn() ?: 0);
|
||||
return latest_ha_notify_epoch_by_tag('control-system-notice');
|
||||
}
|
||||
|
||||
function fan_spike_history(int $limit = 100): array
|
||||
@@ -1981,14 +1979,14 @@ function collect_snapshot(bool $applyFan = true): array
|
||||
$fanSpike = fan_spike_analysis($history, $fan, $system, $processes);
|
||||
|
||||
if (!empty($fanSpike['active'])) {
|
||||
$latestNoticePush = latest_system_notice_push_epoch();
|
||||
$latestNoticeNotify = latest_system_notice_notify_epoch();
|
||||
$spikeLogId = add_fan_spike_log($fanSpike, $fan, $system, $processes);
|
||||
if ($spikeLogId > 0) {
|
||||
$fanSpike['log_id'] = $spikeLogId;
|
||||
if ($latestNoticePush <= 0 || time() - $latestNoticePush >= 600) {
|
||||
$fanSpike['push'] = send_fan_spike_push($fanSpike, $fan, $system, $processes);
|
||||
if ($latestNoticeNotify <= 0 || time() - $latestNoticeNotify >= 600) {
|
||||
$fanSpike['notify'] = send_fan_spike_notify($fanSpike, $fan, $system, $processes);
|
||||
} else {
|
||||
$fanSpike['push'] = [
|
||||
$fanSpike['notify'] = [
|
||||
'sent' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => 'system_notice_cooldown',
|
||||
@@ -2040,60 +2038,6 @@ function control_api_dispatch(): void
|
||||
]);
|
||||
}
|
||||
|
||||
if ($action === 'push_devices') {
|
||||
json_out([
|
||||
'ok' => true,
|
||||
'data' => [
|
||||
'devices' => push_device_rows(),
|
||||
'summary' => push_health_summary(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($action === 'send_push_healthcheck') {
|
||||
require_csrf();
|
||||
|
||||
json_out([
|
||||
'ok' => true,
|
||||
'data' => [
|
||||
'result' => send_push_healthcheck_if_due(24, true),
|
||||
'devices' => push_device_rows(),
|
||||
'summary' => push_health_summary(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($action === 'push_status') {
|
||||
json_out([
|
||||
'ok' => true,
|
||||
'data' => push_subscription_status((string)($_GET['endpoint'] ?? $_POST['endpoint'] ?? '')),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($action === 'delete_push_device') {
|
||||
delete_push_device((string)($_POST['endpoint_hash'] ?? ''));
|
||||
|
||||
json_out([
|
||||
'ok' => true,
|
||||
'data' => [
|
||||
'devices' => push_device_rows(),
|
||||
'summary' => push_health_summary(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($action === 'delete_push_endpoint') {
|
||||
delete_push_endpoint((string)($_POST['endpoint'] ?? ''));
|
||||
|
||||
json_out([
|
||||
'ok' => true,
|
||||
'data' => [
|
||||
'devices' => push_device_rows(),
|
||||
'summary' => push_health_summary(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($action === 'fan') {
|
||||
$mode = (string)($_POST['mode'] ?? 'auto');
|
||||
$pwm = max(0, min(255, (int)($_POST['pwm'] ?? 120)));
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||
json_out(['ok' => false, 'error' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$raw = (string)file_get_contents('php://input');
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
$data = [];
|
||||
}
|
||||
|
||||
$event = (string)($data['event'] ?? '');
|
||||
$allowedEvents = [
|
||||
'push_received',
|
||||
'notification_shown',
|
||||
'notification_show_failed',
|
||||
'notification_click',
|
||||
'notification_close',
|
||||
'client_log_failed',
|
||||
];
|
||||
|
||||
if (!in_array($event, $allowedEvents, true)) {
|
||||
json_out(['ok' => false, 'error' => 'invalid_event'], 422);
|
||||
}
|
||||
|
||||
$endpoint = (string)($data['endpoint'] ?? '');
|
||||
$meta = is_array($data['meta'] ?? null) ? $data['meta'] : [];
|
||||
$context = [
|
||||
'endpoint' => $endpoint,
|
||||
'message' => mb_substr((string)($data['push_id'] ?? ''), 0, 255),
|
||||
'push_id' => (string)($data['push_id'] ?? ''),
|
||||
'tag' => (string)($data['tag'] ?? ''),
|
||||
'visibility_state' => (string)($data['visibility_state'] ?? ''),
|
||||
'client_count' => isset($data['client_count']) ? (int)$data['client_count'] : null,
|
||||
'meta' => $meta,
|
||||
];
|
||||
|
||||
push_log_event($event, $context);
|
||||
|
||||
json_out(['ok' => true]);
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
|
||||
if (!signed_in()) {
|
||||
json_out([
|
||||
'ok' => false,
|
||||
'error' => 'login_required',
|
||||
], 401);
|
||||
}
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||
json_out([
|
||||
'ok' => false,
|
||||
'error' => 'method_not_allowed',
|
||||
], 405);
|
||||
}
|
||||
|
||||
require_csrf();
|
||||
|
||||
$subscription = push_subscription_from_json((string)file_get_contents('php://input'));
|
||||
save_push_subscription($subscription);
|
||||
|
||||
json_out([
|
||||
'ok' => true,
|
||||
]);
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
|
||||
if (!signed_in()) {
|
||||
json_out([
|
||||
'ok' => false,
|
||||
'error' => 'login_required',
|
||||
], 401);
|
||||
}
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||
json_out([
|
||||
'ok' => false,
|
||||
'error' => 'method_not_allowed',
|
||||
], 405);
|
||||
}
|
||||
|
||||
require_csrf();
|
||||
|
||||
$data = push_subscription_from_json((string)file_get_contents('php://input'));
|
||||
$payload = [
|
||||
'title' => (string)($data['title'] ?? 'Seoul Control Center'),
|
||||
'body' => (string)($data['body'] ?? 'Push notification test'),
|
||||
'url' => '/',
|
||||
'tag' => (string)($data['tag'] ?? 'control-test'),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
json_out([
|
||||
'ok' => true,
|
||||
'data' => send_push_payload($payload),
|
||||
]);
|
||||
@@ -34,9 +34,6 @@
|
||||
statusBatteryRemaining: $('#statusBatteryRemaining'),
|
||||
spikeLogList: $('#spikeLogList'),
|
||||
noticeBaseline: $('#noticeBaseline'),
|
||||
pushStatus: $('#pushStatus'),
|
||||
pushDeviceList: $('#pushDeviceList'),
|
||||
pushHealthcheckBtn: $('#pushHealthcheckBtn'),
|
||||
rebootBtn: $('#rebootBtn'),
|
||||
translateBtn: $('#translateBtn'),
|
||||
themeBtn: $('#themeBtn'),
|
||||
@@ -70,7 +67,6 @@
|
||||
wsFallbackTimer: null,
|
||||
wsReconnectTimer: null,
|
||||
wsReconnectDelay: 1000,
|
||||
pushDevicesLastRefresh: 0,
|
||||
rebootRequesting: false,
|
||||
lang: 'en',
|
||||
theme: 'dark',
|
||||
@@ -141,10 +137,6 @@
|
||||
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',
|
||||
@@ -179,34 +171,8 @@
|
||||
avg: 'AVG',
|
||||
min: 'MIN',
|
||||
minuteAgo: ({ minutes }) => `${minutes}m ago`,
|
||||
health_healthy: 'Healthy',
|
||||
health_watch: 'Watch',
|
||||
health_stale: 'Stale',
|
||||
health_failed: 'Failed',
|
||||
health_pending: 'Pending',
|
||||
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',
|
||||
@@ -225,16 +191,7 @@
|
||||
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',
|
||||
@@ -298,10 +255,6 @@
|
||||
systemNotice: '시스템 알림',
|
||||
noticeHistory: '알림 이력',
|
||||
noNoticeHistory: '시스템 알림 이력이 없습니다.',
|
||||
pushDevices: 'Push 기기',
|
||||
healthCheck: '상태 점검',
|
||||
pushStatusChecking: 'Push 상태 확인 중...',
|
||||
noPushDevices: 'Push 기기가 없습니다.',
|
||||
show: '보기',
|
||||
hide: '숨기기',
|
||||
translateButton: '번역',
|
||||
@@ -336,34 +289,8 @@
|
||||
avg: '평균',
|
||||
min: '최소',
|
||||
minuteAgo: ({ minutes }) => `${minutes}분 전`,
|
||||
health_healthy: '정상',
|
||||
health_watch: '주의',
|
||||
health_stale: '장기 미수신',
|
||||
health_failed: '실패',
|
||||
health_pending: '수신 대기',
|
||||
pushSummary: ({ total, healthy, watch, stale, failed, pending }) => `Push 기기: 총 ${total} / 정상 ${healthy} / 주의 ${watch} / 장기 미수신 ${stale} / 실패 ${failed} / 대기 ${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: '팬 갱신 완료',
|
||||
@@ -382,16 +309,7 @@
|
||||
rebootStarted: '재부팅 시작',
|
||||
rebootStartedBody: '재부팅 명령을 전송했습니다. 잠시 후 연결이 끊어질 수 있습니다.',
|
||||
rebootFailed: '재부팅 실패',
|
||||
pushDeviceNamePrompt: '등록할 기기 이름을 입력하세요. 한글 2글자 이상이어야 합니다.',
|
||||
pushRegisterTitle: 'Push 등록',
|
||||
register: '등록',
|
||||
phone: '휴대폰',
|
||||
pushDeviceNameInvalid: '기기 이름은 한글 2글자 이상이어야 합니다.',
|
||||
autoRepair: '자동복구',
|
||||
pushDeletePrompt: '현재 브라우저의 Push 구독을 삭제할까요?',
|
||||
pushDeleteTitle: 'Push 삭제',
|
||||
delete: '삭제',
|
||||
pushErrorTitle: 'Push 오류',
|
||||
wakeLockTitle: '화면 꺼짐 방지',
|
||||
wakeLockUnsupported: '현재 브라우저에서 WakeLock을 지원하지 않습니다.',
|
||||
wakeLockErrorTitle: 'WakeLock 오류',
|
||||
@@ -408,11 +326,6 @@
|
||||
}
|
||||
window.controlT = t;
|
||||
|
||||
function pushHealthText(status, fallback = '') {
|
||||
const key = `health_${status || 'pending'}`;
|
||||
return messages[state.lang]?.[key] ?? messages.en[key] ?? fallback ?? t('pendingReceive');
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
state.theme = theme === 'light' ? 'light' : 'dark';
|
||||
localStorage.setItem(storageKeys.theme, state.theme);
|
||||
@@ -1084,119 +997,6 @@
|
||||
: `<div class="spike-log-empty">${escapeHtml(t('noNoticeHistory'))}</div>`;
|
||||
}
|
||||
|
||||
function renderPushDevices(devices = [], summary = null) {
|
||||
if (!els.pushDeviceList) return;
|
||||
|
||||
if (summary && els.pushStatus) {
|
||||
const total = Number(summary.total || 0);
|
||||
const healthy = Number(summary.healthy || 0);
|
||||
const watch = Number(summary.watch || 0);
|
||||
const stale = Number(summary.stale || 0);
|
||||
const failed = Number(summary.failed || 0);
|
||||
const pending = Number(summary.pending || 0);
|
||||
els.pushStatus.textContent = t('pushSummary', { total, healthy, watch, stale, failed, pending });
|
||||
}
|
||||
|
||||
els.pushDeviceList.innerHTML = devices.length
|
||||
? devices.map(device => {
|
||||
const healthStatus = device.health_status || 'pending';
|
||||
const healthText = pushHealthText(healthStatus, device.health_text);
|
||||
|
||||
return `
|
||||
<div class="push-device-row ${escapeHtml(healthStatus)}">
|
||||
<div class="push-device-main">
|
||||
<strong>${escapeHtml(device.device_name || t('unnamed'))}</strong>
|
||||
<span class="push-device-badge ${escapeHtml(healthStatus)}">${escapeHtml(healthText)}</span>
|
||||
<span>${escapeHtml(t('hostLabel'))}: ${escapeHtml(device.host || 'unknown')}</span>
|
||||
<span>${escapeHtml(t('ipLabel'))}: ${escapeHtml(device.actor_ip || '-')}</span>
|
||||
<span>${escapeHtml(t('created'))}: ${escapeHtml(device.created_at || '-')}</span>
|
||||
<span>${escapeHtml(t('registeredRefresh'))}: ${escapeHtml(device.last_seen_at || '-')} (${escapeHtml(timeAgo(device.last_seen_seconds))})</span>
|
||||
<span>${escapeHtml(t('lastSendSuccess'))}: ${escapeHtml(device.last_send_success_at || '-')} (${escapeHtml(timeAgo(device.last_send_success_seconds))})</span>
|
||||
<span>${escapeHtml(t('lastReceived'))}: ${escapeHtml(device.last_received_at || '-')} (${escapeHtml(timeAgo(device.last_received_seconds))})</span>
|
||||
<span>${escapeHtml(t('lastShown'))}: ${escapeHtml(device.last_notification_at || '-')} (${escapeHtml(timeAgo(device.last_notification_seconds))})</span>
|
||||
<span>${escapeHtml(t('lastClick'))}: ${escapeHtml(device.last_click_at || '-')}</span>
|
||||
<span>${escapeHtml(t('failures'))}: ${Number(device.failure_count || 0).toLocaleString()}${device.last_failure_reason ? ` / ${escapeHtml(device.last_failure_reason)}` : ''}</span>
|
||||
<span>${escapeHtml(t('encoding'))}: ${escapeHtml(device.content_encoding || '-')}</span>
|
||||
<span>${escapeHtml(t('hash'))}: ${escapeHtml(device.hash || '-')}</span>
|
||||
<span>${escapeHtml(t('endpoint'))}: ${escapeHtml(device.endpoint || '-')}</span>
|
||||
<span>${escapeHtml(t('userAgent'))}: ${escapeHtml(device.user_agent || t('unknownDevice'))}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('')
|
||||
: `<div class="spike-log-empty">${escapeHtml(t('noPushDevices'))}</div>`;
|
||||
}
|
||||
|
||||
function renderPushStatus(detail = {}) {
|
||||
if (!els.pushStatus) return;
|
||||
|
||||
const supported = detail.supported === true;
|
||||
const permission = detail.permission || 'unknown';
|
||||
const hasBrowserSubscription = detail.browser_subscription === true;
|
||||
const hasServerSubscription = detail.server_subscription === true;
|
||||
const manualDisabled = detail.manual_disabled === true;
|
||||
const serverChecked = detail.server_checked === true;
|
||||
|
||||
let text = '';
|
||||
if (!supported) {
|
||||
text = t('statusUnsupported');
|
||||
} else if (permission === 'denied') {
|
||||
text = t('statusDenied');
|
||||
} else if (permission !== 'granted') {
|
||||
text = t('statusUngrant');
|
||||
} else if (manualDisabled) {
|
||||
text = t('statusManualOff');
|
||||
} else {
|
||||
const browserText = hasBrowserSubscription ? t('browserHas') : t('browserMissing');
|
||||
const serverText = serverChecked
|
||||
? (hasServerSubscription ? t('serverHas') : t('serverMissing'))
|
||||
: t('serverPending');
|
||||
text = `${t('pushStatus')}: ${browserText} / ${serverText}`;
|
||||
}
|
||||
|
||||
els.pushStatus.textContent = text;
|
||||
}
|
||||
|
||||
async function refreshPushDevices(force = false) {
|
||||
if (!force && Date.now() - state.pushDevicesLastRefresh < 30000) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.pushDevicesLastRefresh = Date.now();
|
||||
try {
|
||||
const data = await api('push_devices');
|
||||
renderPushDevices(data.devices || [], data.summary || null);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPushHealthcheck() {
|
||||
if (!els.pushHealthcheckBtn) return;
|
||||
|
||||
els.pushHealthcheckBtn.disabled = true;
|
||||
try {
|
||||
const data = await api('send_push_healthcheck', {});
|
||||
renderPushDevices(data.devices || [], data.summary || null);
|
||||
const result = data.result || {};
|
||||
notice(t('healthSent', { sent: Number(result.sent || 0), failed: Number(result.failed || 0) }), result.failed ? 'error' : 'success');
|
||||
} catch (e) {
|
||||
notice(e.message || t('healthFailed'), 'error');
|
||||
} finally {
|
||||
els.pushHealthcheckBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('pushdevices:refresh', () => {
|
||||
refreshPushDevices(true);
|
||||
});
|
||||
|
||||
window.addEventListener('pushstatus:update', event => {
|
||||
renderPushStatus(event.detail || {});
|
||||
});
|
||||
|
||||
els.pushHealthcheckBtn?.addEventListener('click', sendPushHealthcheck);
|
||||
|
||||
function renderFanCause(data) {
|
||||
const processes = data.processes || {};
|
||||
const baseline = data.fan_spike || {};
|
||||
@@ -1211,7 +1011,6 @@
|
||||
|
||||
renderProcessRows(els.processCpuTable, processes.cpu || [], 'cpu');
|
||||
renderProcessRows(els.processMemoryTable, processes.memory || [], 'memory');
|
||||
refreshPushDevices();
|
||||
}
|
||||
|
||||
function rollingStats(values, windowSize = 30) {
|
||||
@@ -1736,7 +1535,6 @@
|
||||
});
|
||||
els.translateBtn?.addEventListener('click', () => {
|
||||
applyLang(state.lang === 'ko' ? 'en' : 'ko');
|
||||
refreshPushDevices(true);
|
||||
refreshStatus();
|
||||
});
|
||||
els.themeBtn?.addEventListener('click', () => {
|
||||
|
||||
+1
-33
@@ -37,22 +37,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['login_password'])) {
|
||||
$loggedIn = signed_in();
|
||||
$csrf = csrf_token();
|
||||
|
||||
if ($loggedIn && array_key_exists('push', $_GET)) {
|
||||
$testPushBody = trim((string)$_GET['push'], " \t\n\r\0\x0B\"'");
|
||||
|
||||
if ($testPushBody !== '') {
|
||||
send_push_payload([
|
||||
'title' => '푸시 알림 테스트',
|
||||
'body' => mb_substr($testPushBody, 0, 500),
|
||||
'url' => '/',
|
||||
'tag' => 'control-test-push-' . time(),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
header('Location: /');
|
||||
exit;
|
||||
}
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
@@ -61,7 +45,6 @@ if ($loggedIn && array_key_exists('push', $_GET)) {
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="csrf-token" content="<?= e($csrf) ?>">
|
||||
<meta name="vapid-public-key" content="<?= e(vapid_public_key()) ?>">
|
||||
<title><?= e(APP_NAME) ?></title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
@@ -123,7 +106,7 @@ html[data-theme="light"] .btn.secondary{background:#d9e2ef;color:#172033}html[da
|
||||
.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: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:var(--text);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 var(--notice-border);background:var(--notice-bg);border-radius:12px}.spike-log-item.latest{border:1px solid var(--notice-border-strong);background:var(--notice-bg-strong);box-shadow:0 0 0 1px var(--notice-border) inset;}.spike-log-item strong{font-size:13px;font-weight:500;color:var(--notice-title)}.spike-log-item span{font-size:12px;color:var(--notice-copy);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:var(--code-bg);color:var(--mono-text);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: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}
|
||||
.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}
|
||||
.notice{position:fixed;right:20px;bottom:20px;min-width:220px;max-width:420px;padding:14px 16px;border-radius:14px;font-weight:500;background:var(--table-head);color:var(--text);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: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))}}
|
||||
@@ -153,7 +136,6 @@ html[data-theme="light"] .btn.secondary{background:#d9e2ef;color:#172033}html[da
|
||||
<p><span data-i18n="updated">Updated</span>: <span id="updatedAt">loading...</span></p>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<button id="pushEnableBtn" class="btn secondary" type="button">Push</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>
|
||||
@@ -289,19 +271,6 @@ html[data-theme="light"] .btn.secondary{background:#d9e2ef;color:#172033}html[da
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="spike-log-box">
|
||||
<div class="push-device-head">
|
||||
<h3 data-i18n="pushDevices">Push Devices</h3>
|
||||
<div class="push-device-actions">
|
||||
<button class="btn secondary" id="pushHealthcheckBtn" type="button" data-i18n="healthCheck">Health Check</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pushStatus" class="spike-log-empty" data-i18n="pushStatusChecking">Push status checking...</div>
|
||||
<div id="pushDeviceList" class="push-device-list">
|
||||
<div class="spike-log-empty" data-i18n="noPushDevices">No push devices.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="spike-log-box">
|
||||
<div class="dmesg-head">
|
||||
<h3>dmesg</h3>
|
||||
@@ -326,7 +295,6 @@ html[data-theme="light"] .btn.secondary{background:#d9e2ef;color:#172033}html[da
|
||||
</div>
|
||||
<script src="/assets/app.js?v=20260618_throttling2"></script>
|
||||
<script src="/assets/wakelock.js?v=20260607_prefs1"></script>
|
||||
<script src="/push_subscribe.js?v=20260607_prefs1"></script>
|
||||
<?php endif; ?>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const button = document.querySelector('#pushEnableBtn');
|
||||
const publicKey = document.querySelector('meta[name="vapid-public-key"]')?.content || '';
|
||||
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
const pushDeviceNameStorageKey = 'controlPushDeviceName';
|
||||
const pushDisabledStorageKey = 'controlPushDisabled';
|
||||
let pushAutoRepairRunning = false;
|
||||
|
||||
function tr(key, fallback) {
|
||||
return typeof window.controlT === 'function' ? window.controlT(key) : fallback;
|
||||
}
|
||||
|
||||
function setButton(text, disabled = false, active = false) {
|
||||
if (!button) return;
|
||||
button.textContent = text || 'Push';
|
||||
button.disabled = disabled;
|
||||
button.dataset.active = active ? '1' : '0';
|
||||
}
|
||||
|
||||
function publishPushStatus(detail) {
|
||||
window.dispatchEvent(new CustomEvent('pushstatus:update', {
|
||||
detail: Object.assign({
|
||||
supported: ('serviceWorker' in navigator) && ('PushManager' in window) && ('Notification' in window),
|
||||
permission: ('Notification' in window) ? Notification.permission : 'unsupported',
|
||||
browser_subscription: false,
|
||||
server_subscription: false,
|
||||
server_checked: false,
|
||||
manual_disabled: localStorage.getItem(pushDisabledStorageKey) === '1',
|
||||
}, detail || {}),
|
||||
}));
|
||||
}
|
||||
|
||||
function hangulCount(value) {
|
||||
return (String(value || '').match(/[가-힣ㄱ-ㅎㅏ-ㅣ]/g) || []).length;
|
||||
}
|
||||
|
||||
async function deviceNameFromUser() {
|
||||
const promptFn = window.customPrompt || (() => Promise.resolve(null));
|
||||
const name = await promptFn(tr('pushDeviceNamePrompt', 'Enter a device name. Korean names require at least 2 Hangul characters.'), {
|
||||
title: tr('pushRegisterTitle', 'Push Register'),
|
||||
okText: tr('register', 'Register'),
|
||||
placeholder: tr('phone', 'Phone'),
|
||||
autocomplete: 'off',
|
||||
});
|
||||
|
||||
if (name === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = String(name || '').trim();
|
||||
|
||||
if (hangulCount(trimmed) < 2) {
|
||||
throw new Error(tr('pushDeviceNameInvalid', 'Device name must contain at least 2 Hangul characters.'));
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function rememberDeviceName(deviceName) {
|
||||
const trimmed = String(deviceName || '').trim();
|
||||
if (hangulCount(trimmed) >= 2) {
|
||||
localStorage.setItem(pushDeviceNameStorageKey, trimmed);
|
||||
localStorage.removeItem(pushDisabledStorageKey);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function savedDeviceName() {
|
||||
const stored = String(localStorage.getItem(pushDeviceNameStorageKey) || '').trim();
|
||||
return hangulCount(stored) >= 2 ? stored : tr('autoRepair', 'Auto Repair');
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(value) {
|
||||
const padding = '='.repeat((4 - value.length % 4) % 4);
|
||||
const base64 = (value + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const raw = atob(base64);
|
||||
const output = new Uint8Array(raw.length);
|
||||
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
output[i] = raw.charCodeAt(i);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
async function postForm(action, body) {
|
||||
const fd = new URLSearchParams();
|
||||
Object.entries(body).forEach(([key, value]) => fd.append(key, String(value)));
|
||||
fd.append('action', action);
|
||||
fd.append('csrf', csrf);
|
||||
|
||||
const res = await fetch('/api.php', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': csrf,
|
||||
},
|
||||
body: fd.toString(),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (!res.ok || !json.ok) {
|
||||
throw new Error(json?.message || json?.error || 'push_request_failed');
|
||||
}
|
||||
|
||||
return json.data;
|
||||
}
|
||||
|
||||
async function saveSubscription(subscription, deviceName) {
|
||||
const payload = subscription.toJSON();
|
||||
payload.device_name = rememberDeviceName(deviceName);
|
||||
|
||||
const res = await fetch('/api/save_subscription.php', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrf,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (!res.ok || !json.ok) {
|
||||
throw new Error(json?.message || json?.error || 'subscription_save_failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function registration() {
|
||||
const reg = await navigator.serviceWorker.register('/sw.js', {
|
||||
scope: '/',
|
||||
updateViaCache: 'none',
|
||||
});
|
||||
await reg.update().catch(() => {});
|
||||
return reg;
|
||||
}
|
||||
|
||||
async function currentSubscription() {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existing = await navigator.serviceWorker.getRegistration('/');
|
||||
if (existing) {
|
||||
await existing.update().catch(() => {});
|
||||
}
|
||||
return existing ? existing.pushManager.getSubscription() : null;
|
||||
}
|
||||
|
||||
async function subscribePush(reg) {
|
||||
return reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||
});
|
||||
}
|
||||
|
||||
async function pushServerStatus(subscription) {
|
||||
const endpoint = subscription ? subscription.endpoint : '';
|
||||
const res = await fetch('/api.php?action=push_status&endpoint=' + encodeURIComponent(endpoint), {
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'X-CSRF-Token': csrf,
|
||||
},
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (!res.ok || !json.ok) {
|
||||
throw new Error(json?.message || json?.error || 'push_status_failed');
|
||||
}
|
||||
|
||||
return json.data || {};
|
||||
}
|
||||
|
||||
async function repairSubscriptionIfNeeded() {
|
||||
if (pushAutoRepairRunning) return;
|
||||
if (!publicKey) return;
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) return;
|
||||
if (Notification.permission !== 'granted') return;
|
||||
if (localStorage.getItem(pushDisabledStorageKey) === '1') return;
|
||||
|
||||
pushAutoRepairRunning = true;
|
||||
|
||||
try {
|
||||
const reg = await registration();
|
||||
let subscription = await reg.pushManager.getSubscription();
|
||||
|
||||
if (!subscription) {
|
||||
subscription = await subscribePush(reg);
|
||||
await saveSubscription(subscription, savedDeviceName());
|
||||
await refreshButton();
|
||||
window.dispatchEvent(new CustomEvent('pushdevices:refresh'));
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await pushServerStatus(subscription);
|
||||
publishPushStatus({
|
||||
browser_subscription: true,
|
||||
server_subscription: status.subscribed === true,
|
||||
server_checked: true,
|
||||
});
|
||||
if (status.device_name) {
|
||||
rememberDeviceName(status.device_name);
|
||||
}
|
||||
|
||||
if (status.subscribed) {
|
||||
await refreshButton();
|
||||
return;
|
||||
}
|
||||
|
||||
await saveSubscription(subscription, savedDeviceName());
|
||||
publishPushStatus({
|
||||
browser_subscription: true,
|
||||
server_subscription: true,
|
||||
server_checked: true,
|
||||
});
|
||||
await refreshButton();
|
||||
window.dispatchEvent(new CustomEvent('pushdevices:refresh'));
|
||||
} catch (error) {
|
||||
console.warn('push auto repair failed', error);
|
||||
} finally {
|
||||
pushAutoRepairRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshButton() {
|
||||
if (!button) return;
|
||||
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) {
|
||||
setButton('Push', true, false);
|
||||
publishPushStatus({ supported: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = await currentSubscription();
|
||||
if (subscription) {
|
||||
setButton('Push', false, true);
|
||||
publishPushStatus({
|
||||
browser_subscription: true,
|
||||
});
|
||||
} else if (Notification.permission === 'denied') {
|
||||
setButton('Push', true, false);
|
||||
publishPushStatus({
|
||||
browser_subscription: false,
|
||||
});
|
||||
} else {
|
||||
setButton('Push', false, false);
|
||||
publishPushStatus({
|
||||
browser_subscription: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribe() {
|
||||
if (!publicKey) {
|
||||
setButton('Push', true, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceName = await deviceNameFromUser();
|
||||
if (deviceName === null) {
|
||||
await refreshButton();
|
||||
return;
|
||||
}
|
||||
|
||||
setButton('Push', true, false);
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
setButton('Push', false, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const reg = await registration();
|
||||
let subscription = await reg.pushManager.getSubscription();
|
||||
if (!subscription) {
|
||||
subscription = await subscribePush(reg);
|
||||
}
|
||||
|
||||
await saveSubscription(subscription, deviceName);
|
||||
publishPushStatus({
|
||||
browser_subscription: true,
|
||||
server_subscription: true,
|
||||
server_checked: true,
|
||||
manual_disabled: false,
|
||||
});
|
||||
await refreshButton();
|
||||
window.dispatchEvent(new CustomEvent('pushdevices:refresh'));
|
||||
}
|
||||
|
||||
async function unsubscribe() {
|
||||
const confirmFn = window.customConfirm || (() => Promise.resolve(false));
|
||||
if (!await confirmFn(tr('pushDeletePrompt', 'Delete this browser Push subscription?'), {
|
||||
title: tr('pushDeleteTitle', 'Delete Push'),
|
||||
okText: tr('delete', 'Delete'),
|
||||
danger: true,
|
||||
})) {
|
||||
await refreshButton();
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = await currentSubscription();
|
||||
if (!subscription) {
|
||||
await refreshButton();
|
||||
return;
|
||||
}
|
||||
|
||||
setButton('Push', true, true);
|
||||
localStorage.setItem(pushDisabledStorageKey, '1');
|
||||
await subscription.unsubscribe();
|
||||
await postForm('delete_push_endpoint', {
|
||||
endpoint: subscription.endpoint,
|
||||
});
|
||||
publishPushStatus({
|
||||
browser_subscription: false,
|
||||
server_subscription: false,
|
||||
server_checked: true,
|
||||
manual_disabled: true,
|
||||
});
|
||||
await refreshButton();
|
||||
window.dispatchEvent(new CustomEvent('pushdevices:refresh'));
|
||||
}
|
||||
|
||||
if (!button) return;
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
const active = button.dataset.active === '1';
|
||||
const job = active ? unsubscribe() : subscribe();
|
||||
job.catch(error => {
|
||||
const alertFn = window.customAlert || (() => Promise.resolve());
|
||||
alertFn(error.message || 'Push failed', {
|
||||
title: tr('pushErrorTitle', 'Push Error'),
|
||||
danger: true,
|
||||
});
|
||||
refreshButton().catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
refreshButton().catch(() => setButton('Push', false, false));
|
||||
repairSubscriptionIfNeeded();
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) {
|
||||
repairSubscriptionIfNeeded();
|
||||
}
|
||||
});
|
||||
setInterval(repairSubscriptionIfNeeded, 5 * 60 * 1000);
|
||||
})();
|
||||
-123
@@ -1,123 +0,0 @@
|
||||
async function currentEndpoint() {
|
||||
const subscription = await self.registration.pushManager.getSubscription();
|
||||
return subscription ? subscription.endpoint : '';
|
||||
}
|
||||
|
||||
async function windowClientCount() {
|
||||
const allClients = await clients.matchAll({
|
||||
type: 'window',
|
||||
includeUncontrolled: true,
|
||||
});
|
||||
return allClients.length;
|
||||
}
|
||||
|
||||
function logPushEvent(eventName, payload = {}, extra = {}) {
|
||||
return Promise.all([
|
||||
currentEndpoint().catch(() => ''),
|
||||
windowClientCount().catch(() => 0),
|
||||
]).then(([endpoint, clientCount]) => fetch('/api/push_event.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
body: JSON.stringify({
|
||||
event: eventName,
|
||||
endpoint,
|
||||
push_id: payload.push_id || '',
|
||||
tag: payload.tag || '',
|
||||
client_count: clientCount,
|
||||
meta: extra,
|
||||
}),
|
||||
})).catch(() => {});
|
||||
}
|
||||
|
||||
self.addEventListener('push', event => {
|
||||
let payload = {};
|
||||
|
||||
try {
|
||||
payload = event.data ? event.data.json() : {};
|
||||
} catch (e) {
|
||||
payload = {
|
||||
body: event.data ? event.data.text() : '',
|
||||
};
|
||||
}
|
||||
|
||||
const title = payload.title || 'Seoul Control Center';
|
||||
const vibrate = Array.isArray(payload.vibrate)
|
||||
? payload.vibrate.map(value => Number(value)).filter(value => Number.isFinite(value) && value >= 0)
|
||||
: undefined;
|
||||
const options = {
|
||||
body: payload.body || 'System notice detected',
|
||||
icon: '/assets/icon-192.png',
|
||||
badge: '/assets/icon-192.png',
|
||||
tag: payload.tag || 'control-push',
|
||||
renotify: payload.renotify === true,
|
||||
requireInteraction: payload.require_interaction === true || payload.requireInteraction === true,
|
||||
silent: payload.silent === true,
|
||||
vibrate,
|
||||
data: {
|
||||
url: payload.url || '/',
|
||||
push_id: payload.push_id || '',
|
||||
tag: payload.tag || 'control-push',
|
||||
},
|
||||
};
|
||||
|
||||
event.waitUntil((async () => {
|
||||
await logPushEvent('push_received', payload);
|
||||
try {
|
||||
await self.registration.showNotification(title, options);
|
||||
await logPushEvent('notification_shown', payload);
|
||||
} catch (error) {
|
||||
await logPushEvent('notification_show_failed', payload, {
|
||||
reason: error && error.message ? error.message : 'show_failed',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', event => {
|
||||
event.notification.close();
|
||||
|
||||
const url = event.notification.data?.url || '/';
|
||||
const payload = {
|
||||
push_id: event.notification.data?.push_id || '',
|
||||
tag: event.notification.data?.tag || event.notification.tag || '',
|
||||
};
|
||||
|
||||
event.waitUntil((async () => {
|
||||
await logPushEvent('notification_click', payload, {
|
||||
action: event.action || '',
|
||||
});
|
||||
|
||||
const allClients = await clients.matchAll({
|
||||
type: 'window',
|
||||
includeUncontrolled: true,
|
||||
});
|
||||
|
||||
for (const client of allClients) {
|
||||
if ('focus' in client) {
|
||||
await client.focus();
|
||||
if ('navigate' in client) {
|
||||
return client.navigate(url);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(url);
|
||||
}
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclose', event => {
|
||||
const payload = {
|
||||
push_id: event.notification.data?.push_id || '',
|
||||
tag: event.notification.data?.tag || event.notification.tag || '',
|
||||
};
|
||||
|
||||
event.waitUntil(logPushEvent('notification_close', payload));
|
||||
});
|
||||
Reference in New Issue
Block a user