diff --git a/Containers/mastercontainer/mastercontainer.conf b/Containers/mastercontainer/mastercontainer.conf
index 7d294694..d85fe1f3 100644
--- a/Containers/mastercontainer/mastercontainer.conf
+++ b/Containers/mastercontainer/mastercontainer.conf
@@ -21,6 +21,11 @@ Listen 8080 https
SetHandler "proxy:fcgi://127.0.0.1:9000"
+
+ # Disable output buffering to enable streaming responses.
+
+
+
# Master dir
DocumentRoot /var/www/docker-aio/php/public/
diff --git a/php/composer.json b/php/composer.json
index 892bdd5d..de8be9d8 100644
--- a/php/composer.json
+++ b/php/composer.json
@@ -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",
diff --git a/php/composer.lock b/php/composer.lock
index a1195b25..accd19b5 100644
--- a/php/composer.lock
+++ b/php/composer.lock
@@ -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",
@@ -4825,5 +4960,5 @@
"ext-apcu": "*"
},
"platform-dev": {},
- "plugin-api-version": "2.9.0"
+ "plugin-api-version": "2.6.0"
}
diff --git a/php/public/forms.js b/php/public/forms.js
index 3adc3997..e9e2b952 100644
--- a/php/public/forms.js
+++ b/php/public/forms.js
@@ -79,6 +79,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') {
diff --git a/php/public/style.css b/php/public/style.css
index b35883d0..2dd199f1 100644
--- a/php/public/style.css
+++ b/php/public/style.css
@@ -468,7 +468,29 @@ input[type="checkbox"]:disabled:not(:checked) + label {
}
#overlay.loading {
- display: block;
+ display: grid;
+ justify-items: center;
+ row-gap: 2rem;
+}
+
+#overlay #overlay-log.visible {
+ visibility: visible;
+ opacity: 1;
+ transition: opacity 1s ease-in;
+}
+
+#overlay #overlay-log {
+ visibility: hidden;
+ opacity: 0;
+ align-self: start;
+ width: 20%;
+ height: 7rem;
+ border-radius: var(--border-radius-large);
+ border: solid thin rgb(192, 192, 192);
+}
+
+.overlay-iframe {
+ padding: 1rem;
}
.loader {
@@ -479,9 +501,7 @@ input[type="checkbox"]:disabled:not(:checked) + label {
height: 120px;
-webkit-animation: spin 2s linear infinite; /* Safari */
animation: spin 2s linear infinite;
- position: absolute;
- top: calc(50% - 60px);
- left: calc(50% - 60px);
+ align-self: end;
}
/* Safari */
@@ -705,4 +725,4 @@ input[type="checkbox"]:disabled:not(:checked) + label {
.office-suite-cards {
grid-template-columns: 1fr;
}
-}
\ No newline at end of file
+}
diff --git a/php/public/toggle-dark-mode.js b/php/public/toggle-dark-mode.js
index 9df54287..b6770f2c 100644
--- a/php/public/toggle-dark-mode.js
+++ b/php/public/toggle-dark-mode.js
@@ -2,7 +2,7 @@
function toggleTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme');
const newTheme = (currentTheme === 'dark') ? '' : 'dark'; // Toggle between no theme and dark theme
- document.documentElement.setAttribute('data-theme', newTheme);
+ setThemeToDOM(newTheme);
localStorage.setItem('theme', newTheme);
// Change the icon based on the current theme
@@ -10,14 +10,16 @@ function toggleTheme() {
themeIcon.textContent = newTheme === 'dark' ? '☀️' : '🌙'; // Switch between moon and sun icons
}
+function setThemeToDOM(value) {
+ // Set the theme to the root document and all possible iframe documents (so they can adapt their styling, too).
+ const documents = [document, Array.from(document.querySelectorAll('iframe')).map((iframe) => iframe.contentDocument)].flat()
+ documents.forEach((doc) => doc.documentElement.setAttribute('data-theme', value));
+}
+
// Function to immediately apply saved theme without icon update
function applySavedThemeImmediately() {
- const savedTheme = localStorage.getItem('theme');
- if (savedTheme === 'dark') {
- document.documentElement.setAttribute('data-theme', 'dark');
- } else {
- document.documentElement.removeAttribute('data-theme'); // Default to light theme
- }
+ // Default to light theme
+ setThemeToDOM(localStorage.getItem('theme') ?? '');
}
// Function to apply theme-icon update
diff --git a/php/src/Controller/DockerController.php b/php/src/Controller/DockerController.php
index 81b920d0..6bbd3072 100644
--- a/php/src/Controller/DockerController.php
+++ b/php/src/Controller/DockerController.php
@@ -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("
{$container->displayName}: {$message}
");
+ };
+
// 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;
+ }
+
+ private function getStreamingResponseHtmlEnd() : string {
+ return "\n \n";
+ }
}
diff --git a/php/src/Docker/DockerActionManager.php b/php/src/Docker/DockerActionManager.php
index 86b36619..10277ae0 100644
--- a/php/src/Docker/DockerActionManager.php
+++ b/php/src/Docker/DockerActionManager.php
@@ -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) {
diff --git a/php/templates/containers.twig b/php/templates/containers.twig
index 8e437bc2..9bab741b 100644
--- a/php/templates/containers.twig
+++ b/php/templates/containers.twig
@@ -338,7 +338,7 @@
{% else %}
{% if was_start_button_clicked == false %}
-
{% else %}
-