diff --git a/bin/push_healthcheck.php b/bin/push_healthcheck.php new file mode 100644 index 0000000..f1d7c66 --- /dev/null +++ b/bin/push_healthcheck.php @@ -0,0 +1,26 @@ + true, + 'result' => $result, + 'summary' => push_health_summary(), +], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . PHP_EOL; diff --git a/config/config.php b/config/config.php index 8925726..2ac65d6 100644 --- a/config/config.php +++ b/config/config.php @@ -262,9 +262,18 @@ function bootstrap_db(): void device_name VARCHAR(64) NULL, user_agent VARCHAR(255) NULL, actor_ip VARCHAR(64) NULL, + last_send_success_at DATETIME NULL, + last_send_failed_at DATETIME NULL, + last_received_at DATETIME NULL, + last_notification_at DATETIME NULL, + last_click_at DATETIME NULL, + failure_count INT UNSIGNED NOT NULL DEFAULT 0, + last_failure_reason VARCHAR(255) NULL, UNIQUE KEY uniq_endpoint_hash (endpoint_hash), - INDEX idx_last_seen_at (last_seen_at) + INDEX idx_last_seen_at (last_seen_at), + INDEX idx_last_received_at (last_received_at), + INDEX idx_last_send_success_at (last_send_success_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci @@ -354,6 +363,15 @@ function bootstrap_db(): void "ALTER TABLE system_notice_state ADD COLUMN active_rpm_delta DECIMAL(12,2) NULL AFTER active_temp_delta", "ALTER TABLE system_notice_state ADD COLUMN process_signature VARCHAR(255) NULL AFTER active_rpm_delta", "ALTER TABLE push_subscriptions ADD COLUMN device_name VARCHAR(64) NULL AFTER content_encoding", + "ALTER TABLE push_subscriptions ADD COLUMN last_send_success_at DATETIME NULL AFTER actor_ip", + "ALTER TABLE push_subscriptions ADD COLUMN last_send_failed_at DATETIME NULL AFTER last_send_success_at", + "ALTER TABLE push_subscriptions ADD COLUMN last_received_at DATETIME NULL AFTER last_send_failed_at", + "ALTER TABLE push_subscriptions ADD COLUMN last_notification_at DATETIME NULL AFTER last_received_at", + "ALTER TABLE push_subscriptions ADD COLUMN last_click_at DATETIME NULL AFTER last_notification_at", + "ALTER TABLE push_subscriptions ADD COLUMN failure_count INT UNSIGNED NOT NULL DEFAULT 0 AFTER last_click_at", + "ALTER TABLE push_subscriptions ADD COLUMN last_failure_reason VARCHAR(255) NULL AFTER failure_count", + "ALTER TABLE push_subscriptions ADD INDEX idx_last_received_at (last_received_at)", + "ALTER TABLE push_subscriptions ADD INDEX idx_last_send_success_at (last_send_success_at)", "ALTER TABLE sensor_logs DROP COLUMN disk_total_gb", "ALTER TABLE sensor_logs DROP COLUMN disk_used_gb", "ALTER TABLE sensor_logs DROP COLUMN disk_free_gb", @@ -499,6 +517,76 @@ function push_log_event(string $event, array $context = []): void ':message' => isset($context['message']) ? mb_substr((string)$context['message'], 0, 255) : null, ':meta' => json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ]); + + update_push_subscription_health($event, $endpointHash, $context); + } catch (Throwable) { + } +} + +function update_push_subscription_health(string $event, string $endpointHash, array $context = []): void +{ + if (!preg_match('/^[a-f0-9]{64}$/', $endpointHash)) { + return; + } + + $reason = mb_substr((string)($context['reason'] ?? ''), 0, 255); + + try { + if (in_array($event, ['register', 'register_update'], true)) { + $stmt = db()->prepare(" + UPDATE push_subscriptions + SET last_seen_at = CURRENT_TIMESTAMP, + failure_count = 0, + last_failure_reason = NULL + WHERE endpoint_hash = :endpoint_hash + "); + $stmt->execute([':endpoint_hash' => $endpointHash]); + return; + } + + if ($event === 'send_success') { + $stmt = db()->prepare(" + UPDATE push_subscriptions + SET last_send_success_at = CURRENT_TIMESTAMP, + failure_count = 0, + last_failure_reason = NULL + WHERE endpoint_hash = :endpoint_hash + "); + $stmt->execute([':endpoint_hash' => $endpointHash]); + return; + } + + if ($event === 'send_failed') { + $stmt = db()->prepare(" + UPDATE push_subscriptions + SET last_send_failed_at = CURRENT_TIMESTAMP, + failure_count = failure_count + 1, + last_failure_reason = :reason + WHERE endpoint_hash = :endpoint_hash + "); + $stmt->execute([ + ':endpoint_hash' => $endpointHash, + ':reason' => $reason !== '' ? $reason : 'send_failed', + ]); + return; + } + + $columnByEvent = [ + 'push_received' => 'last_received_at', + 'notification_shown' => 'last_notification_at', + 'notification_click' => 'last_click_at', + ]; + if (isset($columnByEvent[$event])) { + $column = $columnByEvent[$event]; + $stmt = db()->prepare(" + UPDATE push_subscriptions + SET {$column} = CURRENT_TIMESTAMP, + failure_count = 0, + last_failure_reason = NULL + WHERE endpoint_hash = :endpoint_hash + "); + $stmt->execute([':endpoint_hash' => $endpointHash]); + } } catch (Throwable) { } } @@ -597,7 +685,18 @@ function push_device_rows(): array user_agent, actor_ip, created_at, - last_seen_at + last_seen_at, + last_send_success_at, + last_send_failed_at, + last_received_at, + last_notification_at, + last_click_at, + failure_count, + last_failure_reason, + TIMESTAMPDIFF(SECOND, last_seen_at, NOW()) AS last_seen_seconds, + TIMESTAMPDIFF(SECOND, last_send_success_at, NOW()) AS last_send_success_seconds, + TIMESTAMPDIFF(SECOND, last_received_at, NOW()) AS last_received_seconds, + TIMESTAMPDIFF(SECOND, last_notification_at, NOW()) AS last_notification_seconds FROM push_subscriptions ORDER BY last_seen_at DESC LIMIT 200 @@ -606,6 +705,26 @@ function push_device_rows(): array $rows = []; foreach ($stmt->fetchAll() as $row) { $endpoint = (string)($row['endpoint'] ?? ''); + $failureCount = (int)($row['failure_count'] ?? 0); + $lastReceivedSeconds = isset($row['last_received_seconds']) ? (int)$row['last_received_seconds'] : null; + $lastSendSuccessSeconds = isset($row['last_send_success_seconds']) ? (int)$row['last_send_success_seconds'] : null; + $status = 'pending'; + $statusText = '수신 대기'; + + if ($failureCount >= 3) { + $status = 'failed'; + $statusText = '발송 실패 누적'; + } elseif ($lastReceivedSeconds !== null && $lastReceivedSeconds <= 86400) { + $status = 'healthy'; + $statusText = '정상'; + } elseif ($lastReceivedSeconds !== null && $lastReceivedSeconds <= 604800) { + $status = 'watch'; + $statusText = '수신 지연'; + } elseif ($lastSendSuccessSeconds !== null) { + $status = 'stale'; + $statusText = '장기 미수신'; + } + $rows[] = [ 'hash' => (string)($row['endpoint_hash'] ?? hash('sha256', $endpoint)), 'endpoint' => $endpoint, @@ -616,12 +735,48 @@ function push_device_rows(): array 'actor_ip' => (string)($row['actor_ip'] ?? ''), 'created_at' => (string)($row['created_at'] ?? ''), 'last_seen_at' => (string)($row['last_seen_at'] ?? ''), + 'last_send_success_at' => (string)($row['last_send_success_at'] ?? ''), + 'last_send_failed_at' => (string)($row['last_send_failed_at'] ?? ''), + 'last_received_at' => (string)($row['last_received_at'] ?? ''), + 'last_notification_at' => (string)($row['last_notification_at'] ?? ''), + 'last_click_at' => (string)($row['last_click_at'] ?? ''), + 'last_seen_seconds' => $row['last_seen_seconds'] !== null ? (int)$row['last_seen_seconds'] : null, + 'last_send_success_seconds' => $lastSendSuccessSeconds, + 'last_received_seconds' => $lastReceivedSeconds, + 'last_notification_seconds' => $row['last_notification_seconds'] !== null ? (int)$row['last_notification_seconds'] : null, + 'failure_count' => $failureCount, + 'last_failure_reason' => (string)($row['last_failure_reason'] ?? ''), + 'health_status' => $status, + 'health_text' => $statusText, ]; } return $rows; } +function push_health_summary(): array +{ + $devices = push_device_rows(); + $summary = [ + 'total' => count($devices), + 'healthy' => 0, + 'watch' => 0, + 'stale' => 0, + 'failed' => 0, + 'pending' => 0, + ]; + + foreach ($devices as $device) { + $status = (string)($device['health_status'] ?? 'pending'); + if (!array_key_exists($status, $summary)) { + $status = 'pending'; + } + $summary[$status]++; + } + + return $summary; +} + function push_subscription_status(string $endpoint): array { $endpointHash = $endpoint !== '' ? hash('sha256', $endpoint) : ''; @@ -808,6 +963,50 @@ function send_push_payload(array $payload): array ]; } +function latest_push_send_epoch_by_tag(string $tagPrefix): int +{ + $stmt = db()->prepare(" + SELECT UNIX_TIMESTAMP(MAX(created_at)) + FROM push_event_logs + WHERE event = 'send_success' + AND meta LIKE :tag + "); + $stmt->execute([ + ':tag' => '%\"tag\":\"' . str_replace(['%', '_'], ['\\%', '\\_'], $tagPrefix) . '%', + ]); + + return (int)($stmt->fetchColumn() ?: 0); +} + +function send_push_healthcheck_if_due(int $minHours = 24, bool $force = false): array +{ + $minHours = max(1, min(168, $minHours)); + $latest = latest_push_send_epoch_by_tag('control-healthcheck-'); + + if (!$force && $latest > 0 && time() - $latest < $minHours * 3600) { + return [ + 'sent' => 0, + 'failed' => 0, + 'error' => null, + 'skipped' => true, + 'message' => 'healthcheck_cooldown', + 'next_after_seconds' => ($minHours * 3600) - (time() - $latest), + ]; + } + + return send_push_payload([ + 'title' => 'Seoul Control Center', + 'body' => '푸시 기기 상태 확인 알림입니다.', + 'url' => '/', + 'tag' => 'control-healthcheck-' . date('YmdHi'), + 'created_at' => date('Y-m-d H:i:s'), + 'silent' => true, + 'data' => [ + 'kind' => 'push_healthcheck', + ], + ]); +} + function reset_battery_low_push_state(): void { $stmt = db()->prepare(" diff --git a/public/api.php b/public/api.php index b5bb232..8fc6831 100644 --- a/public/api.php +++ b/public/api.php @@ -1673,6 +1673,20 @@ function control_api_dispatch(): void '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(), ], ]); } @@ -1691,6 +1705,7 @@ function control_api_dispatch(): void 'ok' => true, 'data' => [ 'devices' => push_device_rows(), + 'summary' => push_health_summary(), ], ]); } @@ -1702,6 +1717,7 @@ function control_api_dispatch(): void 'ok' => true, 'data' => [ 'devices' => push_device_rows(), + 'summary' => push_health_summary(), ], ]); } diff --git a/public/assets/app.js b/public/assets/app.js index dc29bc4..d880a32 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -28,6 +28,7 @@ noticeBaseline: $('#noticeBaseline'), pushStatus: $('#pushStatus'), pushDeviceList: $('#pushDeviceList'), + pushHealthcheckBtn: $('#pushHealthcheckBtn'), processCpuTable: $('#processCpuTable'), processMemoryTable: $('#processMemoryTable'), dmesgToggle: $('#dmesgToggle'), @@ -324,6 +325,11 @@ return formatDhms(parseWifiDurationSeconds(value)) || value; } + function timeAgo(seconds) { + const formatted = formatDhms(seconds); + return formatted ? `${formatted} ago` : '-'; + } + function renderWifi(data) { const rows = data.wifi?.clients || []; if (!els.wifiTable) return; @@ -522,18 +528,34 @@ : '
No system notice history.
'; } - function renderPushDevices(devices = []) { + 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 = `Push devices: ${total} total / ${healthy} healthy / ${watch} watch / ${stale} stale / ${failed} failed / ${pending} pending`; + } + els.pushDeviceList.innerHTML = devices.length ? devices.map(device => ` -
+
${escapeHtml(device.device_name || '이름 없음')} + ${escapeHtml(device.health_text || '수신 대기')} Host: ${escapeHtml(device.host || 'unknown')} IP: ${escapeHtml(device.actor_ip || '-')} Created: ${escapeHtml(device.created_at || '-')} - Last seen: ${escapeHtml(device.last_seen_at || '-')} + Registered refresh: ${escapeHtml(device.last_seen_at || '-')} (${escapeHtml(timeAgo(device.last_seen_seconds))}) + Last send success: ${escapeHtml(device.last_send_success_at || '-')} (${escapeHtml(timeAgo(device.last_send_success_seconds))}) + Last received: ${escapeHtml(device.last_received_at || '-')} (${escapeHtml(timeAgo(device.last_received_seconds))}) + Last shown: ${escapeHtml(device.last_notification_at || '-')} (${escapeHtml(timeAgo(device.last_notification_seconds))}) + Last click: ${escapeHtml(device.last_click_at || '-')} + Failures: ${Number(device.failure_count || 0).toLocaleString()}${device.last_failure_reason ? ` / ${escapeHtml(device.last_failure_reason)}` : ''} Encoding: ${escapeHtml(device.content_encoding || '-')} Hash: ${escapeHtml(device.hash || '-')} Endpoint: ${escapeHtml(device.endpoint || '-')} @@ -582,12 +604,28 @@ state.pushDevicesLastRefresh = Date.now(); try { const data = await api('push_devices'); - renderPushDevices(data.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(`Health check sent ${Number(result.sent || 0)} / failed ${Number(result.failed || 0)}`, result.failed ? 'error' : 'success'); + } catch (e) { + notice(e.message || 'Health check failed', 'error'); + } finally { + els.pushHealthcheckBtn.disabled = false; + } + } + window.addEventListener('pushdevices:refresh', () => { refreshPushDevices(true); }); @@ -596,6 +634,8 @@ renderPushStatus(event.detail || {}); }); + els.pushHealthcheckBtn?.addEventListener('click', sendPushHealthcheck); + function renderFanCause(data) { const processes = data.processes || {}; const baseline = data.fan_spike || {}; diff --git a/public/index.php b/public/index.php index eba9653..b21db07 100644 --- a/public/index.php +++ b/public/index.php @@ -105,7 +105,7 @@ a{color:inherit;text-decoration:none} .table-wrap{max-width:100%;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;border-radius:16px;border:1px solid var(--line)}.wifi-table{width:100%;min-width:1080px;table-layout:fixed;border-collapse:collapse}.wifi-table th,.wifi-table td{padding:11px 13px;border-bottom:1px solid rgba(255,255,255,.06);text-align:left;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.wifi-table th{position:sticky;top:0;background:#1d2330;z-index:1;font-weight:500}.wifi-table tr:hover{background:rgba(255,255,255,.03)}.wifi-table .col-band{width:72px}.wifi-table .col-host{width:240px}.wifi-table .col-ip{width:132px}.wifi-table .col-mac{width:170px}.wifi-table .col-signal{width:100px}.wifi-table .col-rate{width:120px}.wifi-table .col-time{width:130px} .chart-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}.chart-box{height:280px;background:#11151d;border:1px solid rgba(84,101,128,.58);border-radius:16px;padding:12px 12px 18px}.chart-box h3{margin:0 0 8px;color:#c6d3e6;font-size:13px}.chart-canvas{position:relative;height:calc(100% - 27px);min-height:0}.chart-canvas canvas{width:100%!important;height:100%!important} .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-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-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} +.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}.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)} @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}} @@ -260,7 +260,12 @@ a{color:inherit;text-decoration:none}
-

Push Devices

+
+

Push Devices

+
+ +
+
Push status checking...
No push devices.
@@ -278,7 +283,7 @@ a{color:inherit;text-decoration:none}
- +