Load container status into iframe as streamed response

Signed-off-by: Pablo Zmdl <pablo@nextcloud.com>
This commit is contained in:
Pablo Zmdl
2026-02-11 15:54:41 +01:00
parent dd989ee87f
commit bf2d9ff394
10 changed files with 257 additions and 28 deletions

View File

@@ -2,12 +2,14 @@
namespace AIO\Controller;
use AIO\Container\Container;
use AIO\Container\ContainerState;
use AIO\ContainerDefinitionFetcher;
use AIO\Docker\DockerActionManager;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use AIO\Data\ConfigurationManager;
use Slim\Psr7\NonBufferedBody;
readonly class DockerController {
private const string TOP_CONTAINER = 'nextcloud-aio-apache';
@@ -19,12 +21,12 @@ readonly class DockerController {
) {
}
private function PerformRecursiveContainerStart(string $id, bool $pullImage = true) : void {
private function PerformRecursiveContainerStart(string $id, bool $pullImage = true, ?\Closure $addToStreamingResponseBody = null) : void {
$container = $this->containerDefinitionFetcher->GetContainerById($id);
// Start all dependencies first and then itself
foreach($container->dependsOn as $dependency) {
$this->PerformRecursiveContainerStart($dependency, $pullImage);
$this->PerformRecursiveContainerStart($dependency, $pullImage, $addToStreamingResponseBody);
}
// Don't start if container is already running
@@ -36,9 +38,9 @@ readonly class DockerController {
$this->dockerActionManager->DeleteContainer($container);
$this->dockerActionManager->CreateVolumes($container);
$this->dockerActionManager->PullImage($container, $pullImage);
$this->dockerActionManager->PullImage($container, $pullImage, $addToStreamingResponseBody);
$this->dockerActionManager->CreateContainer($container);
$this->dockerActionManager->StartContainer($container);
$this->dockerActionManager->StartContainer($container, $addToStreamingResponseBody);
$this->dockerActionManager->ConnectContainerToNetwork($container);
}
@@ -197,17 +199,37 @@ readonly class DockerController {
if ($pullImage === false) {
error_log('WARNING: Not pulling container images. Instead, using local ones.');
}
$nonbufResp = $response
->withBody(new NonBufferedBody())
->withHeader('Content-Type', 'text/html; charset=utf-8')
->withHeader('X-Accel-Buffering', 'no')
->withHeader('Cache-Control', 'no-cache');
// Text written into this body is immediately sent to the client, without waiting for later content.
$streamingResponseBody = $nonbufResp->getBody();
$streamingResponseBody->write($this->getStreamingResponseHtmlStart());
// Create a closure to pass around to the code, which should to the logging (because it e.g. decides
// if it'll actually pull an image), but which should not need to know anything about the
// wanted markup or formatting.
$addToStreamingResponseBody = function (Container $container, string $message) use ($streamingResponseBody) : void {
$streamingResponseBody->write("<div>{$container->displayName}: {$message}</div>");
};
// Start container
$this->startTopContainer($pullImage);
$this->startTopContainer($pullImage, $addToStreamingResponseBody);
// Clear apcu cache in order to check if container updates are available
// Temporarily disabled as it leads much faster to docker rate limits
// apcu_clear_cache();
return $response->withStatus(201)->withHeader('Location', '.');
$streamingResponseBody->write($this->getStreamingResponseHtmlEnd());
return $nonbufResp;
}
public function startTopContainer(bool $pullImage) : void {
public function startTopContainer(bool $pullImage, ?\Closure $addToStreamingResponseBody = null) : void {
$this->configurationManager->aioToken = bin2hex(random_bytes(24));
// Stop domaincheck since apache would not be able to start otherwise
@@ -215,7 +237,7 @@ readonly class DockerController {
$id = self::TOP_CONTAINER;
$this->PerformRecursiveContainerStart($id, $pullImage);
$this->PerformRecursiveContainerStart($id, $pullImage, $addToStreamingResponseBody);
}
public function StartWatchtowerContainer(Request $request, Response $response, array $args) : Response {
@@ -307,4 +329,31 @@ readonly class DockerController {
$id = 'nextcloud-aio-domaincheck';
$this->PerformRecursiveContainerStop($id);
}
private function getStreamingResponseHtmlStart() : string {
return <<<END
<!DOCTYPE html>
<html lang="en" class="overlay-iframe">
<head>
<link rel="stylesheet" href="../../style.css?v7" media="all" />
<script>
const observer = new MutationObserver((records) => {
const node = records[0]?.addedNodes[0];
// Text nodes also appear here but can't be scrolled to, so we have to check for the
// function being present.
if (node && typeof(node.scrollIntoView) === 'function') {
node.scrollIntoView();
}
});
observer.observe(document, {childList: true, subtree: true});
</script>
</head>
<body>
END;
}
private function getStreamingResponseHtmlEnd() : string {
return "\n </body>\n</html>";
}
}

View File

@@ -165,9 +165,12 @@ readonly class DockerActionManager {
return $response;
}
public function StartContainer(Container $container): void {
public function StartContainer(Container $container, ?\Closure $addToStreamingResponseBody = null): void {
$url = $this->BuildApiUrl(sprintf('containers/%s/start', urlencode($container->identifier)));
try {
if ($addToStreamingResponseBody !== null) {
$addToStreamingResponseBody($container, "Starting container");
}
$this->guzzleClient->post($url);
} catch (RequestException $e) {
throw new \Exception("Could not start container " . $container->identifier . ": " . $e->getResponse()?->getBody()->getContents());
@@ -472,8 +475,7 @@ readonly class DockerActionManager {
}
}
public function PullImage(Container $container, bool $pullImage = true): void {
public function PullImage(Container $container, bool $pullImage = true, ?\Closure $addToStreamingResponseBody = null): void {
// Skip database image pull if the last shutdown was not clean
if ($container->identifier === 'nextcloud-aio-database') {
if ($this->GetDatabasecontainerExitCode() > 0) {
@@ -501,6 +503,9 @@ readonly class DockerActionManager {
$url = $this->BuildApiUrl(sprintf('images/create?fromImage=%s', $encodedImageName));
$imageIsThere = true;
try {
if ($addToStreamingResponseBody) {
$addToStreamingResponseBody($container, "Pulling image");
}
$imageUrl = $this->BuildApiUrl(sprintf('images/%s/json', $encodedImageName));
$this->guzzleClient->get($imageUrl)->getBody()->getContents();
} catch (\Throwable $e) {