Finish 3.6

This commit is contained in:
Thomas Peterson 2026-06-19 17:07:43 +02:00
parent cbe894293c
commit 05ecf57822
8 changed files with 340 additions and 16 deletions

View File

@ -129,7 +129,6 @@
"symfony/stopwatch": "*",
"symfony/web-profiler-bundle": "*",
"symplify/config-transformer": "^11.1",
"tomasvotruba/symfony-config-generator": "^0.1.5",
"vincentlanglet/twig-cs-fixer": "^3.5"
},
"config": {

View File

@ -17,6 +17,7 @@ use Doctrine\ORM\EntityManagerInterface;
use PSC\Shop\ContactBundle\Repository\ContactRepository;
use PSC\Shop\OrderBundle\Service\Order;
use PSC\Shop\QueueBundle\Service\Queue\Manager;
use PSC\System\SettingsBundle\Service\DiskUsage;
use PSC\System\SettingsBundle\Service\Instance;
use PSC\System\SettingsBundle\Service\Shop;
use PSC\System\UpdateBundle\Service\Migration;
@ -59,6 +60,7 @@ class DashboardController extends AbstractController
ChartBuilderInterface $chartBuilder,
ContactRepository $contactRepository,
Order $orderService,
DiskUsage $diskUsage,
) {
// Muss vor dem ersten Laden des Shops geprüft werden: ausstehende
// Migrationen können Spalten ergänzen, die das Shop-Entity bereits mappt
@ -170,6 +172,7 @@ class DashboardController extends AbstractController
'queueErrorCount' => $queueService->getErrorJobCount(),
'instance' => $instanceService->getInstance(),
'chart' => $chart,
'diskUsage' => $this->isGranted('ROLE_ADMIN') ? $diskUsage->getUsage() : null,
];
}
}

View File

@ -8,6 +8,7 @@
{% block body %}
<div class="grid grid-cols-1 lg:grid-cols-2 filament-widgets-container gap-4 lg:gap-8 mb-6">
{% if is_granted('ROLE_ADMIN') %}
<div class="space-y-4 lg:space-y-8">
<div class="rounded-sm border border bg-white px-7.5 py-6 shadow-lg dark:border-strokedark dark:bg-boxdark">
<div class="p-2 space-y-2">
<div class="flex items-center justify-between gap-8 px-4 py-2 mb-2">
@ -28,22 +29,123 @@
</table>
</div>
<div class="rounded-sm border border bg-white px-7.5 py-6 shadow-lg dark:border-strokedark dark:bg-boxdark">
<div class="p-2 space-y-2">
<div class="flex items-center justify-between gap-8 px-4 py-2 mb-2">
<h2 class="text-xl font-medium tracking-tight filament-card-heading">
<div class="rounded-sm border border bg-white px-5 py-4 shadow-lg dark:border-strokedark dark:bg-boxdark">
<div class="px-2 pt-1 pb-2">
<h2 class="text-lg font-medium tracking-tight filament-card-heading mb-2">
Warnhinweise
</h2>
</div>
<div aria-hidden="true" class="filament-hr border-t dark:border-gray-700"></div>
</div>
<table class="w-full text-start divide-y table-auto">
<table class="w-full text-start divide-y table-auto text-sm">
<tbody>
<tr><td class="px-4 py-3">Aktionen:</td><td class="px-4 py-3"><p>{% if queueErrorCount > 0 %}<span class="bg-red-100 text-red-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded">{{ queueErrorCount }}</span>{% else %}<span class="bg-green-100 text-green-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded">Alles Ok</span>{% endif %}</p></td></tr>
<tr><td class="px-4 py-3">Mailserver:</td><td class="px-4 py-3"><p>{% if not instance.isSmtpOwn %}<span class="bg-red-100 text-red-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded">Kein eigener definiert</span>{% else %}{% if instance.smtpHost == '' or instance.smtpPort == '' or instance.smtpUsername == '' or instance.smtpPassword == '' %}<span class="bg-yellow-500 text-yellow-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded">Smtp Einstellungen überprüfen</span>{% else %}<span class="bg-green-100 text-green-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded">Alles Ok</span>{% endif %}{% endif %}</p></td></tr>
<tr><td class="px-4 py-2">Aktionen:</td><td class="px-4 py-2"><p>{% if queueErrorCount > 0 %}<span class="bg-red-100 text-red-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded">{{ queueErrorCount }}</span>{% else %}<span class="bg-green-100 text-green-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded">Alles Ok</span>{% endif %}</p></td></tr>
<tr><td class="px-4 py-2">Mailserver:</td><td class="px-4 py-2"><p>{% if not instance.isSmtpOwn %}<span class="bg-red-100 text-red-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded">Kein eigener definiert</span>{% else %}{% if instance.smtpHost == '' or instance.smtpPort == '' or instance.smtpUsername == '' or instance.smtpPassword == '' %}<span class="bg-yellow-500 text-yellow-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded">Smtp Einstellungen überprüfen</span>{% else %}<span class="bg-green-100 text-green-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded">Alles Ok</span>{% endif %}{% endif %}</p></td></tr>
</tbody>
</table>
</div>
</div>
{% if diskUsage %}
<div class="rounded-sm border border bg-white px-5 py-4 shadow-lg dark:border-strokedark dark:bg-boxdark">
<div class="px-2 pt-1 pb-2">
<h2 class="text-lg font-medium tracking-tight filament-card-heading mb-2">
Speicherverbrauch
</h2>
<div aria-hidden="true" class="filament-hr border-t dark:border-gray-700"></div>
</div>
{# Obere Liste wird gegen das Daten-Volume (/dev/sdb) abgeglichen.
Fallback, falls der (evtl. ältere) Cache diese Keys nicht enthält. #}
{% set dataDisk = diskUsage.dataDisk|default({ 'total': null, 'free': null, 'used': null }) %}
{% set items = [{ label: 'Datenbank', size: diskUsage.database }] %}
{% for label, size in diskUsage.directories %}
{% set items = items|merge([{ label: label, size: size }]) %}
{% endfor %}
{% set colors = ['bg-psc-500', 'bg-sky-500', 'bg-emerald-500', 'bg-amber-500', 'bg-violet-500', 'bg-rose-500'] %}
{# Übrige Belegung = belegter Speicher des Daten-Volumes außerhalb der überwachten Verzeichnisse #}
{% if dataDisk.total %}
{% set trackedSize = 0 %}
{% for item in items %}{% if item.size is not null %}{% set trackedSize = trackedSize + item.size %}{% endif %}{% endfor %}
{% set otherUsed = dataDisk.used - trackedSize %}
{% if otherUsed < 0 %}{% set otherUsed = 0 %}{% endif %}
{% set items = items|merge([{ label: 'Übrige Belegung', size: otherUsed, color: 'bg-gray-400 dark:bg-gray-500' }]) %}
{% endif %}
{% set totalSize = 0 %}
{% for item in items %}
{% if item.size is not null %}{% set totalSize = totalSize + item.size %}{% endif %}
{% endfor %}
<div class="px-2 py-1 space-y-3">
{% for item in items %}
{% set percent = (totalSize > 0 and item.size is not null) ? (item.size / totalSize * 100) : 0 %}
<div>
<div class="flex items-center justify-between mb-1 text-sm">
<span class="font-medium text-gray-700 dark:text-gray-200">{{ item.label }}</span>
<span class="font-medium text-gray-900 dark:text-white">
{% if item.size is not null %}{{ item.size|readable_filesize }}<span class="ml-1 text-xs font-normal text-gray-400">({{ percent < 1 ? percent|round(2) : percent|round(1) }}%)</span>{% else %}<span class="text-gray-400">n/a</span>{% endif %}
</span>
</div>
<div class="w-full h-2 rounded-full bg-gray-100 dark:bg-gray-700 overflow-hidden">
<div class="h-2 rounded-full {{ item.color|default(colors[loop.index0 % colors|length]) }} transition-all duration-500"
style="width: {{ percent > 0 and percent < 2 ? 2 : percent|round(1) }}%"></div>
</div>
</div>
{% endfor %}
{# Dateisysteme: System (Overlay) und Data (Volume) jeweils mit Icon, Auslastungsbalken und Belegt/Frei/Gesamt #}
{% set systemDisk = diskUsage.systemDisk|default({ 'total': null, 'free': null, 'used': null }) %}
{% if systemDisk.total %}
<div class="pt-3 mt-1 border-t dark:border-gray-700">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-full bg-psc-50 text-psc-500 dark:bg-gray-700">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 0 0 2.25-2.25V6.75a2.25 2.25 0 0 0-2.25-2.25H6.75A2.25 2.25 0 0 0 4.5 6.75v10.5a2.25 2.25 0 0 0 2.25 2.25Zm.75-12h9v9h-9v-9Z" />
</svg>
</div>
<div class="flex-1">
<div class="flex items-center justify-between mb-1 text-sm">
<span class="font-medium text-gray-700 dark:text-gray-200">System <span class="text-xs font-normal text-gray-400">(Overlay)</span></span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ systemDisk.used|readable_filesize }} / {{ systemDisk.total|readable_filesize }}</span>
</div>
<div class="w-full h-2 rounded-full bg-gray-100 dark:bg-gray-700 overflow-hidden">
<div class="h-2 rounded-full bg-psc-500 transition-all duration-500" style="width: {{ (systemDisk.used / systemDisk.total * 100)|round(1) }}%"></div>
</div>
<div class="flex items-center justify-between mt-1 text-xs">
<span class="text-gray-500 dark:text-gray-400">Belegt <span class="font-semibold text-gray-900 dark:text-white">{{ systemDisk.used|readable_filesize }}</span> ({{ (systemDisk.used / systemDisk.total * 100)|round(1) }}%)</span>
<span class="text-gray-500 dark:text-gray-400">Frei <span class="font-semibold text-emerald-600 dark:text-emerald-400">{{ systemDisk.free|readable_filesize }}</span></span>
</div>
</div>
</div>
</div>
{% endif %}
{% set dataDisk = diskUsage.dataDisk|default({ 'total': null, 'free': null, 'used': null }) %}
{% if dataDisk.total %}
<div class="pt-3 mt-1 border-t dark:border-gray-700">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-full bg-psc-50 text-psc-500 dark:bg-gray-700">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
</svg>
</div>
<div class="flex-1">
<div class="flex items-center justify-between mb-1 text-sm">
<span class="font-medium text-gray-700 dark:text-gray-200">Data <span class="text-xs font-normal text-gray-400">(Volume)</span></span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ dataDisk.used|readable_filesize }} / {{ dataDisk.total|readable_filesize }}</span>
</div>
<div class="w-full h-2 rounded-full bg-gray-100 dark:bg-gray-700 overflow-hidden">
<div class="h-2 rounded-full bg-psc-500 transition-all duration-500" style="width: {{ (dataDisk.used / dataDisk.total * 100)|round(1) }}%"></div>
</div>
<div class="flex items-center justify-between mt-1 text-xs">
<span class="text-gray-500 dark:text-gray-400">Belegt <span class="font-semibold text-gray-900 dark:text-white">{{ dataDisk.used|readable_filesize }}</span> ({{ (dataDisk.used / dataDisk.total * 100)|round(1) }}%)</span>
<span class="text-gray-500 dark:text-gray-400">Frei <span class="font-semibold text-emerald-600 dark:text-emerald-400">{{ dataDisk.free|readable_filesize }}</span></span>
</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% endif %}
<div class="col-span-full">
<div class="rounded-sm border border bg-white px-7.5 py-6 shadow-lg dark:border-strokedark dark:bg-boxdark">

View File

@ -8,6 +8,19 @@ services:
PSC\System\SettingsBundle\:
resource: '../../*/*'
PSC\System\SettingsBundle\Service\DiskUsage:
arguments:
$directories:
'Uploads (Data Volume)': '/data/www/old/public/uploads'
'Pakete (Data Volume)': '/data/www/old/data/packages'
'Templateprint Layouter (Data Volume)': '/data/www/old/market/templateprint'
'Form Based Layouter (Data Volume)': '/data/www/old/market/collectlayouter'
'Creative Layouter (Data Volume)': '/data/www/old/market/steplayouter'
# Daten-Volume (/dev/sdb): Bezug für die obere Liste inkl. "Übrige Belegung"
$dataPath: '/data/www/new/watch'
# System-/Overlay-Dateisystem: Bezug für Belegt/Frei/Gesamt unten
$systemPath: '/'
PSC\System\SettingsBundle\Form\Backend\CopyType:
tags:
- { name: form.type }

View File

@ -0,0 +1,144 @@
<?php
namespace PSC\System\SettingsBundle\Service;
use Doctrine\DBAL\Connection;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
/**
* Ermittelt den Speicherverbrauch der Datenbank und der konfigurierten
* Verzeichnisse für die Anzeige im Dashboard.
*
* Da das Durchlaufen großer Verzeichnisse teuer ist, werden die Ergebnisse
* zwischengespeichert, damit das Dashboard schnell bleibt.
*/
class DiskUsage
{
/**
* @param array<string,string> $directories Label => absoluter Pfad
* @param string $dataPath Pfad auf dem Daten-Volume (z.B. /dev/sdb), gegen das
* die überwachten Verzeichnisse abgeglichen werden
* @param string $systemPath Pfad auf dem System-/Overlay-Dateisystem
*/
public function __construct(
private Connection $connection,
private CacheInterface $cache,
private array $directories,
private string $dataPath = '/data/www/old',
private string $systemPath = '/',
private int $cacheTtl = 3600,
) {
}
/**
* @return array{
* database: ?int,
* directories: array<string,?int>,
* dataDisk: array{total: ?int, free: ?int, used: ?int},
* systemDisk: array{total: ?int, free: ?int, used: ?int}
* }
*/
public function getUsage(): array
{
// Versionssuffix: ändert sich die Struktur, werden alte Cache-Einträge ignoriert
return $this->cache->get('psc_dashboard_disk_usage_v2', function (ItemInterface $item): array {
$item->expiresAfter($this->cacheTtl);
return [
'database' => $this->getDatabaseSize(),
'directories' => $this->getDirectorySizes(),
'dataDisk' => $this->getDiskSpace($this->dataPath),
'systemDisk' => $this->getDiskSpace($this->systemPath),
];
});
}
/**
* Gesamtkapazität, freier und belegter Speicher des Dateisystems am
* angegebenen Pfad in Bytes.
*
* @return array{total: ?int, free: ?int, used: ?int}
*/
private function getDiskSpace(string $path): array
{
if (!is_dir($path)) {
return ['total' => null, 'free' => null, 'used' => null];
}
$total = @disk_total_space($path);
$free = @disk_free_space($path);
if ($total === false || $free === false) {
return ['total' => null, 'free' => null, 'used' => null];
}
return [
'total' => (int) $total,
'free' => (int) $free,
'used' => (int) ($total - $free),
];
}
/**
* Größe der aktuellen Datenbank in Bytes (Daten + Indizes).
*/
private function getDatabaseSize(): ?int
{
try {
$database = $this->connection->getDatabase();
if ($database === null) {
return null;
}
$size = $this->connection->fetchOne(
'SELECT SUM(data_length + index_length) FROM information_schema.TABLES WHERE table_schema = :db',
['db' => $database],
);
return $size !== false && $size !== null ? (int) $size : null;
} catch (\Throwable) {
return null;
}
}
/**
* @return array<string,?int> Label => Größe in Bytes (oder null)
*/
private function getDirectorySizes(): array
{
$result = [];
foreach ($this->directories as $label => $path) {
$result[$label] = $this->getDirectorySize($path);
}
return $result;
}
/**
* Größe eines Verzeichnisses in Bytes via "du" (schnell). Liefert null,
* wenn das Verzeichnis fehlt oder nicht ermittelt werden kann.
*/
private function getDirectorySize(string $path): ?int
{
if (!is_dir($path)) {
return null;
}
$output = [];
$exitCode = 0;
@exec('du -sb ' . escapeshellarg($path) . ' 2>/dev/null', $output, $exitCode);
if ($exitCode === 0 && isset($output[0])) {
$parts = preg_split('/\s+/', trim($output[0]));
if (isset($parts[0]) && is_numeric($parts[0])) {
return (int) $parts[0];
}
}
return null;
}
}

View File

@ -197,7 +197,7 @@ class StartController extends AbstractController
$docData = [];
if ($contactEntity->getAbteilung() != '') {
$docData[] = [
'name' => 'data[grad][enable]',
'name' => 'data[betrieb][enable]',
'value' => '1',
];
}

View File

@ -1587,6 +1587,10 @@ html {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid-cols-3{
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.grid-cols-4{
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@ -1974,6 +1978,11 @@ html {
border-color: rgb(250 204 21 / var(--tw-border-opacity));
}
.bg-amber-500{
--tw-bg-opacity: 1;
background-color: rgb(245 158 11 / var(--tw-bg-opacity));
}
.bg-black\/70{
background-color: rgb(0 0 0 / 0.7);
}
@ -2003,6 +2012,11 @@ html {
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
}
.bg-emerald-500{
--tw-bg-opacity: 1;
background-color: rgb(16 185 129 / var(--tw-bg-opacity));
}
.bg-gray-100{
--tw-bg-opacity: 1;
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
@ -2110,6 +2124,16 @@ html {
background-color: rgb(220 38 38 / var(--tw-bg-opacity));
}
.bg-rose-500{
--tw-bg-opacity: 1;
background-color: rgb(244 63 94 / var(--tw-bg-opacity));
}
.bg-sky-500{
--tw-bg-opacity: 1;
background-color: rgb(14 165 233 / var(--tw-bg-opacity));
}
.bg-slate-100{
--tw-bg-opacity: 1;
background-color: rgb(241 245 249 / var(--tw-bg-opacity));
@ -2125,6 +2149,11 @@ html {
background-color: rgb(245 245 244 / var(--tw-bg-opacity));
}
.bg-violet-500{
--tw-bg-opacity: 1;
background-color: rgb(139 92 246 / var(--tw-bg-opacity));
}
.bg-white{
--tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
@ -2312,10 +2341,18 @@ html {
padding-right: 1rem;
}
.pt-1{
padding-top: 0.25rem;
}
.pt-2{
padding-top: 0.5rem;
}
.pt-3{
padding-top: 0.75rem;
}
.text-left{
text-align: left;
}
@ -2453,6 +2490,11 @@ html {
color: rgb(30 64 175 / var(--tw-text-opacity));
}
.text-emerald-600{
--tw-text-opacity: 1;
color: rgb(5 150 105 / var(--tw-text-opacity));
}
.text-gray-400{
--tw-text-opacity: 1;
color: rgb(156 163 175 / var(--tw-text-opacity));
@ -2777,6 +2819,10 @@ html {
transition-duration: 300ms;
}
.duration-500{
transition-duration: 500ms;
}
.duration-75{
transition-duration: 75ms;
}
@ -4511,6 +4557,11 @@ html {
background-color: rgb(30 58 138 / 0.2);
}
:is(.dark .dark\:bg-gray-500){
--tw-bg-opacity: 1;
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
}
:is(.dark .dark\:bg-gray-600){
--tw-bg-opacity: 1;
background-color: rgb(75 85 99 / var(--tw-bg-opacity));
@ -4581,6 +4632,11 @@ html {
color: rgb(59 130 246 / var(--tw-text-opacity));
}
:is(.dark .dark\:text-emerald-400){
--tw-text-opacity: 1;
color: rgb(52 211 153 / var(--tw-text-opacity));
}
:is(.dark .dark\:text-gray-100){
--tw-text-opacity: 1;
color: rgb(243 244 246 / var(--tw-text-opacity));
@ -4968,6 +5024,12 @@ html {
gap: 2rem;
}
.lg\:space-y-8 > :not([hidden]) ~ :not([hidden]){
--tw-space-y-reverse: 0;
margin-top: calc(2rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(2rem * var(--tw-space-y-reverse));
}
.lg\:border-r{
border-right-width: 1px;
}

View File

@ -1,11 +1,12 @@
info:
datum: 02.06.2026
datum: 10.06.2026
release: 2.3.6
changelog:
- version: 2.3.6
datum: 02.06.2026
datum: 10.06.2026
changes:
- "Verbrauchsanzeige im Dashboard"
- "Shop kann eigene SMTP Einstellungen haben"
- "Passwort Start und Finish Aktion Absender und Empfänger Bug behoben"
- "Form Based Layouter speichert jetzt die Firma vom angemeldeten Benutzer"