From b51943d8a1494b1b30e85b0242fe85044b85b839 Mon Sep 17 00:00:00 2001 From: "Simon L." Date: Mon, 19 Jan 2026 16:26:28 +0100 Subject: [PATCH] aio-interface: show sub-steps for starting containers Signed-off-by: Simon L. --- php/public/index.php | 14 +++++ php/public/overlay-log.js | 27 +++++++++ php/src/Controller/DockerController.php | 80 +++++++++++++++++++++++++ php/src/Data/DataConst.php | 4 ++ php/templates/layout.twig | 2 + 5 files changed, 127 insertions(+) create mode 100644 php/public/overlay-log.js diff --git a/php/public/index.php b/php/public/index.php index 1ec42949..8b9c83b8 100644 --- a/php/public/index.php +++ b/php/public/index.php @@ -142,6 +142,20 @@ $app->get('/containers', function (Request $request, Response $response, array $ 'bypass_container_update' => $bypass_container_update, ]); })->setName('profile'); + +// Server-Sent Events endpoint for container events (container-start) +$app->get('/events/containers', function (Request $request, Response $response, array $args) use ($container) { + // Only allow authenticated sessions to access SSE + $authManager = $container->get(\AIO\Auth\AuthManager::class); + if (!$authManager->IsAuthenticated()) { + return $response->withStatus(401); + } + + // Delegate streaming logic to the DockerController + $dockerController = $container->get(\AIO\Controller\DockerController::class); + return $dockerController->StreamContainerEvents($response); +}); + $app->get('/login', function (Request $request, Response $response, array $args) use ($container) { $view = Twig::fromRequest($request); /** @var \AIO\Docker\DockerActionManager $dockerActionManager */ diff --git a/php/public/overlay-log.js b/php/public/overlay-log.js new file mode 100644 index 00000000..5b89dc4c --- /dev/null +++ b/php/public/overlay-log.js @@ -0,0 +1,27 @@ +document.addEventListener("DOMContentLoaded", function(event) { + function displayOverlayLogMessage(message) { + const overlayLogElement = document.getElementById('overlay-log'); + if (!overlayLogElement) { + return; + } + overlayLogElement.textContent = message; + } + + // Attempt to connect to Server-Sent Events at /events/containers and listen for 'container-start' events + if (typeof EventSource !== 'undefined') { + try { + const serverSentEventSource = new EventSource('events/containers'); + serverSentEventSource.addEventListener('container-start', function(serverSentEvent) { + try { + let parsedPayload = JSON.parse(serverSentEvent.data); + displayOverlayLogMessage(parsedPayload.name || serverSentEvent.data); + } catch (parseError) { + displayOverlayLogMessage(serverSentEvent.data); + } + }); + serverSentEventSource.onerror = function() { serverSentEventSource.close(); }; + } catch (connectionError) { + /* ignore if Server-Sent Events are not available */ + } + } +}); diff --git a/php/src/Controller/DockerController.php b/php/src/Controller/DockerController.php index 81b920d0..ef9748ad 100644 --- a/php/src/Controller/DockerController.php +++ b/php/src/Controller/DockerController.php @@ -8,6 +8,7 @@ use AIO\Docker\DockerActionManager; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use AIO\Data\ConfigurationManager; +use AIO\Data\DataConst; readonly class DockerController { private const string TOP_CONTAINER = 'nextcloud-aio-apache'; @@ -34,6 +35,15 @@ 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); $this->dockerActionManager->PullImage($container, $pullImage); @@ -261,6 +271,48 @@ readonly class DockerController { return $response->withStatus(201)->withHeader('Location', '.'); } + public function StreamContainerEvents(Response $response): Response { + $eventsFile = \AIO\Data\DataConst::GetContainerEventsFile(); + if (!file_exists($eventsFile)) { + @touch($eventsFile); + } + + $body = $response->getBody(); + $response = $response + ->withHeader('Content-Type', 'text/event-stream') + ->withHeader('Cache-Control', 'no-cache') + ->withHeader('Connection', 'keep-alive'); + + $fileHandle = fopen($eventsFile, 'r'); + if ($fileHandle === false) { + $body->write(''); + return $response; + } + + // Start at end of file so only new events are streamed + fseek($fileHandle, 0, SEEK_END); + + while (!connection_aborted()) { + clearstatcache(false, $eventsFile); + $line = fgets($fileHandle); + if ($line !== false) { + $data = trim($line); + // Write SSE event + $body->write("event: container-start\n"); + $body->write("data: $data\n\n"); + $body->flush(); + // Small pause to avoid tight loop + usleep(100000); + } else { + // No new data, wait a moment + usleep(200000); + } + } + + fclose($fileHandle); + return $response; + } + public function stopTopContainer() : void { $id = self::TOP_CONTAINER; $this->PerformRecursiveContainerStop($id); @@ -307,4 +359,32 @@ readonly class DockerController { $id = 'nextcloud-aio-domaincheck'; $this->PerformRecursiveContainerStop($id); } + + // Write container event to events file and prune old events + private function writeEventsToFile(array $payload): void { + $eventJson = json_encode($payload); + + // Append new event (atomic via LOCK_EX) + file_put_contents($eventsFile, $eventJson . PHP_EOL, FILE_APPEND | LOCK_EX); + } + + // Truncate the events file to keep only the last $maxBytes bytes, aligned to a newline boundary. + private function pruneEventsFileIfTooLarge(): void { + $eventsFile = DataConst::GetContainerEventsFile(); + $maxBytes = 512 * 1024; // 512 KB + $maxLines = 1000; // keep last 1000 events + + if (!file_exists($eventsFile) || filesize($eventsFile) <= $maxBytes) { + return; + } + + $lines = file($eventsFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + if ($lines !== false) { + $total = count($lines); + $start = max(0, $total - $maxLines); + $keep = array_slice($lines, $start); + // rewrite file with kept lines + file_put_contents($eventsFile, implode(PHP_EOL, $keep) . PHP_EOL, LOCK_EX); + } + } } diff --git a/php/src/Data/DataConst.php b/php/src/Data/DataConst.php index 9111a98a..ecb791b4 100644 --- a/php/src/Data/DataConst.php +++ b/php/src/Data/DataConst.php @@ -66,4 +66,8 @@ class DataConst { public static function GetContainersDefinitionPath() : string { return (string)realpath(__DIR__ . '/../../containers.json'); } + + public static function GetContainerEventsFile() : string { + return self::GetDataDirectory() . '/container_events.log'; + } } diff --git a/php/templates/layout.twig b/php/templates/layout.twig index 79c615d9..8c5a625b 100644 --- a/php/templates/layout.twig +++ b/php/templates/layout.twig @@ -13,7 +13,9 @@
+
+