mirror of
https://github.com/nextcloud/all-in-one.git
synced 2026-02-12 00:30:15 +00:00
WIP: Poll for logged events
Signed-off-by: Pablo Zmdl <pablo@nextcloud.com>
This commit is contained in:
parent
2c9a1f3fad
commit
fbffdeed9f
11 changed files with 274 additions and 4 deletions
122
php/public/container_events_log_client.js
Normal file
122
php/public/container_events_log_client.js
Normal file
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -46,6 +46,7 @@ function showPassword(id) {
|
|||
|
||||
function enableSpinner() {
|
||||
document.getElementById('overlay').classList.add('loading');
|
||||
window.containerEventsLogClient.fetchAndShow({ forceReloading: true });
|
||||
}
|
||||
|
||||
function disableSpinner() {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
42
php/src/Controller/ContainerEventsController.php
Normal file
42
php/src/Controller/ContainerEventsController.php
Normal file
|
|
@ -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')));
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
48
php/src/Data/ContainerEventsLog.php
Normal file
48
php/src/Data/ContainerEventsLog.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
namespace AIO\Data;
|
||||
|
||||
class ContainerEventsLog {
|
||||
readonly public string $filename;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{# @var c \App\Containers\Container #}
|
||||
<li>
|
||||
<span>
|
||||
<span class="container-elem" data-container-id="{{ c.identifier }}">
|
||||
{% if c.GetStartingState().value == 'starting' %}
|
||||
<span class="status running"></span>
|
||||
{{ c.displayName }}
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
{{ c.displayName }}
|
||||
(<a href="api/docker/logs?id={{ c.identifier }}" target="_blank">Stopped</a>)
|
||||
{% endif %}
|
||||
<span class="events-log"></span>
|
||||
{% if c.documentation != '' %}
|
||||
(<a target="_blank" href="{{ c.documentation }}">docs</a>)
|
||||
{% endif %}
|
||||
|
|
@ -24,4 +25,4 @@
|
|||
<input type="text" value="{{ c.GetUiSecret() }}" readonly>
|
||||
</details>
|
||||
{% endif %}
|
||||
</li>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
<link rel="icon" href="img/favicon.png">
|
||||
<script type="text/javascript" src="forms.js"></script>
|
||||
<script type="text/javascript" src="toggle-dark-mode.js"></script>
|
||||
<script type="module" src="container_events_log_client.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue