Strengthen push device health checks

This commit is contained in:
seo
2026-06-07 06:43:17 +09:00
parent 79208784e2
commit 39aa262469
5 changed files with 295 additions and 9 deletions
+201 -2
View File
@@ -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("