diff --git a/php/public/scroll-into-view.js b/php/public/scroll-into-view.js index 2c676911..3e4b9574 100644 --- a/php/public/scroll-into-view.js +++ b/php/public/scroll-into-view.js @@ -4,6 +4,10 @@ const observer = new MutationObserver((records) => { // function being present. if (node && typeof(node.scrollIntoView) === 'function') { node.scrollIntoView(); + if (node.classList.contains('progress-indicator')) { + node.previousSibling.append('.'); + node.remove(); + } } }); observer.observe(document, {childList: true, subtree: true}); diff --git a/php/src/Controller/DockerController.php b/php/src/Controller/DockerController.php index 1e50278a..089256b9 100644 --- a/php/src/Controller/DockerController.php +++ b/php/src/Controller/DockerController.php @@ -420,7 +420,7 @@ readonly class DockerController { - + @@ -428,6 +428,11 @@ readonly class DockerController { } private function startStreamingResponse(Response $response) : Response { + // Ensure the script keeps running even if the client connection drops (e.g. due to a + // reverse proxy read timeout during a long image pull). Without this, PHP would abort + // on the first write after the connection is gone, leaving only some containers started. + ignore_user_abort(true); + $nonbufResp = $response ->withBody(new NonBufferedBody()) ->withHeader('Content-Type', 'text/html; charset=utf-8') @@ -448,12 +453,19 @@ readonly class DockerController { // if it'll actually pull an image), but which should not need to know anything about the // wanted markup or formatting. $addToStreamingResponseBody = function (string $message, ?Container $container = null) use ($nonbufResp) : void { - // Strip ANSI codes. - $message = preg_replace('/\e[[][A-Za-z0-9];?[0-9]*m?/', '', $message); - if ($container) { - $message = "{$container->displayName}: {$message}"; + // If the message is a single dot we treat it as a progress indicator and send a specific, empty + // HTML element, which gets special treatment by the Javascript code. + if ($message === '.') { + $html = ""; + } else { + // Strip ANSI codes. If the operation fails, use the unchanged $message as fallback. + $text = preg_replace('/\e[[][A-Za-z0-9];?[0-9]*m?/', '', $message) ?? $message; + if ($container) { + $text = sprintf("%s: %s", $container->displayName, $text); + } + $html = sprintf("
%s
", htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')); } - $nonbufResp->getBody()->write("
" . htmlspecialchars("{$message}", ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "
"); + $nonbufResp->getBody()->write($html); }; return $addToStreamingResponseBody; diff --git a/php/src/Docker/DockerActionManager.php b/php/src/Docker/DockerActionManager.php index 173e4d5f..539f01db 100644 --- a/php/src/Docker/DockerActionManager.php +++ b/php/src/Docker/DockerActionManager.php @@ -11,12 +11,13 @@ use AIO\Data\ConfigurationManager; use AIO\Data\DataConst; use AIO\Helper\NetworkHelper; use GuzzleHttp\Client; -use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Psr7\Utils; +use GuzzleHttp\Exception\RequestException; use http\Env\Response; readonly class DockerActionManager { private const string API_VERSION = 'v1.44'; + private const int PULL_HEARTBEAT_INTERVAL_SECONDS = 4; private Client $guzzleClient; public function __construct( @@ -570,10 +571,43 @@ readonly class DockerActionManager { $maxRetries = 3; for ($attempt = 1; $attempt <= $maxRetries; $attempt++) { try { - $this->sendHttpRequest('POST', $url); + // Use streaming so we can write heartbeat messages to the response while the + // image is being pulled. Without this, a long pull produces no output and a + // reverse proxy (nginx) can drop the connection after its read timeout expires. + // Once the connection is gone, PHP aborts on the next write and all consecutive + // containers are never started. + $pullResponse = $this->sendHttpRequest('POST', $url, ['stream' => true]); + $pullBody = $pullResponse->getBody(); + $pullErrors = []; + $lastHeartbeat = time(); + while (!$pullBody->eof()) { + $line = Utils::readLine($pullBody); + $event = json_decode($line, true); + if (!is_array($event)) { + continue; + } + if (isset($event['error'])) { + $pullErrors[] = $event['error']; + } elseif ($addToStreamingResponseBody !== null) { + // Write a heartbeat at most once every 5 seconds so the reverse + // proxy sees continuous data and does not close the connection. + $now = time(); + $interval = time() - $lastHeartbeat; + if ($interval >= self::PULL_HEARTBEAT_INTERVAL_SECONDS) { + $addToStreamingResponseBody(".", $container); + $lastHeartbeat = $now; + } + } + } + if ($pullErrors !== []) { + throw new \Exception(implode('; ', $pullErrors)); + } break; - } catch (RequestException $e) { - $message = "Could not pull image " . $imageName . " (attempt $attempt/$maxRetries): " . $e->getResponse()?->getBody()->getContents(); + } catch (\Exception $e) { + $errorDetails = $e instanceof RequestException + ? $e->getResponse()?->getBody()->getContents() + : $e->getMessage(); + $message = "Could not pull image " . $imageName . " (attempt $attempt/$maxRetries): " . $errorDetails; if ($attempt === $maxRetries) { if ($imageIsThere === false) { throw new \Exception($message);