Strengthen push device health checks
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../config/config.php';
|
||||||
|
|
||||||
|
$minHours = 24;
|
||||||
|
$force = false;
|
||||||
|
|
||||||
|
foreach (array_slice($argv, 1) as $arg) {
|
||||||
|
if ($arg === '--force') {
|
||||||
|
$force = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($arg, '--min-hours=')) {
|
||||||
|
$minHours = (int)substr($arg, 12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = send_push_healthcheck_if_due($minHours, $force);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => true,
|
||||||
|
'result' => $result,
|
||||||
|
'summary' => push_health_summary(),
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . PHP_EOL;
|
||||||
+201
-2
@@ -262,9 +262,18 @@ function bootstrap_db(): void
|
|||||||
device_name VARCHAR(64) NULL,
|
device_name VARCHAR(64) NULL,
|
||||||
user_agent VARCHAR(255) NULL,
|
user_agent VARCHAR(255) NULL,
|
||||||
actor_ip VARCHAR(64) 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),
|
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
|
) ENGINE=InnoDB
|
||||||
DEFAULT CHARSET=utf8mb4
|
DEFAULT CHARSET=utf8mb4
|
||||||
COLLATE=utf8mb4_unicode_ci
|
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 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 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 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_total_gb",
|
||||||
"ALTER TABLE sensor_logs DROP COLUMN disk_used_gb",
|
"ALTER TABLE sensor_logs DROP COLUMN disk_used_gb",
|
||||||
"ALTER TABLE sensor_logs DROP COLUMN disk_free_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,
|
':message' => isset($context['message']) ? mb_substr((string)$context['message'], 0, 255) : null,
|
||||||
':meta' => json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
':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) {
|
} catch (Throwable) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -597,7 +685,18 @@ function push_device_rows(): array
|
|||||||
user_agent,
|
user_agent,
|
||||||
actor_ip,
|
actor_ip,
|
||||||
created_at,
|
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
|
FROM push_subscriptions
|
||||||
ORDER BY last_seen_at DESC
|
ORDER BY last_seen_at DESC
|
||||||
LIMIT 200
|
LIMIT 200
|
||||||
@@ -606,6 +705,26 @@ function push_device_rows(): array
|
|||||||
$rows = [];
|
$rows = [];
|
||||||
foreach ($stmt->fetchAll() as $row) {
|
foreach ($stmt->fetchAll() as $row) {
|
||||||
$endpoint = (string)($row['endpoint'] ?? '');
|
$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[] = [
|
$rows[] = [
|
||||||
'hash' => (string)($row['endpoint_hash'] ?? hash('sha256', $endpoint)),
|
'hash' => (string)($row['endpoint_hash'] ?? hash('sha256', $endpoint)),
|
||||||
'endpoint' => $endpoint,
|
'endpoint' => $endpoint,
|
||||||
@@ -616,12 +735,48 @@ function push_device_rows(): array
|
|||||||
'actor_ip' => (string)($row['actor_ip'] ?? ''),
|
'actor_ip' => (string)($row['actor_ip'] ?? ''),
|
||||||
'created_at' => (string)($row['created_at'] ?? ''),
|
'created_at' => (string)($row['created_at'] ?? ''),
|
||||||
'last_seen_at' => (string)($row['last_seen_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;
|
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
|
function push_subscription_status(string $endpoint): array
|
||||||
{
|
{
|
||||||
$endpointHash = $endpoint !== '' ? hash('sha256', $endpoint) : '';
|
$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
|
function reset_battery_low_push_state(): void
|
||||||
{
|
{
|
||||||
$stmt = db()->prepare("
|
$stmt = db()->prepare("
|
||||||
|
|||||||
@@ -1673,6 +1673,20 @@ function control_api_dispatch(): void
|
|||||||
'ok' => true,
|
'ok' => true,
|
||||||
'data' => [
|
'data' => [
|
||||||
'devices' => push_device_rows(),
|
'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,
|
'ok' => true,
|
||||||
'data' => [
|
'data' => [
|
||||||
'devices' => push_device_rows(),
|
'devices' => push_device_rows(),
|
||||||
|
'summary' => push_health_summary(),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -1702,6 +1717,7 @@ function control_api_dispatch(): void
|
|||||||
'ok' => true,
|
'ok' => true,
|
||||||
'data' => [
|
'data' => [
|
||||||
'devices' => push_device_rows(),
|
'devices' => push_device_rows(),
|
||||||
|
'summary' => push_health_summary(),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-4
@@ -28,6 +28,7 @@
|
|||||||
noticeBaseline: $('#noticeBaseline'),
|
noticeBaseline: $('#noticeBaseline'),
|
||||||
pushStatus: $('#pushStatus'),
|
pushStatus: $('#pushStatus'),
|
||||||
pushDeviceList: $('#pushDeviceList'),
|
pushDeviceList: $('#pushDeviceList'),
|
||||||
|
pushHealthcheckBtn: $('#pushHealthcheckBtn'),
|
||||||
processCpuTable: $('#processCpuTable'),
|
processCpuTable: $('#processCpuTable'),
|
||||||
processMemoryTable: $('#processMemoryTable'),
|
processMemoryTable: $('#processMemoryTable'),
|
||||||
dmesgToggle: $('#dmesgToggle'),
|
dmesgToggle: $('#dmesgToggle'),
|
||||||
@@ -324,6 +325,11 @@
|
|||||||
return formatDhms(parseWifiDurationSeconds(value)) || value;
|
return formatDhms(parseWifiDurationSeconds(value)) || value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function timeAgo(seconds) {
|
||||||
|
const formatted = formatDhms(seconds);
|
||||||
|
return formatted ? `${formatted} ago` : '-';
|
||||||
|
}
|
||||||
|
|
||||||
function renderWifi(data) {
|
function renderWifi(data) {
|
||||||
const rows = data.wifi?.clients || [];
|
const rows = data.wifi?.clients || [];
|
||||||
if (!els.wifiTable) return;
|
if (!els.wifiTable) return;
|
||||||
@@ -522,18 +528,34 @@
|
|||||||
: '<div class="spike-log-empty">No system notice history.</div>';
|
: '<div class="spike-log-empty">No system notice history.</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPushDevices(devices = []) {
|
function renderPushDevices(devices = [], summary = null) {
|
||||||
if (!els.pushDeviceList) return;
|
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
|
els.pushDeviceList.innerHTML = devices.length
|
||||||
? devices.map(device => `
|
? devices.map(device => `
|
||||||
<div class="push-device-row">
|
<div class="push-device-row ${escapeHtml(device.health_status || 'pending')}">
|
||||||
<div class="push-device-main">
|
<div class="push-device-main">
|
||||||
<strong>${escapeHtml(device.device_name || '이름 없음')}</strong>
|
<strong>${escapeHtml(device.device_name || '이름 없음')}</strong>
|
||||||
|
<span class="push-device-badge ${escapeHtml(device.health_status || 'pending')}">${escapeHtml(device.health_text || '수신 대기')}</span>
|
||||||
<span>Host: ${escapeHtml(device.host || 'unknown')}</span>
|
<span>Host: ${escapeHtml(device.host || 'unknown')}</span>
|
||||||
<span>IP: ${escapeHtml(device.actor_ip || '-')}</span>
|
<span>IP: ${escapeHtml(device.actor_ip || '-')}</span>
|
||||||
<span>Created: ${escapeHtml(device.created_at || '-')}</span>
|
<span>Created: ${escapeHtml(device.created_at || '-')}</span>
|
||||||
<span>Last seen: ${escapeHtml(device.last_seen_at || '-')}</span>
|
<span>Registered refresh: ${escapeHtml(device.last_seen_at || '-')} (${escapeHtml(timeAgo(device.last_seen_seconds))})</span>
|
||||||
|
<span>Last send success: ${escapeHtml(device.last_send_success_at || '-')} (${escapeHtml(timeAgo(device.last_send_success_seconds))})</span>
|
||||||
|
<span>Last received: ${escapeHtml(device.last_received_at || '-')} (${escapeHtml(timeAgo(device.last_received_seconds))})</span>
|
||||||
|
<span>Last shown: ${escapeHtml(device.last_notification_at || '-')} (${escapeHtml(timeAgo(device.last_notification_seconds))})</span>
|
||||||
|
<span>Last click: ${escapeHtml(device.last_click_at || '-')}</span>
|
||||||
|
<span>Failures: ${Number(device.failure_count || 0).toLocaleString()}${device.last_failure_reason ? ` / ${escapeHtml(device.last_failure_reason)}` : ''}</span>
|
||||||
<span>Encoding: ${escapeHtml(device.content_encoding || '-')}</span>
|
<span>Encoding: ${escapeHtml(device.content_encoding || '-')}</span>
|
||||||
<span>Hash: ${escapeHtml(device.hash || '-')}</span>
|
<span>Hash: ${escapeHtml(device.hash || '-')}</span>
|
||||||
<span>Endpoint: ${escapeHtml(device.endpoint || '-')}</span>
|
<span>Endpoint: ${escapeHtml(device.endpoint || '-')}</span>
|
||||||
@@ -582,12 +604,28 @@
|
|||||||
state.pushDevicesLastRefresh = Date.now();
|
state.pushDevicesLastRefresh = Date.now();
|
||||||
try {
|
try {
|
||||||
const data = await api('push_devices');
|
const data = await api('push_devices');
|
||||||
renderPushDevices(data.devices || []);
|
renderPushDevices(data.devices || [], data.summary || null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(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', () => {
|
window.addEventListener('pushdevices:refresh', () => {
|
||||||
refreshPushDevices(true);
|
refreshPushDevices(true);
|
||||||
});
|
});
|
||||||
@@ -596,6 +634,8 @@
|
|||||||
renderPushStatus(event.detail || {});
|
renderPushStatus(event.detail || {});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
els.pushHealthcheckBtn?.addEventListener('click', sendPushHealthcheck);
|
||||||
|
|
||||||
function renderFanCause(data) {
|
function renderFanCause(data) {
|
||||||
const processes = data.processes || {};
|
const processes = data.processes || {};
|
||||||
const baseline = data.fan_spike || {};
|
const baseline = data.fan_spike || {};
|
||||||
|
|||||||
+7
-2
@@ -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}
|
.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}
|
.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-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)}
|
.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: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}}
|
@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}
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="spike-log-box">
|
<div class="spike-log-box">
|
||||||
|
<div class="push-device-head">
|
||||||
<h3>Push Devices</h3>
|
<h3>Push Devices</h3>
|
||||||
|
<div class="push-device-actions">
|
||||||
|
<button class="btn secondary" id="pushHealthcheckBtn" type="button">Health Check</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="pushStatus" class="spike-log-empty">Push status checking...</div>
|
<div id="pushStatus" class="spike-log-empty">Push status checking...</div>
|
||||||
<div id="pushDeviceList" class="push-device-list">
|
<div id="pushDeviceList" class="push-device-list">
|
||||||
<div class="spike-log-empty">No push devices.</div>
|
<div class="spike-log-empty">No push devices.</div>
|
||||||
@@ -278,7 +283,7 @@ a{color:inherit;text-decoration:none}
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<div id="notice" class="notice"></div>
|
<div id="notice" class="notice"></div>
|
||||||
<script src="/assets/app.js?v=20260607_nowifibtn1"></script>
|
<script src="/assets/app.js?v=20260607_pushhealth1"></script>
|
||||||
<script src="/assets/wakelock.js?v=20260606_wakelock2"></script>
|
<script src="/assets/wakelock.js?v=20260606_wakelock2"></script>
|
||||||
<script src="/push_subscribe.js?v=20260606_noappend1"></script>
|
<script src="/push_subscribe.js?v=20260606_noappend1"></script>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
Reference in New Issue
Block a user