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 @@ : '