Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions php/public/container_events_log_client.js
Original file line number Diff line number Diff line change
@@ -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();
});
1 change: 1 addition & 0 deletions php/public/forms.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ function showPassword(id) {

function enableSpinner() {
document.getElementById('overlay').classList.add('loading');
window.containerEventsLogClient.fetchAndShow({ forceReloading: true });
}

function disableSpinner() {
Expand Down
24 changes: 24 additions & 0 deletions php/public/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
$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) {
Expand Down Expand Up @@ -142,6 +143,20 @@
'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 */
Expand Down Expand Up @@ -197,4 +212,13 @@

$errorMiddleware = $app->addErrorMiddleware(false, true, true);

// Set a custom Not Found handler, which doesn't pollute the app output with 404 errors.
$errorMiddleware->setErrorHandler(
\Slim\Exception\HttpNotFoundException::class,
function (Request $request, Throwable $exception, bool $displayErrorDetails) use ($app) {
$response = $app->getResponseFactory()->createResponse();
$response->getBody()->write('Not Found');
return $response->withStatus(404);
});

$app->run();
27 changes: 27 additions & 0 deletions php/public/overlay-log.js
Original file line number Diff line number Diff line change
@@ -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');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://developer.mozilla.org/en-US/docs/Web/API/EventSource says that this has significant limitations when used over HTTP1. Are we concerned about that at all?

Copy link
Collaborator Author

@szaimen szaimen Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We disallow multiple tabs so that should not be a problem but we should probably add a check here as well to abort any connection attempt if a second tab was opened like done here:

channel.addEventListener('message', (msg) => {

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 */
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we log something in case there is another unexpected source of an exception?

}
}
});
39 changes: 38 additions & 1 deletion php/public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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%;
Expand Down Expand Up @@ -705,4 +742,4 @@ input[type="checkbox"]:disabled:not(:checked) + label {
.office-suite-cards {
grid-template-columns: 1fr;
}
}
}
8 changes: 8 additions & 0 deletions php/src/Container/Container.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@
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,
Expand Down Expand Up @@ -39,6 +42,7 @@ public function __construct(
public string $documentation,
private DockerActionManager $dockerActionManager
) {
$this->eventsLog = new ContainerEventsLog();
}

public function GetUiSecret() : string {
Expand Down Expand Up @@ -66,4 +70,8 @@ public function GetUpdateState() : VersionState {
public function GetStartingState() : ContainerState {
return $this->dockerActionManager->GetContainerStartingState($this);
}

public function logEvent(string $message) : void {
$this->eventsLog->add($this->identifier, $message);
}
}
42 changes: 42 additions & 0 deletions php/src/Controller/ContainerEventsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

namespace AIO\Controller;

use AIO\Container\ContainerState;
use AIO\ContainerDefinitionFetcher;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use AIO\Data\ConfigurationManager;
use AIO\Data\DataConst;
use AIO\Data\ContainerEventsLog;

readonly class ContainerEventsController {
public function __construct(
private ContainerDefinitionFetcher $containerDefinitionFetcher,
private ConfigurationManager $configurationManager
) {
}

public function getEventsLog(Request $request, Response $response, array $args) : Response
{
$eventsLog = new ContainerEventsLog();
$currentMtime = $eventsLog->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')));
}
}
Loading