Use Home Assistant notifications

This commit is contained in:
seo
2026-06-21 07:30:39 +09:00
parent 81a24530d6
commit 49394a8ee5
15 changed files with 234 additions and 2371 deletions
+22 -78
View File
@@ -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)));
-44
View File
@@ -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]);
-31
View File
@@ -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,
]);
-38
View File
@@ -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),
]);
-202
View File
@@ -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
View File
@@ -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>
-351
View File
@@ -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
View File
@@ -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));
});