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 769d5e9344
commit 43dc0769b2
8 changed files with 190 additions and 28 deletions

View file

@ -21,6 +21,11 @@ Listen 8080 https
<FilesMatch "\.php$">
SetHandler "proxy:fcgi://127.0.0.1:9000"
</FilesMatch>
# Disable output buffering to enable streaming responses.
<Proxy "fcgi://127.0.0.1:9000/" enablereuse=on flushpackets=on max=10>
</Proxy>
# Master dir
DocumentRoot /var/www/docker-aio/php/public/
<Directory /var/www/docker-aio/php/public/>

View file

@ -16,7 +16,8 @@
"http-interop/http-factory-guzzle": "^1.2",
"slim/twig-view": "^3.3",
"slim/csrf": "^1.3",
"ext-apcu": "*"
"ext-apcu": "*",
"slim/psr7": "^1.8"
},
"require-dev": {
"sserbin/twig-linter": "@dev",

139
php/composer.lock generated
View file

@ -4,8 +4,64 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "19598625395cc28e64f15d2719f8f98f",
"content-hash": "a47e950885b06f1a4b631a1eea56c57e",
"packages": [
{
"name": "fig/http-message-util",
"version": "1.1.5",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-message-util.git",
"reference": "9d94dc0154230ac39e5bf89398b324a86f63f765"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-message-util/zipball/9d94dc0154230ac39e5bf89398b324a86f63f765",
"reference": "9d94dc0154230ac39e5bf89398b324a86f63f765",
"shasum": ""
},
"require": {
"php": "^5.3 || ^7.0 || ^8.0"
},
"suggest": {
"psr/http-message": "The package containing the PSR-7 interfaces"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.1.x-dev"
}
},
"autoload": {
"psr-4": {
"Fig\\Http\\Message\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Utility classes and constants for use with PSR-7 (psr/http-message)",
"keywords": [
"http",
"http-message",
"psr",
"psr-7",
"request",
"response"
],
"support": {
"issues": "https://github.com/php-fig/http-message-util/issues",
"source": "https://github.com/php-fig/http-message-util/tree/1.1.5"
},
"time": "2020-11-24T22:02:12+00:00"
},
{
"name": "guzzlehttp/guzzle",
"version": "7.10.0",
@ -1146,6 +1202,85 @@
},
"time": "2025-11-02T14:58:28+00:00"
},
{
"name": "slim/psr7",
"version": "1.8.0",
"source": {
"type": "git",
"url": "https://github.com/slimphp/Slim-Psr7.git",
"reference": "76e7e3b1cdfd583e9035c4c966c08e01e45ce959"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/slimphp/Slim-Psr7/zipball/76e7e3b1cdfd583e9035c4c966c08e01e45ce959",
"reference": "76e7e3b1cdfd583e9035c4c966c08e01e45ce959",
"shasum": ""
},
"require": {
"fig/http-message-util": "^1.1.5",
"php": "^8.0",
"psr/http-factory": "^1.1",
"psr/http-message": "^1.0 || ^2.0",
"ralouphie/getallheaders": "^3.0"
},
"provide": {
"psr/http-factory-implementation": "^1.0",
"psr/http-message-implementation": "^1.0 || ^2.0"
},
"require-dev": {
"adriansuter/php-autoload-override": "^1.5|| ^2.0",
"ext-json": "*",
"http-interop/http-factory-tests": "^1.0 || ^2.0",
"php-http/psr7-integration-tests": "^1.5",
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^9.6 || ^10",
"squizlabs/php_codesniffer": "^3.13"
},
"type": "library",
"autoload": {
"psr-4": {
"Slim\\Psr7\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Josh Lockhart",
"email": "hello@joshlockhart.com",
"homepage": "https://joshlockhart.com"
},
{
"name": "Andrew Smith",
"email": "a.smith@silentworks.co.uk",
"homepage": "https://silentworks.co.uk"
},
{
"name": "Rob Allen",
"email": "rob@akrabat.com",
"homepage": "https://akrabat.com"
},
{
"name": "Pierre Berube",
"email": "pierre@lgse.com",
"homepage": "https://www.lgse.com"
}
],
"description": "Strict PSR-7 implementation",
"homepage": "https://www.slimframework.com",
"keywords": [
"http",
"psr-7",
"psr7"
],
"support": {
"issues": "https://github.com/slimphp/Slim-Psr7/issues",
"source": "https://github.com/slimphp/Slim-Psr7/tree/1.8.0"
},
"time": "2025-11-02T17:51:19+00:00"
},
{
"name": "slim/slim",
"version": "4.15.1",
@ -4813,5 +4948,5 @@
"ext-apcu": "*"
},
"platform-dev": {},
"plugin-api-version": "2.9.0"
"plugin-api-version": "2.6.0"
}

View file

@ -80,6 +80,17 @@ function showPassword(id) {
for (const form of forms) {
initForm(form);
}
const overlayLogForms = document.querySelectorAll('form[target="overlay-log"]')
for (const form of overlayLogForms) {
form.onsubmit = function() {
enableSpinner();
document.getElementById('overlay-log')?.classList.add('visible');
// Reload the page after the response was fully loaded into the iframe.
document.querySelector('iframe[name="overlay-log"]').addEventListener('load', () => {
location.reload();
});
};
}
}
if (document.readyState === 'loading') {

View file

@ -480,7 +480,7 @@ input[type="checkbox"]:disabled:not(:checked) + label {
#overlay #overlay-log.visible {
visibility: visible;
opacity: 1;
transition: opacity 500ms ease-in;
transition: opacity 1s ease-in;
}
#overlay #overlay-log {
@ -489,11 +489,12 @@ input[type="checkbox"]:disabled:not(:checked) + label {
position: absolute;
top: calc(50% + 120px);
width: 20%;
margin: 0 40%;
color: white;
background-color: rgba(0, 0, 0, 0.5);
padding: 2rem;
border-radius: 5px;
height: 5rem;
margin: 0 39%;
padding: 1rem 1.5rem;
border-radius: 10px;
border: solid thin rgb(192, 192, 192);
background-color: rgba(128, 128, 128);
}
#overlay #overlay-log div {

View file

@ -9,6 +9,7 @@ use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use AIO\Data\ConfigurationManager;
use AIO\Data\DataConst;
use Slim\Psr7\NonBufferedBody;
readonly class DockerController {
private const string TOP_CONTAINER = 'nextcloud-aio-apache';
@ -20,12 +21,12 @@ readonly class DockerController {
) {
}
private function PerformRecursiveContainerStart(string $id, bool $pullImage = true) : void {
private function PerformRecursiveContainerStart(string $id, bool $pullImage = true, ?NonBufferedBody $body = 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, $body);
}
// Don't start if container is already running
@ -35,22 +36,18 @@ readonly class DockerController {
return;
}
// Emit a container-start event for frontend clients (one JSON line per event)
try {
$this->pruneEventsFileIfTooLarge();
$this->writeEventsToFile(['event' => 'Starting container', 'name' => $id, 'time' => time()]);
} catch (\Throwable $e) {
// non-fatal, just log
error_log('Could not write container-start event: ' . $e->getMessage());
}
$this->dockerActionManager->DeleteContainer($container);
$this->dockerActionManager->CreateVolumes($container);
if ($body) {
$body->write("<div>{$container->displayName}: Pulling image</div>");
}
$this->dockerActionManager->PullImage($container, $pullImage);
if ($body) {
$body->write("<div>{$container->displayName}: Starting container</div>");
}
$this->dockerActionManager->CreateContainer($container);
$this->dockerActionManager->StartContainer($container);
$this->dockerActionManager->ConnectContainerToNetwork($container);
$container->logEvent('Container is running');
}
private function PerformRecursiveImagePull(string $id) : void {
@ -208,17 +205,29 @@ 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.
$body = $nonbufResp->getBody();
$body->write("<!DOCTYPE html><html lang='en'><head><style>body { color: white; }</style><script>const observer = new MutationObserver((records) => records[0].addedNodes[0].scrollIntoView()); observer.observe(document, {childList: true, subtree: true});</script></head><body>");
// Start container
$this->startTopContainer($pullImage);
$this->startTopContainer($pullImage, $body);
// 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', '.');
$body->write("</body></html>");
return $nonbufResp;
}
public function startTopContainer(bool $pullImage) : void {
public function startTopContainer(bool $pullImage, ?NonBufferedBody $body = null) : void {
$this->configurationManager->aioToken = bin2hex(random_bytes(24));
// Stop domaincheck since apache would not be able to start otherwise
@ -226,7 +235,7 @@ readonly class DockerController {
$id = self::TOP_CONTAINER;
$this->PerformRecursiveContainerStart($id, $pullImage);
$this->PerformRecursiveContainerStart($id, $pullImage, $body);
}
public function StartWatchtowerContainer(Request $request, Response $response, array $args) : Response {

View file

@ -338,7 +338,7 @@
</form>
{% else %}
{% if was_start_button_clicked == false %}
<form method="POST" action="api/docker/start" class="xhr">
<form method="POST" action="api/docker/start" target="overlay-log">
<input type="hidden" name="{{csrf.keys.name}}" value="{{csrf.name}}">
<input type="hidden" name="{{csrf.keys.value}}" value="{{csrf.value}}">
<input id="base_path" type="hidden" name="base_path" value="">
@ -355,7 +355,7 @@
<input type="submit" value="Start containers" />
</form>
{% else %}
<form method="POST" action="api/docker/start" class="xhr">
<form method="POST" action="api/docker/start" target="overlay-log">
<input type="hidden" name="{{csrf.keys.name}}" value="{{csrf.name}}">
<input type="hidden" name="{{csrf.keys.value}}" value="{{csrf.value}}">
<input id="base_path" type="hidden" name="base_path" value="">

View file

@ -14,7 +14,7 @@
</div>
<div id="overlay">
<div class="loader"></div>
<div id="overlay-log"></div>
<iframe name='overlay-log' id="overlay-log"></iframe>
</div>
<script src="overlay-log.js"></script>
<button id="theme-toggle" onclick="toggleTheme()">