From fbffdeed9f1d66e83f9c754dc64ce0ba512fd074 Mon Sep 17 00:00:00 2001 From: Pablo Zmdl Date: Thu, 5 Feb 2026 12:34:44 +0100 Subject: [PATCH] WIP: Poll for logged events Signed-off-by: Pablo Zmdl --- php/public/container_events_log_client.js | 122 ++++++++++++++++++ php/public/forms.js | 1 + php/public/index.php | 1 + php/public/style.css | 39 +++++- php/src/Container/Container.php | 8 ++ .../Controller/ContainerEventsController.php | 42 ++++++ php/src/Controller/DockerController.php | 1 + php/src/Data/ContainerEventsLog.php | 48 +++++++ php/src/Docker/DockerActionManager.php | 10 +- php/templates/components/container-state.twig | 5 +- php/templates/layout.twig | 1 + 11 files changed, 274 insertions(+), 4 deletions(-) create mode 100644 php/public/container_events_log_client.js create mode 100644 php/src/Controller/ContainerEventsController.php create mode 100644 php/src/Data/ContainerEventsLog.php diff --git a/php/public/container_events_log_client.js b/php/public/container_events_log_client.js new file mode 100644 index 00000000..8833be5c --- /dev/null +++ b/php/public/container_events_log_client.js @@ -0,0 +1,122 @@ +class ContainerEventsLogClient { + overlayElem; + overlayLogElem; + pollingFrequencySec = 5; + pollingIntervalId = null; + etag = ''; + debugLogging = false; + + constructor() { + this.overlayElem = document.getElementById('overlay'); + this.fetchAndShow(); + this.pollingIntervalId = setInterval(() => this.fetchAndShow(), this.pollingFrequencySec * 1000); + } + + #debug(message) { + if (this.debugLogging) { + console.debug(message); + } + } + + stopPolling() { + if (this.pollingIntervalId) { + clearInterval(this.pollingIntervalId); + } + } + + async storeEtag(response) { + const newEtag = response.headers.get('etag'); + if (newEtag) { + this.etag = newEtag; + } + return response; + } + + async getTextFromResponse(response) { + if (response.status >= 200 && response.status < 300) { + return response.text(); + } else if (response.status === 304) { + this.#debug('Cache hit, nothing to do'); + return Promise.reject(); + // Cache hit, nothing to do. + } else { + console.error(`Got response status ${response.status}, cannot continue`); + return Promise.reject(); + } + } + + showLoggedEventsInOverlay(loggedEvents) { + this.overlayLogElem ||= document.getElementById('overlay-log'); + this.overlayLogElem.classList.add('visible'); + loggedEvents.forEach((loggedEvent) => { + const elem = this.overlayLogElem.querySelector(`.${loggedEvent.id}`); + if (elem) { + elem.lastElementChild.textContent = loggedEvent.message; + } else { + const capitalizedContainerName = loggedEvent.id.replace('nextcloud-aio-', '').replace('-', ' ').replace(/(^|\s)[a-z]/gi, (letter) => letter.toUpperCase()); + const newElem = document.createElement('div'); + newElem.className = loggedEvent.id; + const nameElem = document.createElement('span'); + nameElem.textContent = `${capitalizedContainerName}:`; + const messageElem = document.createElement('span'); + messageElem.textContent = loggedEvent.message; + newElem.append(nameElem, messageElem); + this.overlayLogElem.append(newElem); + } + }); + } + + showLoggedEventsInContainerList(loggedEvents) { + this.containerElems ||= new Map(Array.from(document.getElementsByClassName('container-elem')).map((elem) => [elem.dataset.containerId, elem.querySelector('.events-log')])); + loggedEvents.forEach((loggedEvent) => { + const textElem = this.containerElems.get(loggedEvent.id); + // Check if the element exists, the event list might contain events for containers that are + // not contained in our list. + if (textElem) { + textElem.textContent = loggedEvent.message; + } + }); + } + + async showLoggedEvents(text) { + const loggedEvents = new Map(); + this.#debug({ text }); + // Split text into logged-events and filter out empty lines. + const lines = text.split('\n').filter((line) => line); + // Reduce the list of events to the last of each container. + lines.forEach((line) => { + const loggedEvent = JSON.parse(line); + loggedEvents.set(loggedEvent.id, loggedEvent); + }); + if (this.overlayElem && this.overlayElem.checkVisibility()) { + this.showLoggedEventsInOverlay(loggedEvents); + } else { + this.showLoggedEventsInContainerList(loggedEvents); + } + } + + fetchAndShow(args = { forceReloading: false}) { + if (args.forceReloading) { + this.etag = ''; + } + this.#debug('Fetching logged events from server'); + fetch('/api/events/containers', { + cache: 'no-cache', + headers: { + 'If-None-Match': this.etag, + }, + }) + .then((response) => this.storeEtag(response)) + .then((response) => this.getTextFromResponse(response)) + .then((text) => this.showLoggedEvents(text)) + .catch((error) => { + if (error instanceof Error) { + throw error; + } + }); + }; +} + +document.addEventListener('DOMContentLoaded', () => { + window.containerEventsLogClient = new ContainerEventsLogClient(); +}); diff --git a/php/public/forms.js b/php/public/forms.js index 3adc3997..af72d801 100644 --- a/php/public/forms.js +++ b/php/public/forms.js @@ -46,6 +46,7 @@ function showPassword(id) { function enableSpinner() { document.getElementById('overlay').classList.add('loading'); + window.containerEventsLogClient.fetchAndShow({ forceReloading: true }); } function disableSpinner() { diff --git a/php/public/index.php b/php/public/index.php index 8b9c83b8..e4b7cfe4 100644 --- a/php/public/index.php +++ b/php/public/index.php @@ -70,6 +70,7 @@ $app->post('/api/auth/login', AIO\Controller\LoginController::class . ':TryLogin $app->get('/api/auth/getlogin', AIO\Controller\LoginController::class . ':GetTryLogin'); $app->post('/api/auth/logout', AIO\Controller\LoginController::class . ':Logout'); $app->post('/api/configuration', \AIO\Controller\ConfigurationController::class . ':SetConfig'); +$app->get('/api/events/containers', \AIO\Controller\ContainerEventsController::class . ':GetEventsLog'); // Views $app->get('/containers', function (Request $request, Response $response, array $args) use ($container) { diff --git a/php/public/style.css b/php/public/style.css index b35883d0..09fdb131 100644 --- a/php/public/style.css +++ b/php/public/style.css @@ -158,6 +158,12 @@ div.toast.error { border-left-color: var(--color-error); } +.events-log { + font-size: smaller; + font-family: monospace; + color: #444; +} + .status { display: inline-block; height: var(--checkbox-size); @@ -471,6 +477,37 @@ input[type="checkbox"]:disabled:not(:checked) + label { display: block; } +#overlay #overlay-log.visible { + visibility: visible; + opacity: 1; + transition: opacity 500ms ease-in; +} + +#overlay #overlay-log { + visibility: hidden; + opacity: 0; + 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; +} + +#overlay #overlay-log div { + margin-bottom: 0.3rem; +} + +#overlay #overlay-log div:last-child { + margin-bottom: 0; +} + +#overlay #overlay-log div span:first-child { + margin-right: 0.3rem; +} + .loader { border: 16px solid var(--color-loader); border-radius: 50%; @@ -705,4 +742,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/src/Container/Container.php b/php/src/Container/Container.php index 6e5d2b54..b748dbf6 100644 --- a/php/src/Container/Container.php +++ b/php/src/Container/Container.php @@ -5,9 +5,12 @@ namespace AIO\Container; use AIO\Data\ConfigurationManager; use AIO\Docker\DockerActionManager; use AIO\ContainerDefinitionFetcher; +use AIO\Data\ContainerEventsLog; use JsonException; readonly class Container { + protected ContainerEventsLog $eventsLog; + public function __construct( public string $identifier, public string $displayName, @@ -39,6 +42,7 @@ readonly class Container { public string $documentation, private DockerActionManager $dockerActionManager ) { + $this->eventsLog = new ContainerEventsLog(); } public function GetUiSecret() : string { @@ -66,4 +70,8 @@ readonly class Container { public function GetStartingState() : ContainerState { return $this->dockerActionManager->GetContainerStartingState($this); } + + public function logEvent(string $message) : void { + $this->eventsLog->add($this->identifier, $message); + } } diff --git a/php/src/Controller/ContainerEventsController.php b/php/src/Controller/ContainerEventsController.php new file mode 100644 index 00000000..12558d96 --- /dev/null +++ b/php/src/Controller/ContainerEventsController.php @@ -0,0 +1,42 @@ +lastModified(); + if ($currentMtime === false) { + error_log("Error: Could not get mtime of file '{$eventsLog->filename}', something is wrong. Responding with status 502."); + return $response->withStatus(502); + } + $currentMtimeHash = md5($currentMtime); + $knownMtimeHash = $request->getHeaderLine('If-None-Match'); + if ($knownMtimeHash === $currentMtimeHash) { + return $response->withStatus(304); + } + + return $response + ->withStatus(200) + ->withHeader('Content-Type', 'application/json; charset=utf-8') + ->withHeader('Content-Disposition', 'inline') + ->withHeader('Cache-Control', 'no-cache') + ->withHeader('Etag', $currentMtimeHash) + ->withBody(\GuzzleHttp\Psr7\Utils::streamFor(fopen($eventsLog->filename, 'rb'))); + } +} diff --git a/php/src/Controller/DockerController.php b/php/src/Controller/DockerController.php index d1522800..818f71ce 100644 --- a/php/src/Controller/DockerController.php +++ b/php/src/Controller/DockerController.php @@ -50,6 +50,7 @@ readonly class DockerController { $this->dockerActionManager->CreateContainer($container); $this->dockerActionManager->StartContainer($container); $this->dockerActionManager->ConnectContainerToNetwork($container); + $container->logEvent('Container is running'); } private function PerformRecursiveImagePull(string $id) : void { diff --git a/php/src/Data/ContainerEventsLog.php b/php/src/Data/ContainerEventsLog.php new file mode 100644 index 00000000..c20e85c0 --- /dev/null +++ b/php/src/Data/ContainerEventsLog.php @@ -0,0 +1,48 @@ +filename = DataConst::GetDataDirectory() . "/container_events.log"; + if (file_exists($this->filename)) { + $this->pruneFileIfTooLarge(); + } else { + touch($this->filename); + } + } + + public function lastModified() : int|false { + return filemtime($this->filename); + } + + public function add(string $id, string $message) : void + { + $json = json_encode(['time' => time(), 'id' => $id, 'message' => $message]); + + // Append new event (atomic via LOCK_EX) + file_put_contents($this->filename, $json . PHP_EOL, FILE_APPEND | LOCK_EX); + } + + // Truncate the file to keep only the last bytes, aligned to a newline boundary. + protected function pruneFileIfTooLarge() : void { + $maxBytes = 512 * 1024; // 512 KB + $maxLines = 1000; // keep last 1000 events + + if (filesize($this->filename) <= $maxBytes) { + return; + } + + $lines = file($this->filename, 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($this->filename, implode(PHP_EOL, $keep) . PHP_EOL, LOCK_EX); + } + } +} diff --git a/php/src/Docker/DockerActionManager.php b/php/src/Docker/DockerActionManager.php index db48dc2c..f57df77d 100644 --- a/php/src/Docker/DockerActionManager.php +++ b/php/src/Docker/DockerActionManager.php @@ -8,6 +8,7 @@ use AIO\Container\VersionState; use AIO\ContainerDefinitionFetcher; use AIO\Data\ConfigurationManager; use AIO\Data\DataConst; +use AIO\Data\ContainerEventsLog; use GuzzleHttp\Client; use GuzzleHttp\Exception\RequestException; use http\Env\Response; @@ -167,6 +168,7 @@ readonly class DockerActionManager { public function StartContainer(Container $container): void { $url = $this->BuildApiUrl(sprintf('containers/%s/start', urlencode($container->identifier))); + $container->logEvent('Starting container'); try { $this->guzzleClient->post($url); } catch (RequestException $e) { @@ -201,6 +203,7 @@ readonly class DockerActionManager { } public function CreateContainer(Container $container): void { + $container->logEvent('Creating container'); $volumes = []; foreach ($container->volumes->GetVolumes() as $volume) { // // NEXTCLOUD_MOUNT gets added via bind-mount later on @@ -501,12 +504,14 @@ readonly class DockerActionManager { $imageIsThere = false; } + $container->logEvent('Pulling image'); $maxRetries = 3; for ($attempt = 1; $attempt <= $maxRetries; $attempt++) { try { $this->guzzleClient->post($url); + $container->logEvent('Finished pulling image'); break; - } catch (RequestException $e) { + } catch (\Throwable $e) { $message = "Could not pull image " . $imageName . " (attempt $attempt/$maxRetries): " . $e->getResponse()?->getBody()->getContents(); if ($attempt === $maxRetries) { if ($imageIsThere === false) { @@ -514,6 +519,7 @@ readonly class DockerActionManager { } else { error_log($message); } + $container->logEvent('Pulling image failed, please review the output of the "nextcloud-aio-mastercontainer" container'); } else { error_log($message . ' Retrying...'); sleep(1); @@ -829,6 +835,7 @@ readonly class DockerActionManager { } public function ConnectContainerToNetwork(Container $container): void { + $container->logEvent('Connecting container to network'); // Add a secondary alias for domaincheck container, to keep it as similar to actual apache controller as possible. // If a reverse-proxy is relying on container name as hostname this allows it to operate as usual and still validate the domain // The domaincheck container and apache container are never supposed to be active at the same time because they use the same APACHE_PORT anyway, so this doesn't add any new constraints. @@ -851,6 +858,7 @@ readonly class DockerActionManager { $maxShutDownTime = $container->maxShutdownTime; } $url = $this->BuildApiUrl(sprintf('containers/%s/stop?t=%s', urlencode($container->identifier), $maxShutDownTime)); + $container->logEvent('Stopping container'); try { $this->guzzleClient->post($url); } catch (RequestException $e) { diff --git a/php/templates/components/container-state.twig b/php/templates/components/container-state.twig index 07580e66..2f293912 100644 --- a/php/templates/components/container-state.twig +++ b/php/templates/components/container-state.twig @@ -1,6 +1,6 @@ {# @var c \App\Containers\Container #}
  • - + {% if c.GetStartingState().value == 'starting' %} {{ c.displayName }} @@ -14,6 +14,7 @@ {{ c.displayName }} (Stopped) {% endif %} + {% if c.documentation != '' %} (docs) {% endif %} @@ -24,4 +25,4 @@ {% endif %} -
  • \ No newline at end of file + diff --git a/php/templates/layout.twig b/php/templates/layout.twig index 8c5a625b..4be63c76 100644 --- a/php/templates/layout.twig +++ b/php/templates/layout.twig @@ -5,6 +5,7 @@ +