From bbda7e546447359aecc991661d99bd0bd8a57878 Mon Sep 17 00:00:00 2001
From: Jan-Hendrik Willms <tleilax+studip@gmail.com>
Date: Fri, 26 Apr 2024 23:40:10 +0000
Subject: [PATCH] Wip2

---
 composer.json                               |   2 +
 config/openssl-cert-request                 |   8 ++
 config/php-fpm.conf                         |   4 +-
 lib/Commands/Compile.php                    |  85 ++++++++++++----
 lib/Commands/Keys/Create.php                |  11 ++-
 lib/Commands/PHP/Port.php                   |  15 ++-
 lib/Commands/PHP/SetDefault.php             |  59 +++++++++++
 lib/Config.php                              |  18 +++-
 lib/ConfigurationCreator.php                |  48 +++++++++
 lib/Creators/DockerComposeConfiguration.php |  47 ++++-----
 lib/Creators/NginxConfiguration.php         |  69 ++++---------
 lib/SupportedPHPVersions.php                |  16 +--
 studip-docker                               |   1 +
 web/LogParser.php                           |  41 ++++++++
 web/assets/script.js                        |  41 ++++++++
 web/assets/style.css                        |  36 +++++++
 web/index.php                               | 102 +++++++++++++-------
 17 files changed, 443 insertions(+), 160 deletions(-)
 create mode 100644 config/openssl-cert-request
 create mode 100644 lib/Commands/PHP/SetDefault.php
 create mode 100644 lib/ConfigurationCreator.php
 create mode 100644 web/LogParser.php
 create mode 100644 web/assets/script.js
 create mode 100644 web/assets/style.css

diff --git a/composer.json b/composer.json
index 6bc6df8..76ba804 100644
--- a/composer.json
+++ b/composer.json
@@ -2,6 +2,8 @@
     "name": "tleilax/studip-dockerized",
     "type": "project",
     "require": {
+        "php": "^8",
+        "ext-json": "*",
         "symfony/console": "^6.4",
         "symfony/yaml": "^6.4",
         "symfony/var-dumper": "^6.4",
diff --git a/config/openssl-cert-request b/config/openssl-cert-request
new file mode 100644
index 0000000..415c4ff
--- /dev/null
+++ b/config/openssl-cert-request
@@ -0,0 +1,8 @@
+[dn]
+CN=localhost
+[req]
+distinguished_name = dn
+[EXT]
+subjectAltName=DNS:localhost
+keyUsage=digitalSignature
+extendedKeyUsage=serverAuth
diff --git a/config/php-fpm.conf b/config/php-fpm.conf
index 60cf05e..2d2ca09 100644
--- a/config/php-fpm.conf
+++ b/config/php-fpm.conf
@@ -1,4 +1,4 @@
 php_admin_flag[log_errors] = on
-access.log = /var/log/php-access.log
-php_admin_value[error_log] = /var/log/php-error.log
+php_admin_value[error_log] = /var/log/error.log
+access.log = /dev/null
 pm.max_requests = 500
diff --git a/lib/Commands/Compile.php b/lib/Commands/Compile.php
index 061a9ee..01fe7e5 100644
--- a/lib/Commands/Compile.php
+++ b/lib/Commands/Compile.php
@@ -128,12 +128,20 @@ final class Compile extends Command
     {
         $creator = new DockerComposeConfiguration('studip-dockerized');
 
+        $creator->hook('services', function (array $service): array {
+            $service['restart'] = 'unless-stopped';
+            return $service;
+        });
+
         // Declare volumes
         $volumes = [
-            [realpath($cwd . '/web'), '/var/www/html/studip-dockerized-config.app', 'ro'],
-            [realpath($cwd . '/config.json'), '/var/www/html/studip-dockerized-config.json', 'ro'],
+            [realpath($cwd . '/web'), '/usr/share/nginx/html/studip-dockerized-config.app', 'ro'],
+            [realpath($cwd . '/logs'), '/usr/share/nginx/html/logs', 'ro'],
+            [realpath($cwd . '/config.json'), '/usr/share/nginx/html/studip-dockerized-config.json', 'ro'],
             ...array_map(
-                fn(string $path, array $definition) => [$definition['source'], '/var/www/html/' . $path],
+                fn(string $path, array $definition) => [
+                    $definition['source'], '/usr/share/nginx/html/' . $path
+                ],
                 array_keys(Config::getInstance()->get('sites') ?? []),
                 array_values(Config::getInstance()->get('sites') ?? [])
             )
@@ -143,23 +151,30 @@ final class Compile extends Command
         $creator->addService('nginx', 'nginx:alpine', [
             'networks' => ['code-network'],
             'depends_on' => ['redis-server', 'memcached-server'],
-            'ports' => array_map(
-                function ($port) {
-                    return "{$port}:{$port}";
-                },
-                array_values(Config::getInstance()->get('php'))
+            'ports' => array_merge(
+                Config::getInstance()->get('default') ? ['80:80', '443:443'] : [],
+                ...array_map(
+                    fn($config) => [
+                        "{$config[0]}:{$config[0]}",
+                        "{$config[1]}:{$config[1]}"
+                    ],
+                    array_values(Config::getInstance()->get('php'))
+                ),
             ),
         ]);
 
         $mounts = [
-            "{$compiledPath}/nginx.conf" => 'conf.d/nginx.conf',
+            "{$compiledPath}/nginx.conf" => 'conf.d/default.conf',
             "{$compiledPath}/nginx-sites.conf" => 'sites.conf',
             "{$cwd}/config/nginx-php.conf" => 'php.conf',
             "{$compiledPath}/ssl.crt" => "ssl.crt",
             "{$compiledPath}/ssl.key" => "ssl.key",
         ];
         foreach ($mounts as $source => $target) {
-            $creator->addServiceVolume('nginx', $source, "/etc/nginx/{$target}", 'ro');
+            if (!str_starts_with($target, '/')) {
+                $target = "/etc/nginx/{$target}";
+            }
+            $creator->addServiceVolume('nginx', $source, $target, 'ro');
         }
         $creator->addServiceVolume('nginx', realpath("{$cwd}/logs/nginx"), '/var/log/nginx');
 
@@ -225,25 +240,53 @@ final class Compile extends Command
     public function createNginxConfiguration(): string
     {
         $creator = new NginxConfiguration();
+        $creator->hook('server', function (array $config): array {
+            $config['config'] = [
+                ...$config['config'],
 
-        // Add upstreams and servers
-        foreach (Config::getInstance()->get('php') as $version => $port) {
+                'server_name _',
+
+                'proxy_read_timeout' => 300,
+                'proxy_connect_timeout' => 300,
+                'proxy_send_timeout' => 300,
+
+                'access_log off',
+                'error_log off',
+
+                'include sites.conf',
+            ];
+            return $config;
+        });
+
+        // Add upstreams
+        $servers = [];
+        foreach (Config::getInstance()->get('php') as $version => [$port, $sslPort]) {
             $index = $this->getPHPVersionIndex($version, 'php');
 
             $creator->addUpstream("{$index}_backend", [
                 "server {$index}:9000"
             ]);
 
+            $servers[] = [$index, $port, $sslPort];
+
+            if (Config::getInstance()->get('default') === $version) {
+                $servers[] = [$index, 80, 443];
+            }
+        }
+
+        // Add servers
+        foreach ($servers as [$index, $port, $sslPort]) {
             $creator->addServer([
-                'listen ' . $port . ' default_server ssl',
-                'server_name _',
+                'listen ' . $port . ' default_server',
+                'set $fastcgi_backend ' . $index . '_backend',
+            ]);
+
+            $creator->addServer([
+                'listen ' . $sslPort . ' default_server ssl',
+                'set $fastcgi_backend ' . $index . '_backend',
 
                 'ssl_certificate /etc/nginx/ssl.crt',
                 'ssl_certificate_key /etc/nginx/ssl.key',
-
-                'root /var/www/html',
-                'set $fastcgi_backend ' . $index . '_backend',
-                'include sites.conf',
             ]);
         }
 
@@ -256,15 +299,15 @@ final class Compile extends Command
 
         // Add default location
         $creator->addLocation('/sites', [
-            'alias /var/www/html/studip-dockerized-config.app',
+            'alias /usr/share/nginx/html/studip-dockerized-config.app',
             'index index.php index.html index.html',
-            'set $site_document_root /var/www/html/studip-dockerized-config.app',
+            'set $site_document_root /usr/share/nginx/html/studip-dockerized-config.app',
             'include php.conf',
         ]);
 
         // Add configured locations
         foreach (Config::getInstance()->get('sites') ?? [] as $path => $definition) {
-            $p = '/var/www/html/' . $path;
+            $p = '/usr/share/nginx/html/' . $path;
             if (isset($definition['mount'])) {
                 $p .= '/' . $definition['mount'];
             }
diff --git a/lib/Commands/Keys/Create.php b/lib/Commands/Keys/Create.php
index dcec238..36dbece 100644
--- a/lib/Commands/Keys/Create.php
+++ b/lib/Commands/Keys/Create.php
@@ -25,13 +25,16 @@ final class Create extends Command
 
         $process = new Process([
             'openssl', 'req',
-            '-batch',
             '-x509',
+            '-out', $this->getCompiledPath('ssl.crt'),
+            '-keyout', $this->getCompiledPath('ssl.key'),
+            '-newkey', 'rsa:2048',
             '-nodes',
+            '-sha256',
             '-days', '365',
-            '-newkey', 'rsa:2048',
-            '-keyout', $this->getCompiledPath('ssl.key'),
-            '-out', $this->getCompiledPath('ssl.crt'),
+            '-subj', '/CN=localhost',
+            '-extensions', 'EXT',
+            '-config', realpath(__DIR__ . '/../../../config/openssl-cert-request'),
         ]);
         $process->run();
 
diff --git a/lib/Commands/PHP/Port.php b/lib/Commands/PHP/Port.php
index 18cdf70..2a0f6f7 100644
--- a/lib/Commands/PHP/Port.php
+++ b/lib/Commands/PHP/Port.php
@@ -26,12 +26,19 @@ final class Port extends Command
             InputArgument::REQUIRED,
             'Port to use'
         );
+
+        $this->addArgument(
+            'ssl-port',
+            InputArgument::REQUIRED,
+            'SSL-Port to use'
+        );
     }
 
     protected function execute(InputInterface $input, OutputInterface $output)
     {
         $version = $input->getArgument('version');
-        $port = $input->getArgument('port');
+        $port = (int) $input->getArgument('port');
+        $sslPort = (int) $input->getArgument('ssl-port');
 
         $config = Config::getInstance();
         $io = new SymfonyStyle($input, $output);
@@ -42,12 +49,12 @@ final class Port extends Command
         }
 
         $php = $config->get('php');
-        $php[$version] = (int) $port;
+        $php[$version] = [$port, $sslPort];
         $config->set('php', $php);
         $config->store();
 
-        $io->success("Port of PHP version {$version} was changed to {$port}");
+        $io->success("Ports of PHP version {$version} were changed to {$port} / {$sslPort}");
 
         return Command::SUCCESS;
     }
-}
\ No newline at end of file
+}
diff --git a/lib/Commands/PHP/SetDefault.php b/lib/Commands/PHP/SetDefault.php
new file mode 100644
index 0000000..e700286
--- /dev/null
+++ b/lib/Commands/PHP/SetDefault.php
@@ -0,0 +1,59 @@
+<?php
+namespace Studip\Dockerized\Commands\PHP;
+
+use Studip\Dockerized\Config;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+
+final class SetDefault extends Command
+{
+    protected function configure()
+    {
+        $this->setName('php:default');
+        $this->setDescription('Set PHP version as default');
+        $this->addArgument(
+            'version',
+            InputArgument::REQUIRED,
+            'PHP version(s) that should be disabled',
+            null,
+            array_keys(Config::getInstance()->get('php')) + ['none']
+        );
+    }
+
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $config = Config::getInstance();
+        $io = new SymfonyStyle($input, $output);
+
+        $version = $input->getArgument('version');
+
+        if (
+            $version !== 'none'
+            && !array_key_exists($version, $config->get('php'))
+        ) {
+            $io->error("PHP version {$version} is not enabled");
+            return Command::FAILURE;
+        }
+
+        if ($version === $config->get('default')) {
+            return Command::SUCCESS;
+        }
+
+        if ($version === 'none') {
+            $config->delete('default');
+            $config->store();
+
+            $io->success('Default PHP version has been removed');
+        } else {
+            $config->set('default', $version);
+            $config->store();
+
+            $io->success("PHP version {$version} was set as default");
+        }
+
+        return Command::SUCCESS;
+    }
+}
diff --git a/lib/Config.php b/lib/Config.php
index 74cb7db..ace3ee1 100644
--- a/lib/Config.php
+++ b/lib/Config.php
@@ -40,9 +40,11 @@ final class Config
         return $this->data[$key] ?? null;
     }
 
-    public function set(string $key, mixed $value): void
+    public function set(string $key, mixed $value): Config
     {
         $this->data[$key] = $value;
+
+        return $this;
     }
 
     public function has(string $key): bool
@@ -50,9 +52,11 @@ final class Config
         return array_key_exists($key, $this->data);
     }
 
-    public function delete(string $key): void
+    public function delete(string $key): Config
     {
         unset($this->data[$key]);
+
+        return $this;
     }
 
     public function dump(): string
@@ -60,7 +64,7 @@ final class Config
         return json_encode($this->data, JSON_PRETTY_PRINT);
     }
 
-    public function reset(): void
+    public function reset(): Config
     {
         if (!file_exists($this->filename)) {
             throw new \Exception("Config file '{$this->filename}' does not exist");
@@ -71,14 +75,18 @@ final class Config
         }
 
         $this->data = json_decode(file_get_contents($this->filename), true);
+
+        return $this;
     }
 
-    public function store(): void
+    public function store(): Config
     {
         if (file_exists($this->filename) && !is_writable($this->filename)) {
             throw new \Exception("Config file '{$this->filename}' is not writable");
         }
 
         file_put_contents($this->filename, $this->dump());
+
+        return $this;
     }
-}
\ No newline at end of file
+}
diff --git a/lib/ConfigurationCreator.php b/lib/ConfigurationCreator.php
new file mode 100644
index 0000000..d0b1c78
--- /dev/null
+++ b/lib/ConfigurationCreator.php
@@ -0,0 +1,48 @@
+<?php
+namespace Studip\Dockerized;
+
+use Closure;
+use Exception;
+
+abstract class ConfigurationCreator
+{
+    private array $configuration = [];
+    private array $hooks = [];
+
+    public function hook(string $type, Closure $hook): void
+    {
+        if (!isset($this->hooks[$type])) {
+            $this->hooks[$type] = [];
+        }
+        $this->hooks[$type][] = $hook;
+    }
+
+    public function addConfiguration(string $type, mixed $data, string $key = null): void
+    {
+        if (!isset($this->configuration[$type])) {
+            $this->configuration[$type] = [];
+        }
+        if (isset($key)) {
+            $this->configuration[$type][$key] = $this->applyHooks($type, $data);
+        } else {
+            $this->configuration[$type][] = $this->applyHooks($type, $data);
+        }
+    }
+
+    protected function getConfiguration(?string $type = null, mixed $default = null): mixed
+    {
+        if (func_num_args() === 0 || $type === null) {
+            return $this->configuration;
+        }
+
+        return $this->configuration[$type] ?? $default;
+    }
+
+    protected function applyHooks(string $type, mixed $config): mixed
+    {
+        foreach ($this->hooks[$type] ?? [] as $hook) {
+            $config = $hook($config);
+        }
+        return $config;
+    }
+}
diff --git a/lib/Creators/DockerComposeConfiguration.php b/lib/Creators/DockerComposeConfiguration.php
index 0c494f3..22c8f70 100644
--- a/lib/Creators/DockerComposeConfiguration.php
+++ b/lib/Creators/DockerComposeConfiguration.php
@@ -1,11 +1,11 @@
 <?php
 namespace Studip\Dockerized\Creators;
 
+use Studip\Dockerized\ConfigurationCreator;
 use Symfony\Component\Yaml\Yaml;
 
-final class DockerComposeConfiguration
+final class DockerComposeConfiguration extends ConfigurationCreator
 {
-    private array $configuration = [];
     private string $cwd;
 
     public function __construct(string $cwd, string $name = '')
@@ -13,58 +13,45 @@ final class DockerComposeConfiguration
         $this->cwd = $cwd;
 
         if ($name) {
-            $this->configuration[$name] = $name;
+            $this->addConfiguration('name', $name);
         }
     }
 
     public function addService(string $service, ?string $image, array $config = []): void
     {
-        if (!isset($this->configuration['services'])) {
-            $this->configuration['services'] = [];
-        }
-        $this->configuration['services'][$service] = array_merge(
-            isset($image) ? compact('image') : [],
+        $this->addConfiguration('services', array_merge(
+            isset($image) ? ['image' => $image] : [],
             $config
-        );
-    }
-
-    public function extendService(string $service, array $config): void
-    {
-        if (!isset($this->configuration['services'][$service])) {
-            throw new \Exception('Service "' . $service . '" does not exist');
-        }
-
-
+        ), $service);
     }
 
     public function addServiceVolume(string $service, string $source, string $target, string $modifier = ''): void
     {
-        if (!isset($this->configuration['services'][$service])) {
+        $config = $this->getConfiguration('services', []);
+
+        if (!array_key_exists($service, $config)) {
             throw new \Exception('Service "' . $service . '" does not exist');
         }
 
-        if (!isset($this->configuration['services'][$service]['volumes'])) {
-            $this->configuration['services'][$service]['volumes'] = [];
+        if (!isset($config[$service]['volumes'])) {
+            $config[$service]['volumes'] = [];
         }
-
-        $this->configuration['services'][$service]['volumes'][] = implode(':', array_filter([
+        $config[$service]['volumes'][] = implode(':', array_filter([
             $source,
             $target,
             $modifier,
         ]));
+
+        $this->addConfiguration('services', $config[$service], $service);
     }
 
     public function addNetwork(string $network, array $config): void
     {
-        if (!isset($this->configuration['networks'])) {
-            $this->configuration['networks'] = [];
-        }
-        $this->configuration['networks'][$network] = $config;
+        $this->addConfiguration('networks', $config, $network);
     }
 
-
     public function dump(): string
     {
-        return Yaml::dump($this->configuration, 128);
+        return Yaml::dump($this->getConfiguration(), 128);
     }
-}
\ No newline at end of file
+}
diff --git a/lib/Creators/NginxConfiguration.php b/lib/Creators/NginxConfiguration.php
index 9e4c710..ffcadca 100644
--- a/lib/Creators/NginxConfiguration.php
+++ b/lib/Creators/NginxConfiguration.php
@@ -2,58 +2,32 @@
 namespace Studip\Dockerized\Creators;
 
 use RomanPitak\Nginx\Config\Scope;
+use Studip\Dockerized\ConfigurationCreator;
 
-final class NginxConfiguration
+final class NginxConfiguration extends ConfigurationCreator
 {
-    public const EVENT_LOCATION_ADD_BEFORE = 'location:add:before';
-    public const EVENT_SERVER_ADD_BEFORE = 'server:add:before';
-    public const EVENT_UPSTREAM_ADD_BEFORE = 'upstream:add:before';
-
-    private array $locations = [];
-    private array $servers = [];
-    private array $upstreams = [];
-
-    private array $event_handlers = [];
-
-    public function __construct()
-    {
-    }
-
-    public function on(string $event, \Closure $handler): void
-    {
-        if (!isset($this->event_handlers[$event])) {
-            $this->event_handlers[$event] = [];
-        }
-        $this->event_handlers[$event][] = $handler;
-    }
-
-    private function trigger(string $event, &...$data): void
-    {
-        if (isset($this->event_handlers[$event])) {
-            foreach ($this->event_handlers[$event] as $handler) {
-                $handler(...$data);
-            }
-        }
-    }
-
     public function addLocation(string $location, array $config, string $modifier = '^~')
     {
-        $location = '/' . ltrim($location, '/');
-
-        $this->trigger(self::EVENT_LOCATION_ADD_BEFORE, $location, $config, $modifier);
-        $this->locations[] = compact('location', 'modifier', 'config');
+        $this->addConfiguration('location', [
+            'location' => $location = '/' . ltrim($location, '/'),
+            'modifier' => $modifier,
+            'config'   => $config,
+        ]);
     }
 
     public function addServer(array $config)
     {
-        $this->trigger(self::EVENT_SERVER_ADD_BEFORE, $config);
-        $this->servers[] = compact('config');
+        $this->addConfiguration('server', [
+            'config' => $config,
+        ]);
     }
 
     public function addUpstream(string $upstream, array $config)
     {
-        $this->trigger(self::EVENT_UPSTREAM_ADD_BEFORE, $upstream, $config);
-        $this->upstreams[] = compact('upstream', 'config');
+        $this->addConfiguration('upstream', [
+            'upstream' => $upstream,
+            'config' => $config,
+        ]);
     }
 
     public function dump(): string
@@ -65,21 +39,21 @@ final class NginxConfiguration
                     "upstream {$upstream['upstream']}",
                     $upstream['config']
                 ),
-                $this->upstreams
+                $this->getConfiguration('upstream', [])
             ),
             ...array_map(
                 fn($server) => $this->write(
                     'server',
                     $server['config']
                 ),
-                $this->servers
+                $this->getConfiguration('server', [])
             ),
             ...array_map(
                 fn($location) => $this->write(
                     "location {$location['modifier']} {$location['location']}",
                     $location['config']
                 ),
-                $this->locations
+                $this->getConfiguration('location', [])
             ),
         ]));
         $result = (string) Scope::fromFile($tempName);
@@ -88,12 +62,7 @@ final class NginxConfiguration
         return $result;
     }
 
-    public function __toString(): string
-    {
-        return $this->dump();
-    }
-
-    public function write(string $index, array $content): string
+    private function write(string $index, array $content): string
     {
         return sprintf(
             '%s { %s }',
@@ -104,4 +73,4 @@ final class NginxConfiguration
             ))
         );
     }
-}
\ No newline at end of file
+}
diff --git a/lib/SupportedPHPVersions.php b/lib/SupportedPHPVersions.php
index 4db0926..1b70aee 100644
--- a/lib/SupportedPHPVersions.php
+++ b/lib/SupportedPHPVersions.php
@@ -4,13 +4,13 @@ namespace Studip\Dockerized;
 final class SupportedPHPVersions
 {
     private const CONFIGURATION = [
-        '7.2' => 8072,
-        '7.3' => 8073,
-        '7.4' => 8074,
-        '8.0' => 8080,
-        '8.1' => 8081,
-        '8.2' => 8082,
-        '8.3' => 8083,
+        '7.2' => [8072, 18072],
+        '7.3' => [8073, 18073],
+        '7.4' => [8074, 18074],
+        '8.0' => [8080, 18080],
+        '8.1' => [8081, 18081],
+        '8.2' => [8082, 18082],
+        '8.3' => [8083, 18083],
     ];
 
     private const DEFAULT = ['7.4', '8.1', '8.3'];
@@ -32,4 +32,4 @@ final class SupportedPHPVersions
         }
         return self::CONFIGURATION[$version];
     }
-}
\ No newline at end of file
+}
diff --git a/studip-docker b/studip-docker
index aa12677..7f8162f 100755
--- a/studip-docker
+++ b/studip-docker
@@ -20,6 +20,7 @@ if (file_exists(CONFIG_FILE)) {
     $application->add(new Commands\Sites\Remove());
     $application->add(new Commands\Sites\Show());
 
+    $application->add(new Commands\PHP\SetDefault());
     $application->add(new Commands\PHP\Disable());
     $application->add(new Commands\PHP\Enable());
     $application->add(new Commands\PHP\Port());
diff --git a/web/LogParser.php b/web/LogParser.php
new file mode 100644
index 0000000..48fad38
--- /dev/null
+++ b/web/LogParser.php
@@ -0,0 +1,41 @@
+<?php
+final class LogParser
+{
+    private string $filename;
+
+    public function __construct(string $filename)
+    {
+        $this->filename = $filename;
+    }
+
+    public function hasData(): bool
+    {
+        return file_exists($this->filename)
+            && filesize($this->filename) > 0;
+    }
+
+    public function entries(): array
+    {
+        $entries = [];
+        $index = -1;
+
+        $fp = fopen($this->filename, 'r');
+        while (!feof($fp)) {
+            $line = trim(fgets($fp));
+            if (strlen($line) === 0) {
+                continue;
+            }
+
+            if ($index === -1 || preg_match('/^\[.+]/', $line)) {
+                $index += 1;
+            }
+
+            if (!isset($entries[$index])) {
+                $entries[$index] = '';
+            }
+            $entries[$index] .= $entries[$index] ? "\n" . $line : $line;
+        }
+
+        return array_reverse($entries);
+    }
+}
diff --git a/web/assets/script.js b/web/assets/script.js
new file mode 100644
index 0000000..cd60e28
--- /dev/null
+++ b/web/assets/script.js
@@ -0,0 +1,41 @@
+const ready = new Promise(resolve => {
+    if (document.readyState === 'complete') {
+        resolve();
+    } else {
+        window.addEventListener('DOMContentLoaded', () => {
+            resolve();
+        });
+    }
+});
+
+ready.then(() => {
+    // const tabs = document.querySelector('.tabs');
+    //
+    // // Change default selected if hash is set
+    // if (window.location.hash) {
+    //     tabs.childNodes.forEach(node => {
+    //         node.classList.toggle('active', node.href === window.location.hash);
+    //     });
+    // }
+    //
+    // const selectors = Array.from(tabs.querySelectorAll('a')).map(node => {
+    //     const selector = new URL(node.href).hash;
+    //
+    //     node.addEventListener('click', (event) => {
+    //         tabs.querySelector('.active').classList.remove('active');
+    //         node.parentNode.classList.toggle('active');
+    //
+    //         selectors.forEach(s => {
+    //             document.querySelector(s).toggleAttribute('hidden', s !== selector)
+    //         });
+    //
+    //         event.preventDefault();
+    //     });
+    //
+    //     if (!node.parentNode.classList.contains('active')) {
+    //         document.querySelector(selector).setAttribute('hidden', '');
+    //     }
+    //
+    //     return selector;
+    // });
+})
diff --git a/web/assets/style.css b/web/assets/style.css
new file mode 100644
index 0000000..07e712f
--- /dev/null
+++ b/web/assets/style.css
@@ -0,0 +1,36 @@
+.tabs {
+    background-color: #fff;
+    border-bottom: 1px solid #ccc;
+    font-size: 1.5em;
+    line-height: 1.5em;
+    position: fixed;
+    top: 0;
+    left: 0;
+    right: 0;
+    height: 1.5em;
+    padding: 0 1em;
+}
+.tabs a {
+    text-decoration: none;
+}
+.tabs a:focus {
+    font-weight: bold;
+}
+
+section {
+    padding-top: 1.5em;
+}
+section:not(:target) {
+    display: none;
+}
+
+table {
+    font-size: 3.5em;
+    width: 100%;
+}
+th {
+    text-align: left;
+}
+tr:hover td {
+    background-color: #fec;
+}
diff --git a/web/index.php b/web/index.php
index 5fdebf2..a238c72 100644
--- a/web/index.php
+++ b/web/index.php
@@ -1,48 +1,78 @@
 <?php
+require_once __DIR__ . '/LogParser.php';
+
 $config = json_decode(file_get_contents(__DIR__ . '/../studip-dockerized-config.json'), true);
+$versions = $config['php'];
+ksort($versions);
+
+$log = new LogParser(__DIR__ . '/../logs/php/error.log');
 ?>
 <!doctype html>
 <html>
 <head>
-
+    <link href="assets/style.css" type="text/css" rel="stylesheet">
+    <script src="assets/script.js"></script>
+    <title>Available sites</title>
 </head>
 <body>
-<style>
-html {
-    font-size: 4em;
-}
-table {
-    width: 100%;
-}
-th {
-    text-align: left;
-}
-</style>
-<?php if (empty($config['sites'])): ?>
-    No sites defined
-<?php else: ?>
-    <table>
-        <thead>
-            <tr>
-                <th>Name</th>
-                <th colspan="<?= count($config['php']) ?>"></th>
-            </tr>
-        </thead>
-        <tbody>
-        <?php foreach ($config['sites'] ?? [] as $path => $definition): ?>
-            <tr>
-                <td><?= htmlentities($path) ?></td>
-            <?php foreach ($config['php'] as $version => $port): ?>
-                <td>
-                    <a href="https://localhost:<?= $port ?>/<?= htmlentities($path) ?>">
-                        <?= htmlentities($version) ?>
-                    </a>
-                </td>
+    <nav class="tabs">
+        <span class="active">
+            <a href="#sites">Sites</a>
+        </span>
+        <span>
+            <a href="#logs">Logs</a>
+        </span>
+    </nav>
+
+    <section id="sites">
+    <?php if (empty($config['sites'])): ?>
+        No sites defined
+    <?php else: ?>
+        <table>
+            <thead>
+                <tr>
+                    <th>Name</th>
+                    <th colspan="<?= count($config['php']) ?>"></th>
+                </tr>
+            </thead>
+            <tbody>
+            <?php foreach ($config['sites'] ?? [] as $path => $definition): ?>
+                <tr>
+                    <td>
+                    <?php if (!empty($config['default'])): ?>
+                        <a href="https://localhost/<?= htmlentities($path) ?>">
+                            <?= htmlentities($path) ?>
+                        </a>
+                    <?php else: ?>
+                        <?= htmlentities($path) ?>
+                    <?php endif; ?>
+                    </td>
+                    <?php foreach ($versions as $version => [$port, $sslPort]): ?>
+                        <td>
+                            <a href="https://localhost:<?= $port ?>/<?= htmlentities($path) ?>">
+                                <?= htmlentities($version) ?>
+                            </a>
+                            <a href="https://localhost:<?= $sslPort ?>/<?= htmlentities($path) ?>">
+                                (SSL)
+                            </a>
+                        </td>
+                    <?php endforeach; ?>
+                </tr>
             <?php endforeach; ?>
-            </tr>
+            </tbody>
+        </table>
+    <?php endif; ?>
+
+    </section>
+
+    <section id="logs">
+    <?php if ($log->hasData()): ?>
+        <ul>
+        <?php foreach ($log->entries() as $entry): ?>
+            <li><?= nl2br(htmlentities($entry)) ?></li>
         <?php endforeach; ?>
-        </tbody>
-    </table>
-<?php endif; ?>
+        </ul>
+    <?php endif; ?>
+    </section>
 </body>
 </html>
-- 
GitLab