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/containers-form-submit.js b/php/public/containers-form-submit.js index 1382bced..2c8e0e63 100644 --- a/php/public/containers-form-submit.js +++ b/php/public/containers-form-submit.js @@ -1,4 +1,9 @@ document.addEventListener("DOMContentLoaded", function () { + // Don't run if the expected form isn't present. + if (document.getElementById('options-form') === null) { + return; + } + // Hide submit button initially const optionsFormSubmit = document.querySelectorAll(".options-form-submit"); optionsFormSubmit.forEach(element => { diff --git a/php/public/forms.js b/php/public/forms.js index 3adc3997..6b982b0d 100644 --- a/php/public/forms.js +++ b/php/public/forms.js @@ -70,15 +70,24 @@ function showPassword(id) { } form.onsubmit = submit; - console.info(form); } function initForms() { const forms = document.querySelectorAll('form.xhr') - console.info("Making " + forms.length + " form(s) use XHR."); 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..8eeba013 100644 --- a/php/public/toggle-dark-mode.js +++ b/php/public/toggle-dark-mode.js @@ -2,28 +2,26 @@ 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 - const themeIcon = document.getElementById('theme-icon'); - themeIcon.textContent = newTheme === 'dark' ? '☀️' : '🌙'; // Switch between moon and sun icons + setThemeIcon(newTheme); } -// 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 - } +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 getSavedTheme() { + return localStorage.getItem('theme') ?? ''; } // Function to apply theme-icon update -function setThemeIcon() { - const savedTheme = localStorage.getItem('theme'); - if (savedTheme === 'dark') { +function setThemeIcon(theme) { + if (theme === 'dark') { document.getElementById('theme-icon').textContent = '☀️'; // Sun icon for dark mode } else { document.getElementById('theme-icon').textContent = '🌙'; // Moon icon for light mode @@ -31,7 +29,7 @@ function setThemeIcon() { } // Immediately apply the saved theme to avoid flickering -applySavedThemeImmediately(); +setThemeToDOM(getSavedTheme()); // Apply theme when the page loads -document.addEventListener('DOMContentLoaded', setThemeIcon); +document.addEventListener('DOMContentLoaded', () => setThemeIcon(getSavedTheme())); diff --git a/php/src/Controller/DockerController.php b/php/src/Controller/DockerController.php index 9564b7c2..a8d1b31b 100644 --- a/php/src/Controller/DockerController.php +++ b/php/src/Controller/DockerController.php @@ -3,12 +3,14 @@ declare(strict_types=1); 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'; @@ -20,12 +22,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 @@ -37,9 +39,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); } @@ -198,17 +200,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 @@ -216,7 +238,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 { @@ -308,4 +330,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 d6a52d93..2d3c9b7b 100644 --- a/php/src/Docker/DockerActionManager.php +++ b/php/src/Docker/DockerActionManager.php @@ -166,9 +166,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()); @@ -473,8 +476,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) { @@ -502,6 +504,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 07073c65..47b67ebf 100644 --- a/php/templates/containers.twig +++ b/php/templates/containers.twig @@ -27,7 +27,7 @@ {# js for optional containers and additional containers forms #} - + {% set hasBackupLocation = borg_backup_host_location or borg_remote_repo %} {% set isAnyRunning = false %} @@ -339,7 +339,7 @@ {% else %} {% if was_start_button_clicked == false %} -
+ @@ -356,7 +356,7 @@
{% else %} -
+ diff --git a/php/templates/layout.twig b/php/templates/layout.twig index 79c615d9..eb7467c2 100644 --- a/php/templates/layout.twig +++ b/php/templates/layout.twig @@ -1,10 +1,10 @@ AIO - + - - + + @@ -13,6 +13,7 @@
+