From f4952f16e664d5ad820975cee8b520d5434d97c1 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Wed, 22 Apr 2026 14:23:24 +0200 Subject: [PATCH] Add caching layer for edge gateway views, including payload storage, retrieval, sync, and invalidation --- .../app/classes/edge_gateway_manager.php | 427 +++++++++++++++++- .../edge_gateway_operation_service.php | 14 + .../app/classes/edge_gateway_view_cache.php | 339 ++++++++++++++ .../app/classes/edge_gateway_view_service.php | 37 +- .../nginx/app/routes/edgeGatewaysRoute.php | 6 +- .../EdgeGatewayFleetUsageStatisticsTest.php | 59 +++ .../Selfserve/EdgeGatewayRouteWiringTest.php | 1 + .../EdgeGatewayUpdateLifecycleTest.php | 6 +- .../Selfserve/EdgeGatewayViewCacheTest.php | 218 +++++++++ 9 files changed, 1083 insertions(+), 24 deletions(-) create mode 100644 services/nginx/app/classes/edge_gateway_view_cache.php create mode 100644 services/nginx/app/tests/Unit/Selfserve/EdgeGatewayViewCacheTest.php diff --git a/services/nginx/app/classes/edge_gateway_manager.php b/services/nginx/app/classes/edge_gateway_manager.php index 35f54366..02d963e3 100644 --- a/services/nginx/app/classes/edge_gateway_manager.php +++ b/services/nginx/app/classes/edge_gateway_manager.php @@ -170,8 +170,11 @@ class edge_gateway_manager ['hostname' => $hostname, 'installed_version' => $installedVersion] ); + $gatewayPayload = $this->getGateway($gatewayId); + edge_gateway_view_cache::syncGateway($gatewayPayload); + return [ - 'gateway' => $this->getGateway($gatewayId), + 'gateway' => $gatewayPayload, 'agent_token' => $agentToken, 'heartbeat_url' => $this->getApiBaseUrl() . '/edge-agent/gateways/' . $gatewayId . '/heartbeat', 'commands_poll_url' => $this->getApiBaseUrl() . '/edge-agent/gateways/' . $gatewayId . '/commands/poll', @@ -214,7 +217,10 @@ class edge_gateway_manager $this->syncDeviceInventory($gatewayId, $payload['inventory']); } - return $this->getGateway($gatewayId); + $gatewayPayload = $this->getGateway($gatewayId); + edge_gateway_view_cache::syncGateway($gatewayPayload); + + return $gatewayPayload; } /** @@ -229,9 +235,20 @@ class edge_gateway_manager : $gatewayObject->getFieldsWhere(['department_id' => $departmentId, 'deleted_at' => null], ['id']); $gateways = []; + $gatewayIds = []; foreach ($rows as $row) { - $gateway = $this->requireGateway((int)$row['id']); + $gatewayId = (int)$row['id']; + $gateway = $this->requireGateway($gatewayId); $gateways[] = $this->buildGatewayPayload($gateway, $includeDetail); + $gatewayIds[] = $gatewayId; + } + + if ($gatewayIds !== []) { + $gateways = self::attachGatewayCollectionSummaries( + $gateways, + $this->aggregateInventoryUsageByGateway($gatewayIds), + $this->aggregateBindingUsageByGateway($gatewayIds) + ); } usort($gateways, static fn(array $a, array $b): int => ($a['department_id'] <=> $b['department_id']) ?: ($a['id'] <=> $b['id'])); @@ -246,16 +263,8 @@ class edge_gateway_manager public function buildFleetUsageStatistics(?int $departmentId = null, array $gateways = []): array { $fleet = $gateways !== [] ? $gateways : $this->listGateways($departmentId, false); - $gatewayIds = array_values(array_filter(array_map( - static fn(array $gateway): int => (int)($gateway['id'] ?? 0), - $fleet - ))); - return self::summarizeFleetUsage( - $fleet, - $this->aggregateInventoryUsage($gatewayIds), - $this->aggregateBindingUsage($gatewayIds) - ); + return self::summarizeFleetUsageFromGatewayRows($fleet); } /** @@ -264,8 +273,12 @@ class edge_gateway_manager */ public static function summarizeFleetUsage(array $gateways, array $inventoryUsage = [], array $bindingUsage = []): array { - $inventory = array_merge(self::emptyInventoryUsage(), $inventoryUsage); - $bindings = array_merge(self::emptyBindingUsage(), $bindingUsage); + $inventory = $inventoryUsage === [] + ? self::aggregateInventoryUsageFromGatewayRows($gateways) + : array_merge(self::emptyInventoryUsage(), $inventoryUsage); + $bindings = $bindingUsage === [] + ? self::aggregateBindingUsageFromGatewayRows($gateways) + : array_merge(self::emptyBindingUsage(), $bindingUsage); $totalGateways = count($gateways); $departmentIds = []; $gatewayOnline = 0; @@ -361,13 +374,56 @@ class edge_gateway_manager ]; } + /** + * @param array> $gateways + * @return array + */ + public static function summarizeFleetUsageFromGatewayRows(array $gateways): array + { + return self::summarizeFleetUsage( + $gateways, + self::aggregateInventoryUsageFromGatewayRows($gateways), + self::aggregateBindingUsageFromGatewayRows($gateways) + ); + } + + /** + * @param array $gateway + * @return array + */ + public static function prepareGatewayForListCache(array $gateway, bool $includeDetail): array + { + $gateway = self::decorateGatewayUsageSummaries($gateway); + + if ($includeDetail) { + return $gateway; + } + + $gateway['inventory'] = []; + $gateway['bindings'] = []; + $gateway['recent_commands'] = []; + $gateway['audit_logs'] = []; + if (isset($gateway['operations']) && is_array($gateway['operations'])) { + $gateway['operations'] = array_map(static function (mixed $operation): mixed { + if (!is_array($operation)) { + return $operation; + } + + $operation['events'] = []; + return $operation; + }, array_slice($gateway['operations'], 0, 5)); + } + + return $gateway; + } + /** * @throws Exception */ public function getGateway(int $gatewayId): array { $gateway = $this->requireGateway($gatewayId); - return $this->buildGatewayPayload($gateway, true); + return self::decorateGatewayUsageSummaries($this->buildGatewayPayload($gateway, true)); } /** @@ -434,7 +490,10 @@ class edge_gateway_manager ] ); - return $this->getGateway($gatewayId); + $gatewayPayload = $this->getGateway($gatewayId); + edge_gateway_view_cache::syncGateway($gatewayPayload); + + return $gatewayPayload; } /** @@ -456,6 +515,8 @@ class edge_gateway_manager ['transport_mode' => $transportMode] ); + edge_gateway_view_cache::clearAll(); + return [ 'department_id' => $departmentId, 'transport_mode' => $transportMode, @@ -540,6 +601,8 @@ class edge_gateway_manager ['binding_count' => count($bindings)] ); + edge_gateway_view_cache::syncGateway($this->getGateway($gatewayId)); + return $this->listBindings($gatewayId); } @@ -554,7 +617,10 @@ class edge_gateway_manager 'preferred_channel' => $this->resolveGatewayPreferredCommandChannel($gateway), ]); - return $this->getGateway($gatewayId); + $gatewayPayload = $this->getGateway($gatewayId); + edge_gateway_view_cache::syncGateway($gatewayPayload); + + return $gatewayPayload; } /** @@ -583,6 +649,8 @@ class edge_gateway_manager ['label' => $label] ); + edge_gateway_view_cache::removeGateway($gatewayId, $departmentId); + return [ 'deleted' => true, 'gateway_id' => $gatewayId, @@ -649,6 +717,8 @@ class edge_gateway_manager $this->finalizeCommandJob($job, $ok, $payload, $errorMessage, $gateway); + edge_gateway_view_cache::syncGateway($this->getGateway($gatewayId)); + return [ 'acknowledged' => true, 'job' => $job->asArray(), @@ -1009,6 +1079,37 @@ resolve_compose_command() { fi return 1 } +apt_package_exists() { + local package_name="$1" + apt-cache show "$package_name" 2>/dev/null | grep -q '^Package: ' +} +install_compose_runtime() { + if resolve_compose_command >/dev/null 2>&1; then + return 0 + fi + + if apt_package_exists docker-compose-plugin; then + log_info "Installing Docker Compose package docker-compose-plugin" + apt-get install -y docker-compose-plugin + elif apt_package_exists docker-compose; then + log_info "Installing Docker Compose package docker-compose" + apt-get install -y docker-compose + else + if apt-get install -y docker-compose-plugin; then + : + elif apt-get install -y docker-compose; then + : + else + echo "Unable to install Docker Compose using docker-compose-plugin or docker-compose." >&2 + return 1 + fi + fi + + if ! resolve_compose_command >/dev/null 2>&1; then + echo "Docker Compose command is unavailable after installation." >&2 + return 1 + fi +} wait_for_gateway_claim() { local config_path="$1" local heartbeat_path="$2" @@ -1054,7 +1155,8 @@ REUSE_EXISTING_CREDENTIALS=0 run_step "Creating install directory" mkdir -p "$INSTALL_DIR" "$RUNTIME_DIR" "$RUNTIME_DIR/backups" export DEBIAN_FRONTEND=noninteractive run_step "Updating package lists" apt-get update -run_step "Installing required packages" apt-get install -y curl ca-certificates docker.io docker-compose-plugin php-cli php-curl php-mbstring php-sqlite3 +run_step "Installing base packages" apt-get install -y curl ca-certificates docker.io php-cli php-curl php-mbstring php-sqlite3 +run_step "Installing Docker Compose runtime" install_compose_runtime fetch_http "Download PHP edge agent" "__AGENT_URL__" "$INSTALL_DIR/agent.php" fetch_http "Download LAN worker" "__WORKER_URL__" "$INSTALL_DIR/lan-worker.php" fetch_http "Download compose stack" "__COMPOSE_URL__" "$INSTALL_DIR/docker-compose.gateway.yml" @@ -1164,6 +1266,8 @@ BASH; ] ); + edge_gateway_view_cache::syncGateway($this->getGateway($gatewayId)); + return [ 'gateway_id' => (int)$gateway->id, 'rotated_at' => (string)$metadata['credentials_rotated_at'], @@ -1889,6 +1993,8 @@ BASH; } $gateway->metadata_json->set($gatewayMetadata); + edge_gateway_view_cache::syncGateway($this->getGateway($gatewayId)); + return $presence; } @@ -2534,6 +2640,291 @@ BASH; ]; } + /** + * @param array $gatewayIds + * @return array> + */ + private function aggregateInventoryUsageByGateway(array $gatewayIds): array + { + if ($gatewayIds === []) { + return []; + } + + $placeholders = implode(', ', array_fill(0, count($gatewayIds), '?')); + $statement = db::getPDO()->prepare( + "SELECT + gateway_id, + COUNT(*) AS total, + SUM(CASE WHEN online = 1 THEN 1 ELSE 0 END) AS online_total, + SUM(CASE WHEN online = 0 THEN 1 ELSE 0 END) AS offline_total + FROM edge_gateway_device_inventory + WHERE deleted_at IS NULL + AND gateway_id IN ($placeholders) + GROUP BY gateway_id" + ); + $statement->execute($gatewayIds); + + $rows = []; + while (($row = $statement->fetch()) !== false) { + if (!is_array($row) || !isset($row['gateway_id'])) { + continue; + } + + $rows[(int)$row['gateway_id']] = [ + 'total' => (int)($row['total'] ?? 0), + 'online' => (int)($row['online_total'] ?? 0), + 'offline' => (int)($row['offline_total'] ?? 0), + ]; + } + + return $rows; + } + + /** + * @param array $gatewayIds + * @return array> + */ + private function aggregateBindingUsageByGateway(array $gatewayIds): array + { + if ($gatewayIds === []) { + return []; + } + + $placeholders = implode(', ', array_fill(0, count($gatewayIds), '?')); + $statement = db::getPDO()->prepare( + "SELECT + gateway_id, + COUNT(*) AS total, + SUM(CASE WHEN fallback_mode <> ? THEN 1 ELSE 0 END) AS fallback_overrides, + SUM(CASE WHEN fallback_mode = ? THEN 1 ELSE 0 END) AS cloud_only_total, + SUM(CASE WHEN fallback_mode = ? THEN 1 ELSE 0 END) AS local_only_total + FROM edge_gateway_relay_bindings + WHERE deleted_at IS NULL + AND gateway_id IN ($placeholders) + GROUP BY gateway_id" + ); + $statement->execute(array_merge([ + self::RELAY_FALLBACK_PREFER_LOCAL, + self::RELAY_FALLBACK_CLOUD_ONLY, + self::RELAY_FALLBACK_LOCAL_ONLY, + ], $gatewayIds)); + + $rows = []; + while (($row = $statement->fetch()) !== false) { + if (!is_array($row) || !isset($row['gateway_id'])) { + continue; + } + + $rows[(int)$row['gateway_id']] = [ + 'total' => (int)($row['total'] ?? 0), + 'fallback_overrides' => (int)($row['fallback_overrides'] ?? 0), + 'cloud_only' => (int)($row['cloud_only_total'] ?? 0), + 'local_only' => (int)($row['local_only_total'] ?? 0), + ]; + } + + return $rows; + } + + /** + * @param array> $gateways + * @param array> $inventoryUsageByGateway + * @param array> $bindingUsageByGateway + * @return array> + */ + private static function attachGatewayCollectionSummaries( + array $gateways, + array $inventoryUsageByGateway = [], + array $bindingUsageByGateway = [] + ): array { + foreach ($gateways as $index => $gateway) { + if (!is_array($gateway)) { + continue; + } + + $gatewayId = (int)($gateway['id'] ?? 0); + $gateways[$index] = self::decorateGatewayUsageSummaries( + $gateway, + $inventoryUsageByGateway[$gatewayId] ?? null, + $bindingUsageByGateway[$gatewayId] ?? null + ); + } + + return $gateways; + } + + /** + * @param array> $gateways + * @return array + */ + private static function aggregateInventoryUsageFromGatewayRows(array $gateways): array + { + $totals = self::emptyInventoryUsage(); + + foreach ($gateways as $gateway) { + if (!is_array($gateway)) { + continue; + } + + $summary = self::resolveInventoryUsageForGateway($gateway); + $totals['total'] += (int)($summary['total'] ?? 0); + $totals['online'] += (int)($summary['online'] ?? 0); + $totals['offline'] += (int)($summary['offline'] ?? 0); + } + + return $totals; + } + + /** + * @param array> $gateways + * @return array + */ + private static function aggregateBindingUsageFromGatewayRows(array $gateways): array + { + $totals = self::emptyBindingUsage(); + + foreach ($gateways as $gateway) { + if (!is_array($gateway)) { + continue; + } + + $summary = self::resolveBindingUsageForGateway($gateway); + $totals['total'] += (int)($summary['total'] ?? 0); + $totals['fallback_overrides'] += (int)($summary['fallback_overrides'] ?? 0); + $totals['cloud_only'] += (int)($summary['cloud_only'] ?? 0); + $totals['local_only'] += (int)($summary['local_only'] ?? 0); + } + + return $totals; + } + + /** + * @param array $gateway + * @param array|null $inventoryUsage + * @param array|null $bindingUsage + * @return array + */ + private static function decorateGatewayUsageSummaries( + array $gateway, + ?array $inventoryUsage = null, + ?array $bindingUsage = null + ): array { + $inventory = self::resolveInventoryUsageForGateway($gateway, $inventoryUsage); + $bindings = self::resolveBindingUsageForGateway($gateway, $bindingUsage); + + $gateway['inventory_summary'] = $inventory; + $gateway['binding_summary'] = [ + 'total' => (int)($bindings['total'] ?? 0), + 'fallback_overrides' => (int)($bindings['fallback_overrides'] ?? 0), + ]; + + $fallbackSummary = isset($gateway['fallback_summary']) && is_array($gateway['fallback_summary']) + ? (array)$gateway['fallback_summary'] + : []; + $gateway['fallback_summary'] = array_merge($fallbackSummary, [ + 'cloud_only_relays' => (int)($fallbackSummary['cloud_only_relays'] ?? $bindings['cloud_only'] ?? 0), + 'local_only_relays' => (int)($fallbackSummary['local_only_relays'] ?? $bindings['local_only'] ?? 0), + ]); + + return $gateway; + } + + /** + * @param array $gateway + * @param array|null $summary + * @return array + */ + private static function resolveInventoryUsageForGateway(array $gateway, ?array $summary = null): array + { + if ($summary !== null) { + return array_merge(self::emptyInventoryUsage(), $summary); + } + + if (isset($gateway['inventory_summary']) && is_array($gateway['inventory_summary'])) { + return array_merge(self::emptyInventoryUsage(), array_map('intval', $gateway['inventory_summary'])); + } + + $inventory = isset($gateway['inventory']) && is_array($gateway['inventory']) ? $gateway['inventory'] : []; + $online = 0; + $offline = 0; + foreach ($inventory as $device) { + if (!is_array($device)) { + continue; + } + + if (($device['online'] ?? true) === false) { + $offline += 1; + continue; + } + + $online += 1; + } + + return [ + 'total' => count($inventory), + 'online' => $online, + 'offline' => $offline, + ]; + } + + /** + * @param array $gateway + * @param array|null $summary + * @return array + */ + private static function resolveBindingUsageForGateway(array $gateway, ?array $summary = null): array + { + if ($summary !== null) { + return array_merge(self::emptyBindingUsage(), $summary); + } + + if (isset($gateway['binding_summary']) && is_array($gateway['binding_summary'])) { + $fallbackSummary = isset($gateway['fallback_summary']) && is_array($gateway['fallback_summary']) + ? (array)$gateway['fallback_summary'] + : []; + + return array_merge(self::emptyBindingUsage(), [ + 'total' => (int)($gateway['binding_summary']['total'] ?? 0), + 'fallback_overrides' => (int)($gateway['binding_summary']['fallback_overrides'] ?? 0), + 'cloud_only' => (int)($fallbackSummary['cloud_only_relays'] ?? 0), + 'local_only' => (int)($fallbackSummary['local_only_relays'] ?? 0), + ]); + } + + $bindings = isset($gateway['bindings']) && is_array($gateway['bindings']) ? $gateway['bindings'] : []; + $fallbackOverrides = 0; + $cloudOnly = 0; + $localOnly = 0; + foreach ($bindings as $binding) { + if (!is_array($binding)) { + continue; + } + + $metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : []; + $fallbackMode = isset($metadata['fallback_mode']) + ? (string)$metadata['fallback_mode'] + : (string)($binding['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL); + $fallbackMode = self::normalizeFallbackMode($fallbackMode); + + if ($fallbackMode !== self::RELAY_FALLBACK_PREFER_LOCAL) { + $fallbackOverrides += 1; + } + if ($fallbackMode === self::RELAY_FALLBACK_CLOUD_ONLY) { + $cloudOnly += 1; + } + if ($fallbackMode === self::RELAY_FALLBACK_LOCAL_ONLY) { + $localOnly += 1; + } + } + + return [ + 'total' => count($bindings), + 'fallback_overrides' => $fallbackOverrides, + 'cloud_only' => $cloudOnly, + 'local_only' => $localOnly, + ]; + } + /** * @return array */ diff --git a/services/nginx/app/classes/edge_gateway_operation_service.php b/services/nginx/app/classes/edge_gateway_operation_service.php index 7144e014..c3a7e170 100644 --- a/services/nginx/app/classes/edge_gateway_operation_service.php +++ b/services/nginx/app/classes/edge_gateway_operation_service.php @@ -226,6 +226,8 @@ class edge_gateway_operation_service ['operation_id' => $operationId, 'type' => $type] ); + $this->refreshGatewayViewCache($gatewayId); + return $this->serializeOperation((new edge_gateway_operations_o())->select($operationId), true); } @@ -320,6 +322,8 @@ class edge_gateway_operation_service $operation->summary_json->set($summary); $this->refreshOperationLease($operation); + $this->refreshGatewayViewCache($gatewayId); + return $this->serializeOperation($operation, true); } @@ -374,6 +378,7 @@ class edge_gateway_operation_service ); $this->applyCompletionSideEffects($gatewayId, $operation, $ok, $result, $errorCode, $errorMessage); + $this->refreshGatewayViewCache($gatewayId); return $this->serializeOperation($operation, true); } @@ -490,6 +495,8 @@ class edge_gateway_operation_service ] ); + $this->refreshGatewayViewCache($gatewayId); + return $this->serializeOperation($operation, true); } catch (\Throwable $throwable) { if ($pdo->inTransaction()) { @@ -551,6 +558,8 @@ class edge_gateway_operation_service 'last_progress_at' => $operation->last_progress_at->value(), ] ); + + $this->refreshGatewayViewCache($gatewayId); } } @@ -730,6 +739,11 @@ class edge_gateway_operation_service $operation->lease_expires_at->set($this->leaseExpiry()); } + private function refreshGatewayViewCache(int $gatewayId): void + { + edge_gateway_view_cache::syncGateway($this->manager()->getGateway($gatewayId)); + } + /** * @throws Exception */ diff --git a/services/nginx/app/classes/edge_gateway_view_cache.php b/services/nginx/app/classes/edge_gateway_view_cache.php new file mode 100644 index 00000000..6b6ad939 --- /dev/null +++ b/services/nginx/app/classes/edge_gateway_view_cache.php @@ -0,0 +1,339 @@ +>, fleet_usage: array}|null + */ + public static function getListPayload(?int $departmentId = null, bool $includeDetail = true): ?array + { + return self::decodeListPayload(self::redisGet(self::listKey($departmentId, $includeDetail))); + } + + /** + * @param array{gateways: array>, fleet_usage: array} $payload + */ + public static function storeListPayload(?int $departmentId, bool $includeDetail, array $payload, ?int $ttl = null): void + { + self::storePayload(self::listKey($departmentId, $includeDetail), $payload, $ttl); + } + + /** + * @return array|null + */ + public static function getDetailPayload(int $gatewayId): ?array + { + $decoded = self::decodePayload(self::redisGet(self::detailKey($gatewayId))); + if (!is_array($decoded) || !isset($decoded['gateway']) || !is_array($decoded['gateway'])) { + return null; + } + + return $decoded['gateway']; + } + + /** + * @param array $gateway + */ + public static function storeDetailPayload(int $gatewayId, array $gateway, ?int $ttl = null): void + { + self::storePayload(self::detailKey($gatewayId), ['gateway' => $gateway], $ttl); + } + + public static function clearAll(): void + { + self::clearPattern(self::PREFIX . '*'); + } + + public static function clearGateway(int $gatewayId, ?int $departmentId = null): void + { + self::clearPattern(self::detailKey($gatewayId)); + self::clearPattern(self::listKey(null, true)); + self::clearPattern(self::listKey(null, false)); + + if ($departmentId !== null) { + self::clearPattern(self::listKey($departmentId, true)); + self::clearPattern(self::listKey($departmentId, false)); + } + } + + /** + * @param array $gateway + */ + public static function syncGateway(array $gateway): void + { + $gatewayId = (int)($gateway['id'] ?? 0); + $departmentId = isset($gateway['department_id']) ? (int)$gateway['department_id'] : null; + + if ($gatewayId <= 0) { + return; + } + + $detailGateway = edge_gateway_manager::prepareGatewayForListCache($gateway, true); + + self::storeDetailPayload($gatewayId, $detailGateway); + self::syncListPayload(null, true, $detailGateway); + self::syncListPayload(null, false, $detailGateway); + + if ($departmentId !== null && $departmentId > 0) { + self::syncListPayload($departmentId, true, $detailGateway); + self::syncListPayload($departmentId, false, $detailGateway); + } + } + + public static function removeGateway(int $gatewayId, ?int $departmentId = null): void + { + self::clearPattern(self::detailKey($gatewayId)); + self::removeGatewayFromListPayload(null, true, $gatewayId); + self::removeGatewayFromListPayload(null, false, $gatewayId); + + if ($departmentId !== null) { + self::removeGatewayFromListPayload($departmentId, true, $gatewayId); + self::removeGatewayFromListPayload($departmentId, false, $gatewayId); + } + } + + /** + * @param array $gateway + */ + private static function syncListPayload(?int $departmentId, bool $includeDetail, array $gateway): void + { + $payload = self::getListPayload($departmentId, $includeDetail); + if ($payload === null) { + return; + } + + $rows = isset($payload['gateways']) && is_array($payload['gateways']) ? array_values($payload['gateways']) : []; + $preparedGateway = edge_gateway_manager::prepareGatewayForListCache($gateway, $includeDetail); + $gatewayId = (int)($preparedGateway['id'] ?? 0); + $matchesDepartment = $departmentId === null + || (int)($preparedGateway['department_id'] ?? 0) === (int)$departmentId; + + if ($gatewayId <= 0 || !$matchesDepartment) { + return; + } + + $updated = false; + foreach ($rows as $index => $row) { + if ((int)($row['id'] ?? 0) !== $gatewayId) { + continue; + } + + $rows[$index] = self::mergeGatewayPayload($row, $preparedGateway, $includeDetail); + $updated = true; + break; + } + + if (!$updated) { + $rows[] = $preparedGateway; + } + + usort($rows, static fn(array $left, array $right): int => ((int)($left['department_id'] ?? 0) <=> (int)($right['department_id'] ?? 0)) + ?: ((int)($left['id'] ?? 0) <=> (int)($right['id'] ?? 0))); + + self::storeListPayload($departmentId, $includeDetail, [ + 'gateways' => $rows, + 'fleet_usage' => edge_gateway_manager::summarizeFleetUsageFromGatewayRows($rows), + ]); + } + + private static function removeGatewayFromListPayload(?int $departmentId, bool $includeDetail, int $gatewayId): void + { + $payload = self::getListPayload($departmentId, $includeDetail); + if ($payload === null) { + return; + } + + $rows = array_values(array_filter( + isset($payload['gateways']) && is_array($payload['gateways']) ? $payload['gateways'] : [], + static fn(mixed $row): bool => (int)(is_array($row) ? ($row['id'] ?? 0) : 0) !== $gatewayId + )); + + self::storeListPayload($departmentId, $includeDetail, [ + 'gateways' => $rows, + 'fleet_usage' => edge_gateway_manager::summarizeFleetUsageFromGatewayRows($rows), + ]); + } + + /** + * @param array $currentGateway + * @param array $nextGateway + * @return array + */ + private static function mergeGatewayPayload(array $currentGateway, array $nextGateway, bool $includeDetail): array + { + $merged = array_merge($currentGateway, $nextGateway); + $merged['metadata'] = array_merge( + isset($currentGateway['metadata']) && is_array($currentGateway['metadata']) ? $currentGateway['metadata'] : [], + isset($nextGateway['metadata']) && is_array($nextGateway['metadata']) ? $nextGateway['metadata'] : [] + ); + + foreach (['inventory', 'bindings', 'recent_commands', 'audit_logs', 'operations', 'relay_health', 'diagnostics'] as $listKey) { + if (array_key_exists($listKey, $nextGateway)) { + $merged[$listKey] = $nextGateway[$listKey]; + continue; + } + + if (array_key_exists($listKey, $currentGateway)) { + $merged[$listKey] = $currentGateway[$listKey]; + } + } + + return edge_gateway_manager::prepareGatewayForListCache($merged, $includeDetail); + } + + /** + * @return array{gateways: array>, fleet_usage: array}|null + */ + private static function decodeListPayload(?string $raw): ?array + { + $decoded = self::decodePayload($raw); + if (!is_array($decoded)) { + return null; + } + + if (!isset($decoded['gateways']) || !is_array($decoded['gateways'])) { + return null; + } + + if (!isset($decoded['fleet_usage']) || !is_array($decoded['fleet_usage'])) { + return null; + } + + return [ + 'gateways' => array_values($decoded['gateways']), + 'fleet_usage' => $decoded['fleet_usage'], + ]; + } + + /** + * @return array|null + */ + private static function decodePayload(?string $raw): ?array + { + if ($raw === null || trim($raw) === '') { + return null; + } + + $decoded = json_decode($raw, true); + return is_array($decoded) ? $decoded : null; + } + + /** + * @param array $payload + */ + private static function storePayload(string $key, array $payload, ?int $ttl = null): void + { + $cacheTtl = $ttl ?? self::getTtl(); + if ($cacheTtl <= 0) { + return; + } + + $encoded = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if (!is_string($encoded)) { + return; + } + + self::redisSetEx($key, $encoded, $cacheTtl); + } + + private static function clearPattern(string $pattern): void + { + try { + $client = self::redisClient(); + if ($client === null) { + return; + } + $client->clear_keys($pattern); + } catch (Throwable) { + // Cache invalidation must never break request flow. + } + } + + private static function redisSetEx(string $key, string $value, int $ttl): void + { + try { + $client = self::redisClient(); + if ($client === null) { + return; + } + $client->setEx($key, $value, $ttl); + } catch (Throwable) { + // Best-effort cache write. + } + } + + private static function redisGet(string $key): ?string + { + try { + $client = self::redisClient(); + if ($client === null) { + return null; + } + + $value = $client->get($key); + return is_string($value) ? $value : null; + } catch (Throwable) { + return null; + } + } + + private static function redisClient(): ?object + { + if (self::$adapter !== null) { + return self::$adapter; + } + + try { + if (defined('redis')) { + $instance = constant('redis'); + if (is_object($instance)) { + return $instance; + } + } + + return (new redis())->connect(); + } catch (Throwable) { + return null; + } + } +} diff --git a/services/nginx/app/classes/edge_gateway_view_service.php b/services/nginx/app/classes/edge_gateway_view_service.php index a7c02b0c..8696ac7b 100644 --- a/services/nginx/app/classes/edge_gateway_view_service.php +++ b/services/nginx/app/classes/edge_gateway_view_service.php @@ -17,7 +17,28 @@ class edge_gateway_view_service */ public function listGateways(?int $departmentId = null, bool $includeDetail = true): array { - return $this->manager()->listGateways($departmentId, $includeDetail); + return $this->listGatewaysWithFleetUsage($departmentId, $includeDetail)['gateways']; + } + + /** + * @return array{gateways: array>, fleet_usage: array} + * @throws Exception + */ + public function listGatewaysWithFleetUsage(?int $departmentId = null, bool $includeDetail = true): array + { + $cached = edge_gateway_view_cache::getListPayload($departmentId, $includeDetail); + if ($cached !== null) { + return $cached; + } + + $gateways = $this->manager()->listGateways($departmentId, $includeDetail); + $payload = [ + 'gateways' => $gateways, + 'fleet_usage' => $this->manager()->buildFleetUsageStatistics($departmentId, $gateways), + ]; + edge_gateway_view_cache::storeListPayload($departmentId, $includeDetail, $payload); + + return $payload; } /** @@ -27,6 +48,10 @@ class edge_gateway_view_service */ public function buildFleetUsageStatistics(?int $departmentId = null, array $gateways = []): array { + if ($gateways === []) { + return $this->listGatewaysWithFleetUsage($departmentId, false)['fleet_usage']; + } + return $this->manager()->buildFleetUsageStatistics($departmentId, $gateways); } @@ -35,7 +60,15 @@ class edge_gateway_view_service */ public function getGateway(int $gatewayId): array { - return $this->manager()->getGateway($gatewayId); + $cached = edge_gateway_view_cache::getDetailPayload($gatewayId); + if ($cached !== null) { + return $cached; + } + + $gateway = $this->manager()->getGateway($gatewayId); + edge_gateway_view_cache::storeDetailPayload($gatewayId, $gateway); + + return $gateway; } private function manager(): edge_gateway_manager diff --git a/services/nginx/app/routes/edgeGatewaysRoute.php b/services/nginx/app/routes/edgeGatewaysRoute.php index 075073ac..70862f2c 100644 --- a/services/nginx/app/routes/edgeGatewaysRoute.php +++ b/services/nginx/app/routes/edgeGatewaysRoute.php @@ -87,9 +87,9 @@ class edgeGatewaysRoute $this->requireDepartmentAccess($departmentId); } - $gateways = $this->views()->listGateways($departmentId, $view !== 'summary'); - $response->add_meta('fleet_usage', $this->views()->buildFleetUsageStatistics($departmentId, $gateways)); - $response->success($gateways); + $payload = $this->views()->listGatewaysWithFleetUsage($departmentId, $view !== 'summary'); + $response->add_meta('fleet_usage', $payload['fleet_usage']); + $response->success($payload['gateways']); } private function handleGatewayDetail(): void diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayFleetUsageStatisticsTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayFleetUsageStatisticsTest.php index f1b9424d..d225d60b 100644 --- a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayFleetUsageStatisticsTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayFleetUsageStatisticsTest.php @@ -89,3 +89,62 @@ it('summarizes fleet usage statistics for the dashboard landing view', function 'disk_usage_pct_avg' => 67, ]); }); + +it('derives fleet usage directly from cached gateway row summaries', function (): void { + $summary = edge_gateway_manager::summarizeFleetUsageFromGatewayRows([ + [ + 'id' => 701, + 'department_id' => 1, + 'status' => edge_gateway_manager::STATUS_ONLINE, + 'version_drift' => ['is_drifted' => true], + 'channel_status' => ['broker' => ['connected' => true]], + 'active_operation' => ['id' => 91, 'type' => 'DISCOVERY'], + 'recent_operations_summary' => ['pending' => 1, 'in_progress' => 1], + 'backlog_depth' => ['operations' => 2, 'commands' => 3], + 'metadata' => [ + 'system_metrics' => [ + 'latency_ms' => 184, + 'cpu_usage_pct' => 27, + 'memory_usage_pct' => 61, + 'disk_usage_pct' => 58, + ], + ], + 'inventory_summary' => ['total' => 2, 'online' => 1, 'offline' => 1], + 'binding_summary' => ['total' => 3, 'fallback_overrides' => 2], + 'fallback_summary' => ['cloud_only_relays' => 1, 'local_only_relays' => 1], + ], + [ + 'id' => 702, + 'department_id' => 2, + 'status' => edge_gateway_manager::STATUS_OFFLINE, + 'version_drift' => ['is_drifted' => false], + 'channel_status' => ['broker' => ['connected' => false]], + 'active_operation' => null, + 'recent_operations_summary' => ['pending' => 0, 'in_progress' => 0], + 'backlog_depth' => ['operations' => 0, 'commands' => 1], + 'metadata' => [ + 'system_metrics' => [ + 'latency_ms' => 412, + 'cpu_usage_pct' => 9, + 'memory_usage_pct' => 42, + 'disk_usage_pct' => 76, + ], + ], + 'inventory_summary' => ['total' => 1, 'online' => 1, 'offline' => 0], + 'binding_summary' => ['total' => 0, 'fallback_overrides' => 0], + 'fallback_summary' => ['cloud_only_relays' => 0, 'local_only_relays' => 0], + ], + ]); + + expect($summary['inventory'])->toBe([ + 'total' => 3, + 'online' => 2, + 'offline' => 1, + ]); + expect($summary['bindings'])->toBe([ + 'total' => 3, + 'fallback_overrides' => 2, + 'cloud_only' => 1, + 'local_only' => 1, + ]); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayRouteWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayRouteWiringTest.php index 2e2c406f..4de93332 100644 --- a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayRouteWiringTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayRouteWiringTest.php @@ -14,6 +14,7 @@ it('registers the v2 operator-facing edge gateway routes', function (): void { expect($route)->toContain("'/edge-gateways/{id}/rotate-credentials'"); expect($route)->toContain("'/departments/{id}/gateway-cutover'"); expect($route)->toContain("add_meta('fleet_usage'"); + expect($route)->toContain('listGatewaysWithFleetUsage('); expect($route)->not->toContain("'/edge-gateways/{id}/shell-sessions'"); expect($route)->not->toContain('private function requirePermission'); expect($route)->not->toContain('private function requireDepartmentAccess'); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayUpdateLifecycleTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayUpdateLifecycleTest.php index bfc0a2c6..99a53539 100644 --- a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayUpdateLifecycleTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayUpdateLifecycleTest.php @@ -14,7 +14,11 @@ it('builds the installer around the compose stack artifacts and management polli expect($managerSource)->toContain('fetch_http "Download compose stack service unit" "__STACK_SERVICE_URL__" "$INSTALL_DIR/truckwash-edge-gateway-stack.service"'); expect($managerSource)->toContain('log_error "Request: GET ${url}"'); expect($managerSource)->toContain('log_error "Response body preview (first 400 bytes):"'); - expect($managerSource)->toContain('apt-get install -y curl ca-certificates docker.io docker-compose-plugin php-cli php-curl php-mbstring php-sqlite3'); + expect($managerSource)->toContain('run_step "Installing base packages" apt-get install -y curl ca-certificates docker.io php-cli php-curl php-mbstring php-sqlite3'); + expect($managerSource)->toContain('run_step "Installing Docker Compose runtime" install_compose_runtime'); + expect($managerSource)->toContain('apt-get install -y docker-compose-plugin'); + expect($managerSource)->toContain('apt-get install -y docker-compose'); + expect($managerSource)->toContain('Unable to install Docker Compose using docker-compose-plugin or docker-compose.'); expect($managerSource)->toContain('Existing claimed gateway detected; reinstall will reuse saved gateway credentials.'); expect($managerSource)->toContain('run_step "Writing agent config" merge_agent_config "$CONFIG_TEMPLATE_PATH" "$CONFIG_PATH"'); expect($managerSource)->toContain('systemctl enable truckwash-edge-gateway-stack.service'); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayViewCacheTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayViewCacheTest.php new file mode 100644 index 00000000..39026323 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayViewCacheTest.php @@ -0,0 +1,218 @@ + */ + public array $store = []; + + public function get(string $key): ?string + { + return $this->store[$key] ?? null; + } + + public function setEx(string $key, string $value, int $ttl): void + { + $this->store[$key] = $value; + } + + public function clear_keys(string $pattern): void + { + $regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/'; + foreach (array_keys($this->store) as $key) { + if (preg_match($regex, $key) === 1) { + unset($this->store[$key]); + } + } + } + } +} + +beforeEach(function (): void { + $this->oldTtl = getenv('EDGE_GATEWAY_VIEW_CACHE_TTL'); + $this->redis = new EdgeGatewayViewCacheRedisFake(); + edge_gateway_view_cache::setAdapterForTests($this->redis); +}); + +afterEach(function (): void { + edge_gateway_view_cache::setAdapterForTests(null); + + if ($this->oldTtl === false) { + putenv('EDGE_GATEWAY_VIEW_CACHE_TTL'); + return; + } + + putenv('EDGE_GATEWAY_VIEW_CACHE_TTL=' . $this->oldTtl); +}); + +function edgeGatewayCacheGatewayRow( + int $gatewayId, + int $departmentId, + string $status, + array $inventorySummary = ['total' => 0, 'online' => 0, 'offline' => 0], + array $bindingSummary = ['total' => 0, 'fallback_overrides' => 0], + array $fallbackSummary = ['cloud_only_relays' => 0, 'local_only_relays' => 0] +): array { + return [ + 'id' => $gatewayId, + 'department_id' => $departmentId, + 'label' => 'Gateway ' . $gatewayId, + 'status' => $status, + 'version_drift' => ['is_drifted' => false], + 'channel_status' => ['broker' => ['connected' => $status === edge_gateway_manager::STATUS_ONLINE]], + 'active_operation' => null, + 'recent_operations_summary' => ['pending' => 0, 'in_progress' => 0], + 'backlog_depth' => ['operations' => 0, 'commands' => 0], + 'metadata' => [ + 'system_metrics' => [ + 'latency_ms' => 100, + 'cpu_usage_pct' => 10, + 'memory_usage_pct' => 20, + 'disk_usage_pct' => 30, + ], + ], + 'inventory_summary' => $inventorySummary, + 'binding_summary' => $bindingSummary, + 'fallback_summary' => $fallbackSummary, + 'inventory' => [], + 'bindings' => [], + 'recent_commands' => [], + 'audit_logs' => [], + 'operations' => [], + ]; +} + +it('uses sane ttl defaults and deterministic cache keys', function (): void { + putenv('EDGE_GATEWAY_VIEW_CACHE_TTL'); + expect(edge_gateway_view_cache::getTtl())->toBe(15); + + putenv('EDGE_GATEWAY_VIEW_CACHE_TTL=25'); + expect(edge_gateway_view_cache::getTtl())->toBe(25); + + putenv('EDGE_GATEWAY_VIEW_CACHE_TTL=-5'); + expect(edge_gateway_view_cache::getTtl())->toBe(0); + + expect(edge_gateway_view_cache::listKey(null, false))->toBe('edge_gateway:view:v1:list:department:all:detail:0'); + expect(edge_gateway_view_cache::detailKey(701))->toBe('edge_gateway:view:v1:detail:701'); +}); + +it('stores and retrieves cached list and detail payloads', function (): void { + $gateway = edgeGatewayCacheGatewayRow(701, 1, edge_gateway_manager::STATUS_ONLINE, ['total' => 2, 'online' => 1, 'offline' => 1]); + $payload = [ + 'gateways' => [$gateway], + 'fleet_usage' => edge_gateway_manager::summarizeFleetUsageFromGatewayRows([$gateway]), + ]; + + edge_gateway_view_cache::storeListPayload(null, false, $payload, 30); + edge_gateway_view_cache::storeDetailPayload(701, $gateway, 30); + + expect(edge_gateway_view_cache::getListPayload(null, false))->toBe($payload); + expect(edge_gateway_view_cache::getDetailPayload(701))->toBe($gateway); +}); + +it('syncs gateway snapshots into cached list payloads and refreshes fleet usage', function (): void { + $staleGateway = edgeGatewayCacheGatewayRow( + 701, + 1, + edge_gateway_manager::STATUS_OFFLINE, + ['total' => 1, 'online' => 0, 'offline' => 1], + ['total' => 1, 'fallback_overrides' => 0], + ['cloud_only_relays' => 0, 'local_only_relays' => 0] + ); + $otherGateway = edgeGatewayCacheGatewayRow( + 702, + 2, + edge_gateway_manager::STATUS_ONLINE, + ['total' => 1, 'online' => 1, 'offline' => 0], + ['total' => 1, 'fallback_overrides' => 1], + ['cloud_only_relays' => 1, 'local_only_relays' => 0] + ); + + edge_gateway_view_cache::storeListPayload(null, false, [ + 'gateways' => [$staleGateway, $otherGateway], + 'fleet_usage' => edge_gateway_manager::summarizeFleetUsageFromGatewayRows([$staleGateway, $otherGateway]), + ]); + edge_gateway_view_cache::storeListPayload(1, false, [ + 'gateways' => [$staleGateway], + 'fleet_usage' => edge_gateway_manager::summarizeFleetUsageFromGatewayRows([$staleGateway]), + ]); + + $freshGateway = [ + 'id' => 701, + 'department_id' => 1, + 'label' => 'Gateway 701', + 'status' => edge_gateway_manager::STATUS_ONLINE, + 'version_drift' => ['is_drifted' => false], + 'channel_status' => ['broker' => ['connected' => true]], + 'active_operation' => null, + 'recent_operations_summary' => ['pending' => 0, 'in_progress' => 0], + 'backlog_depth' => ['operations' => 0, 'commands' => 0], + 'metadata' => [ + 'system_metrics' => [ + 'latency_ms' => 150, + 'cpu_usage_pct' => 15, + 'memory_usage_pct' => 25, + 'disk_usage_pct' => 35, + ], + ], + 'inventory' => [ + ['id' => 1, 'online' => true], + ['id' => 2, 'online' => true], + ], + 'bindings' => [ + ['id' => 1, 'fallback_mode' => edge_gateway_manager::RELAY_FALLBACK_PREFER_LOCAL], + ['id' => 2, 'fallback_mode' => edge_gateway_manager::RELAY_FALLBACK_CLOUD_ONLY], + ], + 'recent_commands' => [], + 'audit_logs' => [], + 'operations' => [], + 'fallback_summary' => ['cloud_only_relays' => 1, 'local_only_relays' => 0], + ]; + + edge_gateway_view_cache::syncGateway($freshGateway); + + $allGatewaysPayload = edge_gateway_view_cache::getListPayload(null, false); + $departmentPayload = edge_gateway_view_cache::getListPayload(1, false); + $detailPayload = edge_gateway_view_cache::getDetailPayload(701); + + expect($detailPayload)->not->toBeNull(); + expect($detailPayload['inventory_summary'])->toBe(['total' => 2, 'online' => 2, 'offline' => 0]); + expect($detailPayload['binding_summary'])->toBe(['total' => 2, 'fallback_overrides' => 1]); + + expect($allGatewaysPayload)->not->toBeNull(); + expect($allGatewaysPayload['gateways'][0]['status'])->toBe(edge_gateway_manager::STATUS_ONLINE); + expect($allGatewaysPayload['gateways'][0]['inventory'])->toBe([]); + expect($allGatewaysPayload['fleet_usage']['gateways']['online'])->toBe(2); + expect($allGatewaysPayload['fleet_usage']['inventory']['online'])->toBe(3); + expect($allGatewaysPayload['fleet_usage']['bindings']['cloud_only'])->toBe(2); + + expect($departmentPayload)->not->toBeNull(); + expect($departmentPayload['gateways'][0]['status'])->toBe(edge_gateway_manager::STATUS_ONLINE); + expect($departmentPayload['fleet_usage']['inventory']['online'])->toBe(2); +}); + +it('removes deleted gateways from cached list and detail payloads', function (): void { + $gatewayA = edgeGatewayCacheGatewayRow(701, 1, edge_gateway_manager::STATUS_ONLINE); + $gatewayB = edgeGatewayCacheGatewayRow(702, 1, edge_gateway_manager::STATUS_OFFLINE); + $payload = [ + 'gateways' => [$gatewayA, $gatewayB], + 'fleet_usage' => edge_gateway_manager::summarizeFleetUsageFromGatewayRows([$gatewayA, $gatewayB]), + ]; + + edge_gateway_view_cache::storeListPayload(null, false, $payload); + edge_gateway_view_cache::storeListPayload(1, false, $payload); + edge_gateway_view_cache::storeDetailPayload(701, $gatewayA); + + edge_gateway_view_cache::removeGateway(701, 1); + + expect(edge_gateway_view_cache::getDetailPayload(701))->toBeNull(); + expect(edge_gateway_view_cache::getListPayload(null, false)['gateways'])->toHaveCount(1); + expect(edge_gateway_view_cache::getListPayload(1, false)['gateways'])->toHaveCount(1); + expect(edge_gateway_view_cache::getListPayload(null, false)['fleet_usage']['gateways']['total'])->toBe(1); +});