manage-heartbeat.php 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
  1. <?php
  2. // Status report to the Manage server.
  3. //
  4. // There is no cron on this host, so the heartbeat rides along with admin page
  5. // loads: throttled on admin/settings.php via manageHeartbeatSendIfDue(), and
  6. // unthrottled right after a backup or an update, when the reported values have
  7. // just changed.
  8. //
  9. // The response doubles as a cheap update check, which is why the server
  10. // dashboard stays current without polling the shop.
  11. declare(strict_types=1);
  12. function manageHeartbeatPayload(): array
  13. {
  14. $lastBackupAt = '';
  15. $pendingMigrations = 0;
  16. try {
  17. $backups = manageBackupList();
  18. if ($backups !== []) {
  19. $lastBackupAt = (string) ($backups[0]['created_at'] ?? '');
  20. }
  21. } catch (Throwable $exception) {
  22. // A broken backup index must not stop the heartbeat; the server simply
  23. // keeps its previous value.
  24. manageClientLog('WARNING', 'Heartbeat could not read backup index', [
  25. 'error' => $exception->getMessage(),
  26. ]);
  27. }
  28. try {
  29. $pendingMigrations = count(manageUpdatePendingMigrations());
  30. } catch (Throwable $exception) {
  31. manageClientLog('WARNING', 'Heartbeat could not read migrations', [
  32. 'error' => $exception->getMessage(),
  33. ]);
  34. }
  35. $diskFree = 0;
  36. $free = @disk_free_space(manageClientAppRoot());
  37. if (is_float($free) || is_int($free)) {
  38. $diskFree = (int) $free;
  39. }
  40. return [
  41. 'version' => manageClientVersion(),
  42. 'php_version' => PHP_VERSION,
  43. 'disk_free' => $diskFree,
  44. 'pending_migrations' => $pendingMigrations,
  45. 'last_backup_at' => $lastBackupAt,
  46. ];
  47. }
  48. /**
  49. * Sends the heartbeat.
  50. *
  51. * @return array{success: bool, latest: string, update_available: bool, server_time: string}
  52. */
  53. function manageHeartbeatSend(): array
  54. {
  55. $decoded = manageClientRequestJson(
  56. 'POST',
  57. 'heartbeat.php',
  58. manageHeartbeatPayload(),
  59. (int) MANAGE_HTTP_TIMEOUT
  60. );
  61. manageWriteJson((string) MANAGE_HEARTBEAT_STATE, [
  62. 'sent_at' => date(DATE_ATOM),
  63. 'latest' => (string) ($decoded['latest'] ?? ''),
  64. 'update_available' => !empty($decoded['update_available']),
  65. ]);
  66. return [
  67. 'success' => !empty($decoded['success']),
  68. 'latest' => (string) ($decoded['latest'] ?? ''),
  69. 'update_available' => !empty($decoded['update_available']),
  70. 'server_time' => (string) ($decoded['server_time'] ?? ''),
  71. ];
  72. }
  73. /**
  74. * Heartbeat that never throws. For call sites inside a page render, where an
  75. * unreachable server must not surface as an error.
  76. */
  77. function manageHeartbeatSendQuietly(): ?array
  78. {
  79. if (!manageClientConfigured()) {
  80. return null;
  81. }
  82. try {
  83. return manageHeartbeatSend();
  84. } catch (Throwable $exception) {
  85. manageClientLog('WARNING', 'Heartbeat failed', ['error' => $exception->getMessage()]);
  86. return null;
  87. }
  88. }
  89. function manageHeartbeatLastSentAt(): int
  90. {
  91. $timestamp = strtotime((string) (manageReadJson((string) MANAGE_HEARTBEAT_STATE)['sent_at'] ?? ''));
  92. return $timestamp === false ? 0 : $timestamp;
  93. }
  94. /**
  95. * Sends a heartbeat at most once per hour, so opening the settings page
  96. * repeatedly does not mean one HTTP round trip per render. Never throws.
  97. */
  98. function manageHeartbeatSendIfDue(int $intervalSeconds = 3600): ?array
  99. {
  100. if (time() - manageHeartbeatLastSentAt() < max(1, $intervalSeconds)) {
  101. return null;
  102. }
  103. return manageHeartbeatSendQuietly();
  104. }