DB Updater Install
This commit is contained in:
+107
-3
@@ -34,6 +34,10 @@ function admin_update_summary(array $items): array
|
||||
foreach ($items as $item) {
|
||||
$status = (string) ($item['status'] ?? 'pending');
|
||||
|
||||
if ($status === 'baselined') {
|
||||
$status = 'success';
|
||||
}
|
||||
|
||||
if (array_key_exists($status, $summary)) {
|
||||
$summary[$status]++;
|
||||
}
|
||||
@@ -79,6 +83,7 @@ $page = match ($path) {
|
||||
'/admin/login' => 'login',
|
||||
'/admin/tenants' => 'tenants',
|
||||
'/admin/updates' => 'updates',
|
||||
'/admin/migration' => 'migration',
|
||||
'/admin/logout' => 'logout',
|
||||
default => 'login',
|
||||
};
|
||||
@@ -94,6 +99,8 @@ $summaryMetrics = [];
|
||||
$licensePlans = [];
|
||||
$updateItems = [];
|
||||
$updateSummary = ['pending' => 0, 'success' => 0, 'failed' => 0, 'running' => 0];
|
||||
$migrationItems = [];
|
||||
$migrationSummary = ['pending' => 0, 'success' => 0, 'failed' => 0, 'running' => 0];
|
||||
$appVersion = is_file(dirname(__DIR__) . '/version.php') ? (string) require dirname(__DIR__) . '/version.php' : 'unbekannt';
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST' && $page === 'logout') {
|
||||
@@ -116,7 +123,7 @@ if ($page === 'login' && $pdo instanceof PDO) {
|
||||
$admin = app_admin_user();
|
||||
}
|
||||
|
||||
if (in_array($page, ['overview', 'tenants', 'updates'], true)) {
|
||||
if (in_array($page, ['overview', 'tenants', 'updates', 'migration'], true)) {
|
||||
$admin = app_require_platform_admin();
|
||||
|
||||
if (!$pdo instanceof PDO) {
|
||||
@@ -124,7 +131,7 @@ if (in_array($page, ['overview', 'tenants', 'updates'], true)) {
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array($page, ['overview', 'tenants', 'updates'], true) && $pdo instanceof PDO) {
|
||||
if (in_array($page, ['overview', 'tenants', 'updates', 'migration'], true) && $pdo instanceof PDO) {
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST' && !hash_equals($_SESSION['admin_csrf'], (string) ($_POST['csrf'] ?? ''))) {
|
||||
app_flash('Die Sitzung ist abgelaufen. Bitte lade die Seite neu.', 'error');
|
||||
app_redirect('/admin/' . ($page === 'overview' ? '' : $page . '/'));
|
||||
@@ -155,10 +162,33 @@ if (in_array($page, ['overview', 'tenants', 'updates'], true) && $pdo instanceof
|
||||
app_redirect('/admin/updates/');
|
||||
}
|
||||
|
||||
if ($page === 'migration' && (string) ($_POST['action'] ?? '') === 'run-migrations') {
|
||||
try {
|
||||
$results = scripts_run_pending_migrations(
|
||||
scripts_update_config_from_env(app_env()),
|
||||
'admin:' . (string) ($admin['email'] ?? 'platform')
|
||||
);
|
||||
|
||||
$successful = count(array_filter($results, static fn(array $result): bool => in_array(($result['status'] ?? ''), ['success', 'baselined'], true)));
|
||||
$skipped = count(array_filter($results, static fn(array $result): bool => ($result['status'] ?? '') === 'skipped'));
|
||||
|
||||
app_flash(
|
||||
$successful . ' Migration(en) verarbeitet, ' . $skipped . ' bereits erledigt.',
|
||||
'success'
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
app_flash('Die Migrationen konnten nicht abgeschlossen werden: ' . $exception->getMessage(), 'error');
|
||||
}
|
||||
|
||||
app_redirect('/admin/migration/');
|
||||
}
|
||||
|
||||
$tenants = app_admin_tenant_list($pdo);
|
||||
$licensePlans = app_license_plan_options($pdo);
|
||||
$updateItems = scripts_list_updates_status(scripts_update_config_from_env(app_env()));
|
||||
$updateSummary = admin_update_summary($updateItems);
|
||||
$migrationItems = scripts_list_migrations_status(scripts_update_config_from_env(app_env()));
|
||||
$migrationSummary = admin_update_summary($migrationItems);
|
||||
$summaryMetrics = admin_summary_metrics($tenants, $updateItems);
|
||||
|
||||
if ($page === 'tenants' && isset($_GET['edit']) && $_GET['edit'] !== '') {
|
||||
@@ -211,6 +241,7 @@ SQL,
|
||||
<a href="/admin/" class="<?= $page === 'overview' ? 'active' : '' ?>">Übersicht</a>
|
||||
<a href="/admin/tenants/" class="<?= $page === 'tenants' ? 'active' : '' ?>">Mandanten</a>
|
||||
<a href="/admin/updates/" class="<?= $page === 'updates' ? 'active' : '' ?>">Updates</a>
|
||||
<a href="/admin/migration/" class="<?= $page === 'migration' ? 'active' : '' ?>">Migrationen</a>
|
||||
<form method="post" action="/admin/logout/">
|
||||
<input type="hidden" name="csrf" value="<?= admin_h($_SESSION['admin_csrf']) ?>">
|
||||
<button type="submit" class="button secondary">Abmelden</button>
|
||||
@@ -371,7 +402,7 @@ SQL,
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<?php else: ?>
|
||||
<?php elseif ($page === 'updates'): ?>
|
||||
<section class="hero">
|
||||
<div class="eyebrow">Update-Center</div>
|
||||
<h1>System-Updates zentral prüfen und ausführen.</h1>
|
||||
@@ -433,6 +464,79 @@ SQL,
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<?php else: ?>
|
||||
<section class="hero">
|
||||
<div class="eyebrow">Migrations-Center</div>
|
||||
<h1>Datenbank-Migrationen zentral prüfen und ausführen.</h1>
|
||||
<p>Die SQL-Migrationen aus <code>database/migrations/</code> können hier nachvollziehbar über die zentrale Verwaltung eingespielt werden.</p>
|
||||
</section>
|
||||
<?php if ($dbError !== null): ?><section class="alert alert-warning"><?= admin_h($dbError) ?></section><?php endif; ?>
|
||||
<section class="grid grid-3">
|
||||
<article class="card">
|
||||
<h2>Schema-Stand</h2>
|
||||
<div class="stack">
|
||||
<div class="metric"><h3>Offene Migrationen</h3><p><?= admin_h((string) $migrationSummary['pending']) ?></p></div>
|
||||
<div class="metric"><h3>Erfolgreich</h3><p><?= admin_h((string) $migrationSummary['success']) ?></p></div>
|
||||
<div class="metric"><h3>Fehlgeschlagen</h3><p><?= admin_h((string) $migrationSummary['failed']) ?></p></div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>Migration-Ausführung</h2>
|
||||
<p>Vorhandene Tabellen werden automatisch als bestehender Stand übernommen. Neue Migrationen werden nur einmal ausgeführt und protokolliert.</p>
|
||||
<form method="post" action="/admin/migration/" style="margin-top:18px">
|
||||
<input type="hidden" name="csrf" value="<?= admin_h($_SESSION['admin_csrf']) ?>">
|
||||
<input type="hidden" name="action" value="run-migrations">
|
||||
<button type="submit">Ausstehende Migrationen jetzt ausführen</button>
|
||||
</form>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>Hinweise</h2>
|
||||
<ul>
|
||||
<li>Migrationen liegen unter <code>saas-app/database/migrations/</code>.</li>
|
||||
<li>Die Weboberfläche führt nur fehlende Migrationen aus.</li>
|
||||
<li>Bereits vorhandene Zieltabellen werden als Baseline protokolliert.</li>
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
<section class="card" style="margin-top:18px">
|
||||
<h2>Migrations-Protokoll</h2>
|
||||
<div class="table">
|
||||
<table>
|
||||
<thead><tr><th>Migration</th><th>Status</th><th>Zieltabellen</th><th>Ausgeführt</th><th>Durch</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($migrationItems as $item): ?>
|
||||
<?php
|
||||
$status = (string) ($item['status'] ?? 'pending');
|
||||
$tone = match ($status) {
|
||||
'success' => 'success',
|
||||
'baselined' => 'success',
|
||||
'failed' => 'error',
|
||||
'running' => 'warning',
|
||||
default => 'neutral',
|
||||
};
|
||||
$label = match ($status) {
|
||||
'pending' => 'Offen',
|
||||
'baselined' => 'Baseline',
|
||||
default => ucfirst($status),
|
||||
};
|
||||
?>
|
||||
<tr>
|
||||
<td>
|
||||
<strong><?= admin_h((string) $item['title']) ?></strong>
|
||||
<small class="mono"><?= admin_h((string) $item['key']) ?></small>
|
||||
<?php if (!empty($item['notes'])): ?><small><?= admin_h((string) $item['notes']) ?></small><?php endif; ?>
|
||||
<?php if (!empty($item['error_message'])): ?><small><?= admin_h((string) $item['error_message']) ?></small><?php endif; ?>
|
||||
</td>
|
||||
<td><?= admin_badge($label, $tone) ?></td>
|
||||
<td><?= admin_h(implode(', ', array_map('strval', $item['tables'] ?? []))) ?></td>
|
||||
<td><?= admin_h((string) ($item['executed_at'] ?? '')) ?></td>
|
||||
<td><?= admin_h((string) ($item['executed_by'] ?? '')) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<p class="footer">Kaffeeliste Admin | zentrale Mandanten-Verwaltung, Lizenzsteuerung und Update-Prozess über PHP-Webseiten</p>
|
||||
|
||||
@@ -2,5 +2,4 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Location: /admin/', true, 302);
|
||||
exit;
|
||||
require dirname(__DIR__, 2) . '/admin.php';
|
||||
|
||||
@@ -868,6 +868,313 @@ function scripts_run_pending_updates(array $config, ?string $executedBy = null):
|
||||
return $results;
|
||||
}
|
||||
|
||||
function scripts_ensure_migrations_table(PDO $pdo, string $connection): void
|
||||
{
|
||||
if (scripts_table_exists($pdo, 'app_migrations')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($connection === 'sqlsrv') {
|
||||
$pdo->exec(
|
||||
<<<'SQL'
|
||||
CREATE TABLE app_migrations (
|
||||
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||
migration_key VARCHAR(190) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
checksum VARCHAR(64) NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
executed_at DATETIME NULL,
|
||||
execution_time_ms INT NULL,
|
||||
executed_by VARCHAR(255) NULL,
|
||||
notes NVARCHAR(MAX) NULL,
|
||||
error_message NVARCHAR(MAX) NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
CONSTRAINT app_migrations_migration_key_unique UNIQUE (migration_key)
|
||||
);
|
||||
SQL
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$pdo->exec(
|
||||
<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS app_migrations (
|
||||
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||
migration_key VARCHAR(190) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
checksum VARCHAR(64) NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
executed_at DATETIME NULL,
|
||||
execution_time_ms INT NULL,
|
||||
executed_by VARCHAR(255) NULL,
|
||||
notes TEXT NULL,
|
||||
error_message TEXT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
UNIQUE KEY app_migrations_migration_key_unique (migration_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
SQL
|
||||
);
|
||||
}
|
||||
|
||||
function scripts_migration_files(): array
|
||||
{
|
||||
$dir = scripts_saas_app_path()
|
||||
. DIRECTORY_SEPARATOR . 'database'
|
||||
. DIRECTORY_SEPARATOR . 'migrations';
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$files = glob($dir . DIRECTORY_SEPARATOR . '*.php') ?: [];
|
||||
sort($files, SORT_STRING);
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
function scripts_declared_tables_from_sql(string $sql): array
|
||||
{
|
||||
preg_match_all(
|
||||
'/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"\[]?([A-Za-z0-9_.]+)[`"\]]?/i',
|
||||
$sql,
|
||||
$matches
|
||||
);
|
||||
|
||||
$tables = array_map(
|
||||
static fn(string $table): string => trim($table),
|
||||
$matches[1] ?? []
|
||||
);
|
||||
|
||||
return array_values(array_unique(array_filter($tables, static fn(string $table): bool => $table !== '')));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function scripts_load_migration_definition(string $file, string $connection): array
|
||||
{
|
||||
$sql = scripts_load_migration_sql($file, $connection);
|
||||
$key = pathinfo($file, PATHINFO_FILENAME);
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'title' => $key,
|
||||
'file' => $file,
|
||||
'checksum' => sha1_file($file) ?: null,
|
||||
'sql' => $sql,
|
||||
'tables' => scripts_declared_tables_from_sql($sql),
|
||||
];
|
||||
}
|
||||
|
||||
function scripts_applied_migrations(PDO $pdo): array
|
||||
{
|
||||
if (!scripts_table_exists($pdo, 'app_migrations')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = scripts_query_all(
|
||||
$pdo,
|
||||
'SELECT migration_key, status, checksum, executed_at, execution_time_ms, executed_by, notes, error_message, title, id FROM app_migrations ORDER BY migration_key ASC'
|
||||
);
|
||||
$applied = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$applied[(string) $row['migration_key']] = $row;
|
||||
}
|
||||
|
||||
return $applied;
|
||||
}
|
||||
|
||||
function scripts_migration_targets_exist(PDO $pdo, array $definition): bool
|
||||
{
|
||||
$tables = $definition['tables'] ?? [];
|
||||
|
||||
if (!is_array($tables) || $tables === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($tables as $table) {
|
||||
if (!scripts_table_exists($pdo, (string) $table)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function scripts_list_migrations_status(array $config): array
|
||||
{
|
||||
$pdo = scripts_connect_pdo($config);
|
||||
$connection = scripts_normalize_db_connection((string) ($config['connection'] ?? 'mysql'));
|
||||
scripts_ensure_migrations_table($pdo, $connection);
|
||||
|
||||
$applied = scripts_applied_migrations($pdo);
|
||||
$items = [];
|
||||
|
||||
foreach (scripts_migration_files() as $file) {
|
||||
$definition = scripts_load_migration_definition($file, $connection);
|
||||
$current = $applied[$definition['key']] ?? null;
|
||||
$status = $current['status'] ?? 'pending';
|
||||
$notes = $current['notes'] ?? null;
|
||||
|
||||
if ($current === null && scripts_migration_targets_exist($pdo, $definition)) {
|
||||
$status = 'baselined';
|
||||
$notes = 'Tabellen sind bereits vorhanden und koennen als bestehender Stand uebernommen werden.';
|
||||
} elseif ($status === 'success') {
|
||||
$status = 'success';
|
||||
} elseif ($status === 'running') {
|
||||
$status = 'running';
|
||||
} elseif ($current !== null) {
|
||||
$status = 'failed';
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'key' => $definition['key'],
|
||||
'title' => $definition['title'],
|
||||
'status' => $status,
|
||||
'checksum' => $definition['checksum'],
|
||||
'tables' => $definition['tables'],
|
||||
'executed_at' => $current['executed_at'] ?? null,
|
||||
'execution_time_ms' => $current['execution_time_ms'] ?? null,
|
||||
'executed_by' => $current['executed_by'] ?? null,
|
||||
'notes' => $notes,
|
||||
'error_message' => $current['error_message'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
function scripts_run_pending_migrations(array $config, ?string $executedBy = null): array
|
||||
{
|
||||
$pdo = scripts_connect_pdo($config);
|
||||
$connection = scripts_normalize_db_connection((string) ($config['connection'] ?? 'mysql'));
|
||||
scripts_ensure_migrations_table($pdo, $connection);
|
||||
$applied = scripts_applied_migrations($pdo);
|
||||
$results = [];
|
||||
|
||||
foreach (scripts_migration_files() as $file) {
|
||||
$definition = scripts_load_migration_definition($file, $connection);
|
||||
$current = $applied[$definition['key']] ?? null;
|
||||
|
||||
if (($current['status'] ?? null) === 'success') {
|
||||
$results[] = [
|
||||
'key' => $definition['key'],
|
||||
'title' => $definition['title'],
|
||||
'status' => 'skipped',
|
||||
'message' => 'Bereits erfolgreich ausgefuehrt.',
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($current === null && scripts_migration_targets_exist($pdo, $definition)) {
|
||||
$migrationId = scripts_uuid();
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$notes = 'Automatisch als bestehender Stand uebernommen, da die Zieltabellen bereits vorhanden sind.';
|
||||
|
||||
scripts_execute(
|
||||
$pdo,
|
||||
'INSERT INTO app_migrations (id, migration_key, title, checksum, status, executed_at, execution_time_ms, executed_by, notes, error_message, created_at, updated_at) VALUES (:id, :migration_key, :title, :checksum, :status, :executed_at, 0, :executed_by, :notes, NULL, :created_at, :updated_at)',
|
||||
[
|
||||
'id' => $migrationId,
|
||||
'migration_key' => $definition['key'],
|
||||
'title' => $definition['title'],
|
||||
'checksum' => $definition['checksum'],
|
||||
'status' => 'success',
|
||||
'executed_at' => $now,
|
||||
'executed_by' => $executedBy !== null ? $executedBy . ' (baseline)' : 'baseline',
|
||||
'notes' => $notes,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]
|
||||
);
|
||||
|
||||
$results[] = [
|
||||
'key' => $definition['key'],
|
||||
'title' => $definition['title'],
|
||||
'status' => 'baselined',
|
||||
'message' => $notes,
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
$start = microtime(true);
|
||||
$migrationId = (string) ($current['id'] ?? scripts_uuid());
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
scripts_execute(
|
||||
$pdo,
|
||||
'DELETE FROM app_migrations WHERE migration_key = :migration_key',
|
||||
['migration_key' => $definition['key']]
|
||||
);
|
||||
|
||||
scripts_execute(
|
||||
$pdo,
|
||||
'INSERT INTO app_migrations (id, migration_key, title, checksum, status, executed_at, execution_time_ms, executed_by, notes, error_message, created_at, updated_at) VALUES (:id, :migration_key, :title, :checksum, :status, NULL, NULL, :executed_by, NULL, NULL, :created_at, :updated_at)',
|
||||
[
|
||||
'id' => $migrationId,
|
||||
'migration_key' => $definition['key'],
|
||||
'title' => $definition['title'],
|
||||
'checksum' => $definition['checksum'],
|
||||
'status' => 'running',
|
||||
'executed_by' => $executedBy,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]
|
||||
);
|
||||
|
||||
try {
|
||||
$pdo->exec((string) $definition['sql']);
|
||||
$duration = (int) round((microtime(true) - $start) * 1000);
|
||||
|
||||
scripts_execute(
|
||||
$pdo,
|
||||
'UPDATE app_migrations SET status = :status, executed_at = :executed_at, execution_time_ms = :execution_time_ms, notes = :notes, error_message = NULL, updated_at = :updated_at WHERE id = :id',
|
||||
[
|
||||
'status' => 'success',
|
||||
'executed_at' => date('Y-m-d H:i:s'),
|
||||
'execution_time_ms' => $duration,
|
||||
'notes' => 'Migration erfolgreich ausgefuehrt.',
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
'id' => $migrationId,
|
||||
]
|
||||
);
|
||||
|
||||
$results[] = [
|
||||
'key' => $definition['key'],
|
||||
'title' => $definition['title'],
|
||||
'status' => 'success',
|
||||
'message' => 'Erfolgreich ausgefuehrt.',
|
||||
];
|
||||
} catch (Throwable $exception) {
|
||||
$duration = (int) round((microtime(true) - $start) * 1000);
|
||||
|
||||
scripts_execute(
|
||||
$pdo,
|
||||
'UPDATE app_migrations SET status = :status, executed_at = :executed_at, execution_time_ms = :execution_time_ms, error_message = :error_message, updated_at = :updated_at WHERE id = :id',
|
||||
[
|
||||
'status' => 'failed',
|
||||
'executed_at' => date('Y-m-d H:i:s'),
|
||||
'execution_time_ms' => $duration,
|
||||
'error_message' => $exception->getMessage(),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
'id' => $migrationId,
|
||||
]
|
||||
);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
function scripts_role_id(string $roleKey, string $scope): string
|
||||
{
|
||||
return scripts_uuid_from_string('role:' . $scope . ':' . $roleKey);
|
||||
|
||||
Reference in New Issue
Block a user