mirror of
https://github.com/nextcloud/all-in-one.git
synced 2026-06-10 08:37:02 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab9dbdec4b | |||
| 2f3149d415 | |||
| 43dc0769b2 | |||
| 769d5e9344 | |||
| fbffdeed9f | |||
| 2c9a1f3fad | |||
| b51943d8a1 |
@@ -21,6 +21,11 @@ Listen 8080 https
|
|||||||
<FilesMatch "\.php$">
|
<FilesMatch "\.php$">
|
||||||
SetHandler "proxy:fcgi://127.0.0.1:9000"
|
SetHandler "proxy:fcgi://127.0.0.1:9000"
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
|
|
||||||
|
# Disable output buffering to enable streaming responses.
|
||||||
|
<Proxy "fcgi://127.0.0.1:9000/" enablereuse=on flushpackets=on max=10>
|
||||||
|
</Proxy>
|
||||||
|
|
||||||
# Master dir
|
# Master dir
|
||||||
DocumentRoot /var/www/docker-aio/php/public/
|
DocumentRoot /var/www/docker-aio/php/public/
|
||||||
<Directory /var/www/docker-aio/php/public/>
|
<Directory /var/www/docker-aio/php/public/>
|
||||||
|
|||||||
+2
-1
@@ -16,7 +16,8 @@
|
|||||||
"http-interop/http-factory-guzzle": "^1.2",
|
"http-interop/http-factory-guzzle": "^1.2",
|
||||||
"slim/twig-view": "^3.3",
|
"slim/twig-view": "^3.3",
|
||||||
"slim/csrf": "^1.3",
|
"slim/csrf": "^1.3",
|
||||||
"ext-apcu": "*"
|
"ext-apcu": "*",
|
||||||
|
"slim/psr7": "^1.8"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"sserbin/twig-linter": "@dev",
|
"sserbin/twig-linter": "@dev",
|
||||||
|
|||||||
Generated
+137
-2
@@ -4,8 +4,64 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "19598625395cc28e64f15d2719f8f98f",
|
"content-hash": "a47e950885b06f1a4b631a1eea56c57e",
|
||||||
"packages": [
|
"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",
|
"name": "guzzlehttp/guzzle",
|
||||||
"version": "7.10.0",
|
"version": "7.10.0",
|
||||||
@@ -1146,6 +1202,85 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-11-02T14:58:28+00:00"
|
"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",
|
"name": "slim/slim",
|
||||||
"version": "4.15.1",
|
"version": "4.15.1",
|
||||||
@@ -4813,5 +4948,5 @@
|
|||||||
"ext-apcu": "*"
|
"ext-apcu": "*"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.9.0"
|
"plugin-api-version": "2.6.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
+11
-1
@@ -75,10 +75,20 @@ function showPassword(id) {
|
|||||||
|
|
||||||
function initForms() {
|
function initForms() {
|
||||||
const forms = document.querySelectorAll('form.xhr')
|
const forms = document.querySelectorAll('form.xhr')
|
||||||
console.info("Making " + forms.length + " form(s) use XHR.");
|
|
||||||
for (const form of forms) {
|
for (const form of forms) {
|
||||||
initForm(form);
|
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') {
|
if (document.readyState === 'loading') {
|
||||||
|
|||||||
@@ -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->get('/api/auth/getlogin', AIO\Controller\LoginController::class . ':GetTryLogin');
|
||||||
$app->post('/api/auth/logout', AIO\Controller\LoginController::class . ':Logout');
|
$app->post('/api/auth/logout', AIO\Controller\LoginController::class . ':Logout');
|
||||||
$app->post('/api/configuration', \AIO\Controller\ConfigurationController::class . ':SetConfig');
|
$app->post('/api/configuration', \AIO\Controller\ConfigurationController::class . ':SetConfig');
|
||||||
|
$app->get('/api/events/containers', \AIO\Controller\ContainerEventsController::class . ':GetEventsLog');
|
||||||
|
|
||||||
// Views
|
// Views
|
||||||
$app->get('/containers', function (Request $request, Response $response, array $args) use ($container) {
|
$app->get('/containers', function (Request $request, Response $response, array $args) use ($container) {
|
||||||
@@ -142,6 +143,20 @@ $app->get('/containers', function (Request $request, Response $response, array $
|
|||||||
'bypass_container_update' => $bypass_container_update,
|
'bypass_container_update' => $bypass_container_update,
|
||||||
]);
|
]);
|
||||||
})->setName('profile');
|
})->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) {
|
$app->get('/login', function (Request $request, Response $response, array $args) use ($container) {
|
||||||
$view = Twig::fromRequest($request);
|
$view = Twig::fromRequest($request);
|
||||||
/** @var \AIO\Docker\DockerActionManager $dockerActionManager */
|
/** @var \AIO\Docker\DockerActionManager $dockerActionManager */
|
||||||
@@ -197,4 +212,13 @@ $app->get('/', function (\Psr\Http\Message\RequestInterface $request, Response $
|
|||||||
|
|
||||||
$errorMiddleware = $app->addErrorMiddleware(false, true, true);
|
$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();
|
$app->run();
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
+39
-1
@@ -158,6 +158,12 @@ div.toast.error {
|
|||||||
border-left-color: var(--color-error);
|
border-left-color: var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.events-log {
|
||||||
|
font-size: smaller;
|
||||||
|
font-family: monospace;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
height: var(--checkbox-size);
|
height: var(--checkbox-size);
|
||||||
@@ -471,6 +477,38 @@ input[type="checkbox"]:disabled:not(:checked) + label {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#overlay #overlay-log.visible {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 1s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
#overlay #overlay-log {
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: calc(50% + 120px);
|
||||||
|
width: 20%;
|
||||||
|
height: 5rem;
|
||||||
|
margin: 0 39%;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: solid thin rgb(192, 192, 192);
|
||||||
|
background-color: rgba(128, 128, 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
#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 {
|
.loader {
|
||||||
border: 16px solid var(--color-loader);
|
border: 16px solid var(--color-loader);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -705,4 +743,4 @@ input[type="checkbox"]:disabled:not(:checked) + label {
|
|||||||
.office-suite-cards {
|
.office-suite-cards {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ namespace AIO\Container;
|
|||||||
use AIO\Data\ConfigurationManager;
|
use AIO\Data\ConfigurationManager;
|
||||||
use AIO\Docker\DockerActionManager;
|
use AIO\Docker\DockerActionManager;
|
||||||
use AIO\ContainerDefinitionFetcher;
|
use AIO\ContainerDefinitionFetcher;
|
||||||
|
use AIO\Data\ContainerEventsLog;
|
||||||
use JsonException;
|
use JsonException;
|
||||||
|
|
||||||
readonly class Container {
|
readonly class Container {
|
||||||
|
protected ContainerEventsLog $eventsLog;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $identifier,
|
public string $identifier,
|
||||||
public string $displayName,
|
public string $displayName,
|
||||||
@@ -39,6 +42,7 @@ readonly class Container {
|
|||||||
public string $documentation,
|
public string $documentation,
|
||||||
private DockerActionManager $dockerActionManager
|
private DockerActionManager $dockerActionManager
|
||||||
) {
|
) {
|
||||||
|
$this->eventsLog = new ContainerEventsLog();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function GetUiSecret() : string {
|
public function GetUiSecret() : string {
|
||||||
@@ -66,4 +70,8 @@ readonly class Container {
|
|||||||
public function GetStartingState() : ContainerState {
|
public function GetStartingState() : ContainerState {
|
||||||
return $this->dockerActionManager->GetContainerStartingState($this);
|
return $this->dockerActionManager->GetContainerStartingState($this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function logEvent(string $message) : void {
|
||||||
|
$this->eventsLog->add($this->identifier, $message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ use AIO\Docker\DockerActionManager;
|
|||||||
use Psr\Http\Message\ResponseInterface as Response;
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
use AIO\Data\ConfigurationManager;
|
use AIO\Data\ConfigurationManager;
|
||||||
|
use AIO\Data\DataConst;
|
||||||
|
use Slim\Psr7\NonBufferedBody;
|
||||||
|
|
||||||
readonly class DockerController {
|
readonly class DockerController {
|
||||||
private const string TOP_CONTAINER = 'nextcloud-aio-apache';
|
private const string TOP_CONTAINER = 'nextcloud-aio-apache';
|
||||||
@@ -19,12 +21,12 @@ readonly class DockerController {
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private function PerformRecursiveContainerStart(string $id, bool $pullImage = true) : void {
|
private function PerformRecursiveContainerStart(string $id, bool $pullImage = true, ?NonBufferedBody $body = null) : void {
|
||||||
$container = $this->containerDefinitionFetcher->GetContainerById($id);
|
$container = $this->containerDefinitionFetcher->GetContainerById($id);
|
||||||
|
|
||||||
// Start all dependencies first and then itself
|
// Start all dependencies first and then itself
|
||||||
foreach($container->dependsOn as $dependency) {
|
foreach($container->dependsOn as $dependency) {
|
||||||
$this->PerformRecursiveContainerStart($dependency, $pullImage);
|
$this->PerformRecursiveContainerStart($dependency, $pullImage, $body);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't start if container is already running
|
// Don't start if container is already running
|
||||||
@@ -36,7 +38,13 @@ readonly class DockerController {
|
|||||||
|
|
||||||
$this->dockerActionManager->DeleteContainer($container);
|
$this->dockerActionManager->DeleteContainer($container);
|
||||||
$this->dockerActionManager->CreateVolumes($container);
|
$this->dockerActionManager->CreateVolumes($container);
|
||||||
|
if ($body) {
|
||||||
|
$body->write("<div>{$container->displayName}: Pulling image</div>");
|
||||||
|
}
|
||||||
$this->dockerActionManager->PullImage($container, $pullImage);
|
$this->dockerActionManager->PullImage($container, $pullImage);
|
||||||
|
if ($body) {
|
||||||
|
$body->write("<div>{$container->displayName}: Starting container</div>");
|
||||||
|
}
|
||||||
$this->dockerActionManager->CreateContainer($container);
|
$this->dockerActionManager->CreateContainer($container);
|
||||||
$this->dockerActionManager->StartContainer($container);
|
$this->dockerActionManager->StartContainer($container);
|
||||||
$this->dockerActionManager->ConnectContainerToNetwork($container);
|
$this->dockerActionManager->ConnectContainerToNetwork($container);
|
||||||
@@ -197,17 +205,29 @@ readonly class DockerController {
|
|||||||
if ($pullImage === false) {
|
if ($pullImage === false) {
|
||||||
error_log('WARNING: Not pulling container images. Instead, using local ones.');
|
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.
|
||||||
|
$body = $nonbufResp->getBody();
|
||||||
|
|
||||||
|
$body->write("<!DOCTYPE html><html lang='en'><head><style>body { color: white; }</style><script>const observer = new MutationObserver((records) => records[0].addedNodes[0].scrollIntoView()); observer.observe(document, {childList: true, subtree: true});</script></head><body>");
|
||||||
|
|
||||||
// Start container
|
// Start container
|
||||||
$this->startTopContainer($pullImage);
|
$this->startTopContainer($pullImage, $body);
|
||||||
|
|
||||||
// Clear apcu cache in order to check if container updates are available
|
// Clear apcu cache in order to check if container updates are available
|
||||||
// Temporarily disabled as it leads much faster to docker rate limits
|
// Temporarily disabled as it leads much faster to docker rate limits
|
||||||
// apcu_clear_cache();
|
// apcu_clear_cache();
|
||||||
|
|
||||||
return $response->withStatus(201)->withHeader('Location', '.');
|
$body->write("</body></html>");
|
||||||
|
return $nonbufResp;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function startTopContainer(bool $pullImage) : void {
|
public function startTopContainer(bool $pullImage, ?NonBufferedBody $body = null) : void {
|
||||||
$this->configurationManager->aioToken = bin2hex(random_bytes(24));
|
$this->configurationManager->aioToken = bin2hex(random_bytes(24));
|
||||||
|
|
||||||
// Stop domaincheck since apache would not be able to start otherwise
|
// Stop domaincheck since apache would not be able to start otherwise
|
||||||
@@ -215,7 +235,7 @@ readonly class DockerController {
|
|||||||
|
|
||||||
$id = self::TOP_CONTAINER;
|
$id = self::TOP_CONTAINER;
|
||||||
|
|
||||||
$this->PerformRecursiveContainerStart($id, $pullImage);
|
$this->PerformRecursiveContainerStart($id, $pullImage, $body);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function StartWatchtowerContainer(Request $request, Response $response, array $args) : Response {
|
public function StartWatchtowerContainer(Request $request, Response $response, array $args) : Response {
|
||||||
@@ -261,6 +281,48 @@ readonly class DockerController {
|
|||||||
return $response->withStatus(201)->withHeader('Location', '.');
|
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 {
|
public function stopTopContainer() : void {
|
||||||
$id = self::TOP_CONTAINER;
|
$id = self::TOP_CONTAINER;
|
||||||
$this->PerformRecursiveContainerStop($id);
|
$this->PerformRecursiveContainerStop($id);
|
||||||
@@ -307,4 +369,33 @@ readonly class DockerController {
|
|||||||
$id = 'nextcloud-aio-domaincheck';
|
$id = 'nextcloud-aio-domaincheck';
|
||||||
$this->PerformRecursiveContainerStop($id);
|
$this->PerformRecursiveContainerStop($id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Write container event to events file and prune old events
|
||||||
|
private function writeEventsToFile(array $payload): void {
|
||||||
|
$eventJson = json_encode($payload);
|
||||||
|
|
||||||
|
$eventsFile = DataConst::GetContainerEventsFile();
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,4 +66,8 @@ class DataConst {
|
|||||||
public static function GetContainersDefinitionPath() : string {
|
public static function GetContainersDefinitionPath() : string {
|
||||||
return (string)realpath(__DIR__ . '/../../containers.json');
|
return (string)realpath(__DIR__ . '/../../containers.json');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function GetContainerEventsFile() : string {
|
||||||
|
return self::GetDataDirectory() . '/container_events.log';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use AIO\Container\VersionState;
|
|||||||
use AIO\ContainerDefinitionFetcher;
|
use AIO\ContainerDefinitionFetcher;
|
||||||
use AIO\Data\ConfigurationManager;
|
use AIO\Data\ConfigurationManager;
|
||||||
use AIO\Data\DataConst;
|
use AIO\Data\DataConst;
|
||||||
|
use AIO\Data\ContainerEventsLog;
|
||||||
use GuzzleHttp\Client;
|
use GuzzleHttp\Client;
|
||||||
use GuzzleHttp\Exception\RequestException;
|
use GuzzleHttp\Exception\RequestException;
|
||||||
use http\Env\Response;
|
use http\Env\Response;
|
||||||
@@ -167,6 +168,7 @@ readonly class DockerActionManager {
|
|||||||
|
|
||||||
public function StartContainer(Container $container): void {
|
public function StartContainer(Container $container): void {
|
||||||
$url = $this->BuildApiUrl(sprintf('containers/%s/start', urlencode($container->identifier)));
|
$url = $this->BuildApiUrl(sprintf('containers/%s/start', urlencode($container->identifier)));
|
||||||
|
$container->logEvent('Starting container');
|
||||||
try {
|
try {
|
||||||
$this->guzzleClient->post($url);
|
$this->guzzleClient->post($url);
|
||||||
} catch (RequestException $e) {
|
} catch (RequestException $e) {
|
||||||
@@ -201,6 +203,7 @@ readonly class DockerActionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function CreateContainer(Container $container): void {
|
public function CreateContainer(Container $container): void {
|
||||||
|
$container->logEvent('Creating container');
|
||||||
$volumes = [];
|
$volumes = [];
|
||||||
foreach ($container->volumes->GetVolumes() as $volume) {
|
foreach ($container->volumes->GetVolumes() as $volume) {
|
||||||
// // NEXTCLOUD_MOUNT gets added via bind-mount later on
|
// // NEXTCLOUD_MOUNT gets added via bind-mount later on
|
||||||
@@ -501,12 +504,14 @@ readonly class DockerActionManager {
|
|||||||
$imageIsThere = false;
|
$imageIsThere = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$container->logEvent('Pulling image');
|
||||||
$maxRetries = 3;
|
$maxRetries = 3;
|
||||||
for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
|
for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
|
||||||
try {
|
try {
|
||||||
$this->guzzleClient->post($url);
|
$this->guzzleClient->post($url);
|
||||||
|
$container->logEvent('Finished pulling image');
|
||||||
break;
|
break;
|
||||||
} catch (RequestException $e) {
|
} catch (\Throwable $e) {
|
||||||
$message = "Could not pull image " . $imageName . " (attempt $attempt/$maxRetries): " . $e->getResponse()?->getBody()->getContents();
|
$message = "Could not pull image " . $imageName . " (attempt $attempt/$maxRetries): " . $e->getResponse()?->getBody()->getContents();
|
||||||
if ($attempt === $maxRetries) {
|
if ($attempt === $maxRetries) {
|
||||||
if ($imageIsThere === false) {
|
if ($imageIsThere === false) {
|
||||||
@@ -514,6 +519,7 @@ readonly class DockerActionManager {
|
|||||||
} else {
|
} else {
|
||||||
error_log($message);
|
error_log($message);
|
||||||
}
|
}
|
||||||
|
$container->logEvent('Pulling image failed, please review the output of the "nextcloud-aio-mastercontainer" container');
|
||||||
} else {
|
} else {
|
||||||
error_log($message . ' Retrying...');
|
error_log($message . ' Retrying...');
|
||||||
sleep(1);
|
sleep(1);
|
||||||
@@ -829,6 +835,7 @@ readonly class DockerActionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function ConnectContainerToNetwork(Container $container): void {
|
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.
|
// 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
|
// 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.
|
// 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;
|
$maxShutDownTime = $container->maxShutdownTime;
|
||||||
}
|
}
|
||||||
$url = $this->BuildApiUrl(sprintf('containers/%s/stop?t=%s', urlencode($container->identifier), $maxShutDownTime));
|
$url = $this->BuildApiUrl(sprintf('containers/%s/stop?t=%s', urlencode($container->identifier), $maxShutDownTime));
|
||||||
|
$container->logEvent('Stopping container');
|
||||||
try {
|
try {
|
||||||
$this->guzzleClient->post($url);
|
$this->guzzleClient->post($url);
|
||||||
} catch (RequestException $e) {
|
} catch (RequestException $e) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{# @var c \App\Containers\Container #}
|
{# @var c \App\Containers\Container #}
|
||||||
<li>
|
<li>
|
||||||
<span>
|
<span class="container-elem" data-container-id="{{ c.identifier }}">
|
||||||
{% if c.GetStartingState().value == 'starting' %}
|
{% if c.GetStartingState().value == 'starting' %}
|
||||||
<span class="status running"></span>
|
<span class="status running"></span>
|
||||||
{{ c.displayName }}
|
{{ c.displayName }}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
{{ c.displayName }}
|
{{ c.displayName }}
|
||||||
(<a href="api/docker/logs?id={{ c.identifier }}" target="_blank">Stopped</a>)
|
(<a href="api/docker/logs?id={{ c.identifier }}" target="_blank">Stopped</a>)
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<span class="events-log"></span>
|
||||||
{% if c.documentation != '' %}
|
{% if c.documentation != '' %}
|
||||||
(<a target="_blank" href="{{ c.documentation }}">docs</a>)
|
(<a target="_blank" href="{{ c.documentation }}">docs</a>)
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -24,4 +25,4 @@
|
|||||||
<input type="text" value="{{ c.GetUiSecret() }}" readonly>
|
<input type="text" value="{{ c.GetUiSecret() }}" readonly>
|
||||||
</details>
|
</details>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -338,7 +338,7 @@
|
|||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if was_start_button_clicked == false %}
|
{% if was_start_button_clicked == false %}
|
||||||
<form method="POST" action="api/docker/start" class="xhr">
|
<form method="POST" action="api/docker/start" target="overlay-log">
|
||||||
<input type="hidden" name="{{csrf.keys.name}}" value="{{csrf.name}}">
|
<input type="hidden" name="{{csrf.keys.name}}" value="{{csrf.name}}">
|
||||||
<input type="hidden" name="{{csrf.keys.value}}" value="{{csrf.value}}">
|
<input type="hidden" name="{{csrf.keys.value}}" value="{{csrf.value}}">
|
||||||
<input id="base_path" type="hidden" name="base_path" value="">
|
<input id="base_path" type="hidden" name="base_path" value="">
|
||||||
@@ -355,7 +355,7 @@
|
|||||||
<input type="submit" value="Start containers" />
|
<input type="submit" value="Start containers" />
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form method="POST" action="api/docker/start" class="xhr">
|
<form method="POST" action="api/docker/start" target="overlay-log">
|
||||||
<input type="hidden" name="{{csrf.keys.name}}" value="{{csrf.name}}">
|
<input type="hidden" name="{{csrf.keys.name}}" value="{{csrf.name}}">
|
||||||
<input type="hidden" name="{{csrf.keys.value}}" value="{{csrf.value}}">
|
<input type="hidden" name="{{csrf.keys.value}}" value="{{csrf.value}}">
|
||||||
<input id="base_path" type="hidden" name="base_path" value="">
|
<input id="base_path" type="hidden" name="base_path" value="">
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="overlay">
|
<div id="overlay">
|
||||||
<div class="loader"></div>
|
<div class="loader"></div>
|
||||||
|
<iframe name='overlay-log' id="overlay-log"></iframe>
|
||||||
</div>
|
</div>
|
||||||
<button id="theme-toggle" onclick="toggleTheme()">
|
<button id="theme-toggle" onclick="toggleTheme()">
|
||||||
<span id="theme-icon"></span>
|
<span id="theme-icon"></span>
|
||||||
|
|||||||
Reference in New Issue
Block a user